mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-05 10:12:44 +02:00
Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2e012d7b1 | |||
| d4fe0be386 | |||
| 6d947bbc29 | |||
| 5187d0e55f | |||
| c6253e4fd4 | |||
| 1ab933c8b0 | |||
| e2e5dd372a | |||
| 3e98b6f749 | |||
| 3c465994fe | |||
| 6cfe583535 | |||
| 0ad7a98fc7 | |||
| a8d5b543d7 | |||
| f2e16017f6 | |||
| 4d227cbade | |||
| 15a85299b9 | |||
| d22e9e32ed | |||
| 8beac53f5f | |||
| cbad435690 | |||
| 169b637720 | |||
| f083d4b5f6 | |||
| 3451a312e9 | |||
| 927c1a3514 | |||
| dabcad5ebd | |||
| 796602d1b2 | |||
| 302870a101 | |||
| 3954aa1963 | |||
| 2d8c840ad6 | |||
| f1f02b185e | |||
| 13d21e90f8 | |||
| dd664da871 | |||
| 6ff66370fe | |||
| 23904d57ad | |||
| efdb43e2d2 | |||
| 67523095d6 | |||
| e2d869bb19 | |||
| d38e9499db | |||
| c7429efe95 | |||
| b925dbcc95 | |||
| 2a235b8324 | |||
| 06cc2a1b21 | |||
| 4bcca97b1f | |||
| 313b9026f1 | |||
| 139ee013a7 | |||
| 7e5ab477b2 | |||
| eba37c46cb | |||
| 228d9cc301 | |||
| 85946dd1d5 | |||
| b40598593d | |||
| e918a46d09 | |||
| 8061ee29d5 | |||
| e15e04f085 | |||
| 958d68ffa9 | |||
| c8a743ccc1 | |||
| 09dc95f560 | |||
| 853858825b | |||
| c962090c3a | |||
| 63a8e2433e | |||
| f78d287b59 | |||
| eaa383b6d8 | |||
| 113026ce13 | |||
| 578a946ca5 | |||
| f31306eda0 | |||
| c62b716a2c | |||
| 97ed20c683 | |||
| d5c46dcbfb | |||
| 30934edd57 | |||
| d06fd1a1b1 | |||
| 6bb36381f1 | |||
| a1331fb3f8 | |||
| 17d15144eb | |||
| 74d26eece4 | |||
| 474a7d08d0 | |||
| 639c930779 | |||
| c6323f8ad9 | |||
| caea6c6371 | |||
| d285845e04 | |||
| 5a6867e98a | |||
| 621444114f | |||
| 5591704aad | |||
| cc1181b301 | |||
| 095f49824e | |||
| b330030f50 | |||
| a7d422e23f | |||
| f51a31c8ca | |||
| 290340a385 | |||
| 0137f6dfeb | |||
| 7f27eabf3e | |||
| 4f7588c87d | |||
| a19b6370c4 | |||
| fbd7ae10d1 | |||
| f94c706fc8 | |||
| 9de4b1069a | |||
| 8fbe3c3884 | |||
| abf9120363 | |||
| 69f250cba5 | |||
| 2103edfcdc | |||
| 02ba147bd4 | |||
| 230b548921 |
@@ -0,0 +1,4 @@
|
|||||||
|
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16
|
||||||
|
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||||
|
&& apt-get install ffmpeg gnupg2 -y
|
||||||
|
ENV NODE_ENV=development
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"build": { "dockerfile": "Dockerfile" },
|
||||||
|
"mounts": [
|
||||||
|
"source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
|
||||||
|
],
|
||||||
|
"features": {
|
||||||
|
"fish": "latest"
|
||||||
|
},
|
||||||
|
"extensions": [
|
||||||
|
"eamodio.gitlens"
|
||||||
|
]
|
||||||
|
}
|
||||||
+1
-1
@@ -14,6 +14,6 @@ COPY index.js index.js
|
|||||||
COPY package-lock.json package-lock.json
|
COPY package-lock.json package-lock.json
|
||||||
COPY package.json package.json
|
COPY package.json package.json
|
||||||
COPY server server
|
COPY server server
|
||||||
RUN npm ci --production
|
RUN npm ci --only=production
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
||||||
|
|||||||
@@ -2,49 +2,11 @@
|
|||||||
set -e
|
set -e
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
||||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
|
ABS_LOG_DIR="/var/log/audiobookshelf"
|
||||||
|
|
||||||
declare -r init_type='auto'
|
declare -r init_type='auto'
|
||||||
declare -ri no_rebuild='0'
|
declare -ri no_rebuild='0'
|
||||||
|
|
||||||
add_user() {
|
|
||||||
: "${1:?'User was not defined'}"
|
|
||||||
declare -r user="$1"
|
|
||||||
declare -r uid="$2"
|
|
||||||
|
|
||||||
if [ -z "$uid" ]; then
|
|
||||||
declare -r uid_flags=""
|
|
||||||
else
|
|
||||||
declare -r uid_flags="--uid $uid"
|
|
||||||
fi
|
|
||||||
|
|
||||||
declare -r group="${3:-$user}"
|
|
||||||
declare -r descr="${4:-No description}"
|
|
||||||
declare -r shell="${5:-/bin/false}"
|
|
||||||
|
|
||||||
if ! getent passwd | grep -q "^$user:"; then
|
|
||||||
echo "Creating system user: $user in $group with $descr and shell $shell"
|
|
||||||
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
add_group() {
|
|
||||||
: "${1:?'Group was not defined'}"
|
|
||||||
declare -r group="$1"
|
|
||||||
declare -r gid="$2"
|
|
||||||
|
|
||||||
if [ -z "$gid" ]; then
|
|
||||||
declare -r gid_flags=""
|
|
||||||
else
|
|
||||||
declare -r gid_flags="--gid $gid"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! getent group | grep -q "^$group:" ; then
|
|
||||||
echo "Creating system group: $group"
|
|
||||||
groupadd $gid_flags --system $group
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
start_service () {
|
start_service () {
|
||||||
: "${1:?'Service name was not defined'}"
|
: "${1:?'Service name was not defined'}"
|
||||||
declare -r service_name="$1"
|
declare -r service_name="$1"
|
||||||
@@ -76,13 +38,10 @@ start_service () {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Create log directory if not there and set ownership
|
||||||
add_group 'audiobookshelf' ''
|
if [ ! -d "$ABS_LOG_DIR" ]; then
|
||||||
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
mkdir -p "$ABS_LOG_DIR"
|
||||||
|
chown -R 'audiobookshelf:audiobookshelf' "$ABS_LOG_DIR"
|
||||||
mkdir -p '/var/log/audiobookshelf'
|
fi
|
||||||
chown -R 'audiobookshelf:audiobookshelf' '/var/log/audiobookshelf'
|
|
||||||
chown -R 'audiobookshelf:audiobookshelf' '/usr/share/audiobookshelf'
|
|
||||||
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
|
|
||||||
|
|
||||||
start_service 'audiobookshelf'
|
start_service 'audiobookshelf'
|
||||||
|
|||||||
+58
-75
@@ -2,13 +2,51 @@
|
|||||||
set -e
|
set -e
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
||||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg/"
|
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg"
|
||||||
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
|
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
|
||||||
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
|
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||||
DEFAULT_PORT=7331
|
DEFAULT_PORT=7331
|
||||||
DEFAULT_HOST="0.0.0.0"
|
DEFAULT_HOST="0.0.0.0"
|
||||||
|
|
||||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
|
||||||
|
|
||||||
|
add_user() {
|
||||||
|
: "${1:?'User was not defined'}"
|
||||||
|
declare -r user="$1"
|
||||||
|
declare -r uid="$2"
|
||||||
|
|
||||||
|
if [ -z "$uid" ]; then
|
||||||
|
declare -r uid_flags=""
|
||||||
|
else
|
||||||
|
declare -r uid_flags="--uid $uid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
declare -r group="${3:-$user}"
|
||||||
|
declare -r descr="${4:-No description}"
|
||||||
|
declare -r shell="${5:-/bin/false}"
|
||||||
|
|
||||||
|
if ! getent passwd | grep -q "^$user:"; then
|
||||||
|
echo "Creating system user: $user in $group with $descr and shell $shell"
|
||||||
|
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
add_group() {
|
||||||
|
: "${1:?'Group was not defined'}"
|
||||||
|
declare -r group="$1"
|
||||||
|
declare -r gid="$2"
|
||||||
|
|
||||||
|
if [ -z "$gid" ]; then
|
||||||
|
declare -r gid_flags=""
|
||||||
|
else
|
||||||
|
declare -r gid_flags="--gid $gid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! getent group | grep -q "^$group:" ; then
|
||||||
|
echo "Creating system group: $group"
|
||||||
|
groupadd $gid_flags --system $group
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
install_ffmpeg() {
|
install_ffmpeg() {
|
||||||
echo "Starting FFMPEG Install"
|
echo "Starting FFMPEG Install"
|
||||||
@@ -16,8 +54,9 @@ install_ffmpeg() {
|
|||||||
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"
|
||||||
|
|
||||||
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
||||||
echo "WARNING: can't access working directory ($FFMPEG_INSTALL_DIR) creating it" >&2
|
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
|
||||||
mkdir "$FFMPEG_INSTALL_DIR"
|
mkdir "$FFMPEG_INSTALL_DIR"
|
||||||
|
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
|
||||||
cd "$FFMPEG_INSTALL_DIR"
|
cd "$FFMPEG_INSTALL_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -28,83 +67,23 @@ install_ffmpeg() {
|
|||||||
echo "Good to go on Ffmpeg... hopefully"
|
echo "Good to go on Ffmpeg... hopefully"
|
||||||
}
|
}
|
||||||
|
|
||||||
should_build_config() {
|
|
||||||
if [ -f "$CONFIG_PATH" ]; then
|
|
||||||
echo "You already have a config file. Do you want to use it?"
|
|
||||||
|
|
||||||
options=("Yes" "No")
|
|
||||||
select yn in "${options[@]}"
|
|
||||||
do
|
|
||||||
case $yn in
|
|
||||||
"Yes")
|
|
||||||
false; return
|
|
||||||
;;
|
|
||||||
"No")
|
|
||||||
true; return
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo "No existing config found in $CONFIG_PATH"
|
|
||||||
true; return
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
setup_config_interactive() {
|
|
||||||
if should_build_config; then
|
|
||||||
echo "Okay, let's setup a new config."
|
|
||||||
|
|
||||||
AUDIOBOOK_PATH=""
|
|
||||||
read -p "
|
|
||||||
Enter path for your audiobooks [Default: $DEFAULT_AUDIOBOOK_PATH]:" AUDIOBOOK_PATH
|
|
||||||
|
|
||||||
if [[ -z "$AUDIOBOOK_PATH" ]]; then
|
|
||||||
AUDIOBOOK_PATH="$DEFAULT_AUDIOBOOK_PATH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
DATA_PATH=""
|
|
||||||
read -p "
|
|
||||||
Enter path for data files, i.e. streams, downloads, database [Default: $DEFAULT_DATA_PATH]:" DATA_PATH
|
|
||||||
|
|
||||||
if [[ -z "$DATA_PATH" ]]; then
|
|
||||||
DATA_PATH="$DEFAULT_DATA_PATH"
|
|
||||||
fi
|
|
||||||
|
|
||||||
PORT=""
|
|
||||||
read -p "
|
|
||||||
Port for the web ui [Default: $DEFAULT_PORT]:" PORT
|
|
||||||
|
|
||||||
if [[ -z "$PORT" ]]; then
|
|
||||||
PORT="$DEFAULT_PORT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
config_text="AUDIOBOOK_PATH=$AUDIOBOOK_PATH
|
|
||||||
METADATA_PATH=$DATA_PATH/metadata
|
|
||||||
CONFIG_PATH=$DATA_PATH/config
|
|
||||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
|
||||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
|
||||||
PORT=$PORT
|
|
||||||
HOST=$DEFAULT_HOST"
|
|
||||||
|
|
||||||
echo "$config_text"
|
|
||||||
|
|
||||||
echo "$config_text" > /etc/default/audiobookshelf;
|
|
||||||
|
|
||||||
echo "Config created"
|
|
||||||
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
else
|
else
|
||||||
|
|
||||||
|
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
|
||||||
|
# Create directory and set permissions
|
||||||
|
echo "Creating default data dir at $DEFAULT_DATA_DIR"
|
||||||
|
mkdir "$DEFAULT_DATA_DIR"
|
||||||
|
chown -R 'audiobookshelf:audiobookshelf' "$DEFAULT_DATA_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Creating default config."
|
echo "Creating default config."
|
||||||
|
|
||||||
config_text="AUDIOBOOK_PATH=$DEFAULT_AUDIOBOOK_PATH
|
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
|
||||||
METADATA_PATH=$DEFAULT_DATA_PATH/metadata
|
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
||||||
CONFIG_PATH=$DEFAULT_DATA_PATH/config
|
|
||||||
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
|
||||||
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
FFPROBE_PATH=/usr/lib/audiobookshelf-ffmpeg/ffprobe
|
||||||
PORT=$DEFAULT_PORT
|
PORT=$DEFAULT_PORT
|
||||||
@@ -118,6 +97,10 @@ setup_config() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
add_group 'audiobookshelf' ''
|
||||||
|
|
||||||
|
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
||||||
|
|
||||||
setup_config
|
setup_config
|
||||||
|
|
||||||
install_ffmpeg
|
install_ffmpeg
|
||||||
|
|||||||
+48
-22
@@ -12,18 +12,30 @@
|
|||||||
height: calc(100% - 64px);
|
height: calc(100% - 64px);
|
||||||
max-height: calc(100% - 64px);
|
max-height: calc(100% - 64px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page.streaming {
|
.page.streaming {
|
||||||
height: calc(100% - 64px - 165px);
|
height: calc(100% - 64px - 165px);
|
||||||
max-height: calc(100% - 64px - 165px);
|
max-height: calc(100% - 64px - 165px);
|
||||||
}
|
}
|
||||||
|
|
||||||
#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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bookshelf-row {
|
||||||
|
/* Sidebar width + scrollbar width */
|
||||||
|
width: calc(100vw - 88px);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
#bookshelf {
|
#bookshelf {
|
||||||
height: calc(100% - 80px);
|
height: calc(100% - 80px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bookshelf-row {
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#page-wrapper {
|
#page-wrapper {
|
||||||
@@ -34,33 +46,22 @@
|
|||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar:horizontal {
|
::-webkit-scrollbar:horizontal {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
/* ::-webkit-scrollbar:horizontal { */
|
|
||||||
/* height: 16px; */
|
|
||||||
/* height: 24px;
|
|
||||||
} */
|
|
||||||
/* Track */
|
/* Track */
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background-color: rgba(0,0,0,0);
|
background-color: rgba(0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
/* ::-webkit-scrollbar-track:horizontal { */
|
|
||||||
/* background: rgb(149, 119, 90); */
|
|
||||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
|
||||||
/* background: linear-gradient(180deg, rgb(117, 88, 60) 0%, rgb(65, 41, 17) 17%, rgb(71, 43, 15) 88%, rgb(3, 2, 1) 100%);
|
|
||||||
box-shadow: 2px 14px 8px #111111aa;
|
|
||||||
} */
|
|
||||||
/* Handle */
|
/* Handle */
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #855620;
|
background: #855620;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
/* ::-webkit-scrollbar-thumb:horizontal { */
|
|
||||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
|
||||||
/* box-shadow: 2px 14px 8px #111111aa;
|
|
||||||
border-radius: 4px;
|
|
||||||
} */
|
|
||||||
/* Handle on hover */
|
/* Handle on hover */
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #704922;
|
background: #704922;
|
||||||
@@ -71,6 +72,13 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-scroll {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
/* IE and Edge */
|
||||||
|
scrollbar-width: none;
|
||||||
|
/* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
/* Chrome, Safari, Edge, Opera */
|
/* Chrome, Safari, Edge, Opera */
|
||||||
.no-spinner::-webkit-outer-spin-button,
|
.no-spinner::-webkit-outer-spin-button,
|
||||||
.no-spinner::-webkit-inner-spin-button {
|
.no-spinner::-webkit-inner-spin-button {
|
||||||
@@ -89,18 +97,23 @@ input[type=number] {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid #474747;
|
border: 1px solid #474747;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable tr:nth-child(even) {
|
.tracksTable tr:nth-child(even) {
|
||||||
background-color: #2e2e2e;
|
background-color: #2e2e2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable tr {
|
.tracksTable tr {
|
||||||
background-color: #373838;
|
background-color: #373838;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable tr:hover {
|
.tracksTable tr:hover {
|
||||||
background-color: #474747;
|
background-color: #474747;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable td {
|
.tracksTable td {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracksTable th {
|
.tracksTable th {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -113,13 +126,22 @@ input[type=number] {
|
|||||||
border-right: 6px solid transparent;
|
border-right: 6px solid transparent;
|
||||||
border-top: 6px solid white;
|
border-top: 6px solid white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.arrow-down-small {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
border-right: 4px solid transparent;
|
||||||
|
border-top: 4px solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
.triangle-right {
|
.triangle-right {
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
border-left: 8px solid transparent;
|
border-left: 8px solid transparent;
|
||||||
border-bottom: 8px solid transparent;
|
border-bottom: 8px solid transparent;
|
||||||
border-top: 8px solid rgb(34,127,35);
|
border-top: 8px solid rgb(34, 127, 35);
|
||||||
border-right: 8px solid rgb(34,127,35);
|
border-right: 8px solid rgb(34, 127, 35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-text {
|
.icon-text {
|
||||||
@@ -149,6 +171,7 @@ input[type=number] {
|
|||||||
.box-shadow-book {
|
.box-shadow-book {
|
||||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shadow-height {
|
.shadow-height {
|
||||||
height: calc(100% - 4px);
|
height: calc(100% - 4px);
|
||||||
}
|
}
|
||||||
@@ -165,9 +188,9 @@ input[type=number] {
|
|||||||
Bookshelf Label
|
Bookshelf Label
|
||||||
*/
|
*/
|
||||||
.categoryPlacard {
|
.categoryPlacard {
|
||||||
background-image: url(https://image.freepik.com/free-photo/brown-wooden-textured-flooring-background_53876-128537.jpg);
|
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shinyBlack {
|
.shinyBlack {
|
||||||
background-color: #2d3436;
|
background-color: #2d3436;
|
||||||
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
|
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
|
||||||
@@ -194,8 +217,11 @@ Bookshelf Label
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
line-height: 16px; /* fallback */
|
line-height: 16px;
|
||||||
max-height: 32px; /* fallback */
|
/* fallback */
|
||||||
-webkit-line-clamp: 2; /* number of lines to show */
|
max-height: 32px;
|
||||||
|
/* fallback */
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
/* number of lines to show */
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
@@ -153,9 +153,6 @@ export default {
|
|||||||
},
|
},
|
||||||
currentChapterName() {
|
currentChapterName() {
|
||||||
return this.currentChapter ? this.currentChapter.title : ''
|
return this.currentChapter ? this.currentChapter.title : ''
|
||||||
},
|
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -2,15 +2,17 @@
|
|||||||
<div class="w-full h-16 bg-primary relative">
|
<div class="w-full h-16 bg-primary relative">
|
||||||
<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">
|
||||||
<img v-if="!showBack" src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" />
|
<nuxt-link to="/">
|
||||||
<a v-if="showBack" @click="back" class="rounded-full h-12 w-12 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-4 cursor-pointer">
|
<img src="/icon48.png" class="w-10 h-10 md:w-12 md:h-12 mr-4" />
|
||||||
<span class="material-icons text-4xl text-white">arrow_back</span>
|
</nuxt-link>
|
||||||
</a>
|
|
||||||
<h1 class="text-2xl font-book mr-6 hidden lg:block">audiobookshelf</h1>
|
<nuxt-link to="/">
|
||||||
|
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
|
||||||
|
</nuxt-link>
|
||||||
|
|
||||||
<ui-libraries-dropdown />
|
<ui-libraries-dropdown />
|
||||||
|
|
||||||
<controls-global-search class="hidden md:block" />
|
<controls-global-search v-if="currentLibrary" class="hidden md:block" />
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
||||||
@@ -22,11 +24,11 @@
|
|||||||
<google-cast-launcher></google-cast-launcher>
|
<google-cast-launcher></google-cast-launcher>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||||
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
|
<span class="material-icons" aria-label="User Stats" role="button">equalizer</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||||
<span class="material-icons" aria-label="Upload Media" role="button">upload</span>
|
<span class="material-icons" aria-label="Upload Media" role="button">upload</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
@@ -94,9 +96,6 @@ export default {
|
|||||||
isHome() {
|
isHome() {
|
||||||
return this.$route.name === 'library-library'
|
return this.$route.name === 'library-library'
|
||||||
},
|
},
|
||||||
showBack() {
|
|
||||||
return this.$route.name !== 'library-library-bookshelf-id' && !this.isHome
|
|
||||||
},
|
|
||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
@@ -151,12 +150,6 @@ export default {
|
|||||||
toggleBookshelfTexture() {
|
toggleBookshelfTexture() {
|
||||||
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
|
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
|
||||||
},
|
},
|
||||||
async back() {
|
|
||||||
var popped = await this.$store.dispatch('popRoute')
|
|
||||||
if (popped) this.$store.commit('setIsRoutingBack', true)
|
|
||||||
var backTo = popped || '/'
|
|
||||||
this.$router.push(backTo)
|
|
||||||
},
|
|
||||||
cancelSelectionMode() {
|
cancelSelectionMode() {
|
||||||
if (this.processingBatchDelete) return
|
if (this.processingBatchDelete) return
|
||||||
this.$store.commit('setSelectedLibraryItems', [])
|
this.$store.commit('setSelectedLibraryItems', [])
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
|
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
|
||||||
<!-- Cover size widget -->
|
<!-- Cover size widget -->
|
||||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||||
<!-- Experimental Bookshelf Texture -->
|
<!-- Experimental Bookshelf Texture -->
|
||||||
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40">
|
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
|
||||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
|
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal"><p class="text-sm py-0.5">Texture</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -17,7 +17,25 @@
|
|||||||
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
|
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
|
||||||
<p class="text-center text-xl font-book py-4">No results for query</p>
|
<p class="text-center text-xl font-book py-4">No results for query</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="w-full flex flex-col items-center">
|
<!-- Alternate plain view -->
|
||||||
|
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
||||||
|
<template v-for="(shelf, index) in shelves">
|
||||||
|
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||||
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||||
|
</widgets-item-slider>
|
||||||
|
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||||
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||||
|
</widgets-episode-slider>
|
||||||
|
<widgets-series-slider v-else-if="shelf.type === 'series'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||||
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||||
|
</widgets-series-slider>
|
||||||
|
<widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||||
|
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ shelf.label }}</p>
|
||||||
|
</widgets-authors-slider>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<!-- Regular bookshelf view -->
|
||||||
|
<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="bookCoverAspectRatio" />
|
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</template>
|
</template>
|
||||||
@@ -56,6 +74,12 @@ export default {
|
|||||||
libraryName() {
|
libraryName() {
|
||||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||||
},
|
},
|
||||||
|
bookshelfView() {
|
||||||
|
return this.$store.getters['getServerSetting']('bookshelfView')
|
||||||
|
},
|
||||||
|
isAlternativeBookshelfView() {
|
||||||
|
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||||
|
},
|
||||||
bookCoverWidth() {
|
bookCoverWidth() {
|
||||||
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||||
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
||||||
@@ -166,7 +190,15 @@ export default {
|
|||||||
},
|
},
|
||||||
settingsUpdated(settings) {},
|
settingsUpdated(settings) {},
|
||||||
scan() {
|
scan() {
|
||||||
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
this.$store
|
||||||
|
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Library scan started')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to start scan', error)
|
||||||
|
this.$toast.error('Failed to start scan')
|
||||||
|
})
|
||||||
},
|
},
|
||||||
libraryItemAdded(libraryItem) {
|
libraryItemAdded(libraryItem) {
|
||||||
console.log('libraryItem added', libraryItem)
|
console.log('libraryItem added', libraryItem)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div ref="shelf" class="w-full max-w-full categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
|
<div ref="shelf" class="w-full max-w-full bookshelf-row categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
|
||||||
<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">
|
||||||
@@ -17,18 +17,9 @@
|
|||||||
<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">
|
|
||||||
<nuxt-link :key="entity.name" :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(entity.name)}`">
|
|
||||||
<cards-group-card is-categorized :width="bookCoverWidth" :group="entity" :book-cover-aspect-ratio="bookCoverAspectRatio" @hook:updated="updatedBookCard" />
|
|
||||||
</nuxt-link>
|
|
||||||
</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">
|
||||||
<nuxt-link :key="entity.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(entity.id)}`">
|
<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 :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
|
||||||
</nuxt-link>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,7 +39,6 @@
|
|||||||
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight">
|
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight">
|
||||||
<span class="material-icons text-6xl text-white">chevron_right</span>
|
<span class="material-icons text-6xl text-white">chevron_right</span>
|
||||||
</div>
|
</div>
|
||||||
<modals-authors-edit-modal v-model="showAuthorModal" :author="selectedAuthor" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -70,9 +60,7 @@ export default {
|
|||||||
canScrollLeft: false,
|
canScrollLeft: false,
|
||||||
isScrolling: false,
|
isScrolling: false,
|
||||||
scrollTimer: null,
|
scrollTimer: null,
|
||||||
updateTimer: null,
|
updateTimer: null
|
||||||
showAuthorModal: false,
|
|
||||||
selectedAuthor: null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -98,8 +86,7 @@ export default {
|
|||||||
this.updateSelectionMode(false)
|
this.updateSelectionMode(false)
|
||||||
},
|
},
|
||||||
editAuthor(author) {
|
editAuthor(author) {
|
||||||
this.selectedAuthor = author
|
this.$store.commit('globals/showEditAuthorModal', author)
|
||||||
this.showAuthorModal = true
|
|
||||||
},
|
},
|
||||||
editItem(libraryItem) {
|
editItem(libraryItem) {
|
||||||
var itemIds = this.shelf.entities.map((e) => e.id)
|
var itemIds = this.shelf.entities.map((e) => e.id)
|
||||||
@@ -197,25 +184,13 @@ export default {
|
|||||||
<style>
|
<style>
|
||||||
.categorizedBookshelfRow {
|
.categorizedBookshelfRow {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
width: calc(100vw - 80px);
|
|
||||||
|
|
||||||
/* background-color: rgb(214, 116, 36); */
|
|
||||||
background-image: var(--bookshelf-texture-img);
|
background-image: var(--bookshelf-texture-img);
|
||||||
/* background-position: center; */
|
|
||||||
/* background-size: contain; */
|
|
||||||
background-repeat: repeat-x;
|
background-repeat: repeat-x;
|
||||||
}
|
}
|
||||||
@media (max-width: 768px) {
|
|
||||||
.categorizedBookshelfRow {
|
|
||||||
width: 100vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bookshelfDividerCategorized {
|
.bookshelfDividerCategorized {
|
||||||
background: rgb(149, 119, 90);
|
background: rgb(149, 119, 90);
|
||||||
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
|
|
||||||
background: linear-gradient(180deg, rgb(122, 94, 68) 0%, rgb(92, 62, 31) 17%, rgb(82, 54, 26) 88%, rgba(71, 48, 25, 1) 100%);
|
background: linear-gradient(180deg, rgb(122, 94, 68) 0%, rgb(92, 62, 31) 17%, rgb(82, 54, 26) 88%, rgba(71, 48, 25, 1) 100%);
|
||||||
/* background: linear-gradient(180deg, rgb(114, 85, 59) 0%, rgb(73, 48, 22) 17%, rgb(71, 43, 15) 88%, rgb(61, 41, 20) 100%); */
|
|
||||||
box-shadow: 2px 14px 8px #111111aa;
|
box-shadow: 2px 14px 8px #111111aa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export default {
|
|||||||
return this.$store.getters['user/getUserSetting']('filterBy')
|
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||||
},
|
},
|
||||||
isIssuesFilter() {
|
isIssuesFilter() {
|
||||||
return this.filterBy === 'issues'
|
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -9,9 +9,13 @@
|
|||||||
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<div class="w-full h-10 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
|
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
|
||||||
<p class="font-mono text-sm">v{{ $config.version }}</p>
|
<div class="flex justify-between">
|
||||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-sm">Update available: {{ latestVersion }}</a>
|
<p class="font-mono text-sm">v{{ $config.version }}</p>
|
||||||
|
|
||||||
|
<p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p>
|
||||||
|
</div>
|
||||||
|
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -25,6 +29,12 @@ export default {
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
Source() {
|
||||||
|
return this.$store.state.Source
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
@@ -38,7 +48,7 @@ export default {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
return [
|
const configRoutes = [
|
||||||
{
|
{
|
||||||
id: 'config',
|
id: 'config',
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
@@ -63,18 +73,23 @@ export default {
|
|||||||
id: 'config-log',
|
id: 'config-log',
|
||||||
title: 'Log',
|
title: 'Log',
|
||||||
path: '/config/log'
|
path: '/config/log'
|
||||||
},
|
}
|
||||||
{
|
]
|
||||||
|
|
||||||
|
if (this.currentLibraryId) {
|
||||||
|
configRoutes.push({
|
||||||
id: 'config-library-stats',
|
id: 'config-library-stats',
|
||||||
title: 'Library Stats',
|
title: 'Library Stats',
|
||||||
path: '/config/library-stats'
|
path: '/config/library-stats'
|
||||||
},
|
})
|
||||||
{
|
configRoutes.push({
|
||||||
id: 'config-stats',
|
id: 'config-stats',
|
||||||
title: 'Your Stats',
|
title: 'Your Stats',
|
||||||
path: '/config/stats'
|
path: '/config/stats'
|
||||||
}
|
})
|
||||||
]
|
}
|
||||||
|
|
||||||
|
return configRoutes
|
||||||
},
|
},
|
||||||
wrapperClass() {
|
wrapperClass() {
|
||||||
var classes = []
|
var classes = []
|
||||||
|
|||||||
@@ -22,8 +22,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||||
|
|
||||||
<!-- Experimental Bookshelf Texture -->
|
<!-- Experimental Bookshelf Texture -->
|
||||||
<div v-show="showExperimentalFeatures" class="fixed bottom-4 right-28 z-40">
|
<div v-show="showExperimentalFeatures && !isAlternativeBookshelfView" class="fixed bottom-4 right-28 z-40">
|
||||||
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
|
<div class="rounded-full py-1 bg-primary hover:bg-bg cursor-pointer px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent @click="showBookshelfTextureModal">
|
||||||
<p class="text-sm py-0.5">Texture</p>
|
<p class="text-sm py-0.5">Texture</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,6 +43,7 @@ export default {
|
|||||||
mixins: [bookshelfCardsHelpers],
|
mixins: [bookshelfCardsHelpers],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
routeFullPath: null,
|
||||||
initialized: false,
|
initialized: false,
|
||||||
bookshelfHeight: 0,
|
bookshelfHeight: 0,
|
||||||
bookshelfWidth: 0,
|
bookshelfWidth: 0,
|
||||||
@@ -126,7 +128,7 @@ export default {
|
|||||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||||
},
|
},
|
||||||
isAlternativeBookshelfView() {
|
isAlternativeBookshelfView() {
|
||||||
if (!this.isEntityBook) return false // Only used for bookshelf showing books
|
// if (!this.isEntityBook) return false // Only used for bookshelf showing books
|
||||||
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
return this.bookshelfView === this.$constants.BookshelfView.TITLES
|
||||||
},
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
@@ -185,7 +187,10 @@ export default {
|
|||||||
return 6
|
return 6
|
||||||
},
|
},
|
||||||
shelfHeight() {
|
shelfHeight() {
|
||||||
if (this.isAlternativeBookshelfView) return this.entityHeight + 80 * this.sizeMultiplier
|
if (this.isAlternativeBookshelfView) {
|
||||||
|
var extraTitleSpace = this.isEntityBook ? 80 : 40
|
||||||
|
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
|
||||||
|
}
|
||||||
return this.entityHeight + 40
|
return this.entityHeight + 40
|
||||||
},
|
},
|
||||||
totalEntityCardWidth() {
|
totalEntityCardWidth() {
|
||||||
@@ -409,6 +414,8 @@ export default {
|
|||||||
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
|
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
|
||||||
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
|
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
|
||||||
window.history.replaceState({ path: newurl }, '', newurl)
|
window.history.replaceState({ path: newurl }, '', newurl)
|
||||||
|
|
||||||
|
this.routeFullPath = window.location.pathname + (window.location.search || '') // Update for saving scroll position
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,6 +533,15 @@ export default {
|
|||||||
await this.fetchEntites(0)
|
await this.fetchEntites(0)
|
||||||
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
||||||
this.mountEntites(0, lastBookIndex)
|
this.mountEntites(0, lastBookIndex)
|
||||||
|
|
||||||
|
// Set last scroll position for this bookshelf page
|
||||||
|
if (this.$store.state.lastBookshelfScrollData[this.page] && window.bookshelf) {
|
||||||
|
const { path, scrollTop } = this.$store.state.lastBookshelfScrollData[this.page]
|
||||||
|
if (path === this.routeFullPath) {
|
||||||
|
// Exact path match with query so use scroll position
|
||||||
|
window.bookshelf.scrollTop = scrollTop
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
executeRebuild() {
|
executeRebuild() {
|
||||||
clearTimeout(this.resizeTimeout)
|
clearTimeout(this.resizeTimeout)
|
||||||
@@ -601,13 +617,25 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
|
this.$store
|
||||||
|
.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Library scan started')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to start scan', error)
|
||||||
|
this.$toast.error('Failed to start scan')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.initListeners()
|
this.initListeners()
|
||||||
|
|
||||||
|
this.routeFullPath = window.location.pathname + (window.location.search || '')
|
||||||
},
|
},
|
||||||
updated() {
|
updated() {
|
||||||
|
this.routeFullPath = window.location.pathname + (window.location.search || '')
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (window.innerWidth > 0 && window.innerWidth !== this.mountWindowWidth) {
|
if (window.innerWidth > 0 && window.innerWidth !== this.mountWindowWidth) {
|
||||||
console.log('Updated window width', window.innerWidth, 'from', this.mountWindowWidth)
|
console.log('Updated window width', window.innerWidth, 'from', this.mountWindowWidth)
|
||||||
@@ -618,6 +646,11 @@ export default {
|
|||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.destroyEntityComponents()
|
this.destroyEntityComponents()
|
||||||
this.removeListeners()
|
this.removeListeners()
|
||||||
|
|
||||||
|
// Set bookshelf scroll position for specific bookshelf page and query
|
||||||
|
if (window.bookshelf) {
|
||||||
|
this.$store.commit('setLastBookshelfScrollData', { scrollTop: window.bookshelf.scrollTop || 0, path: this.routeFullPath, name: this.page })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px">
|
<!-- <div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px"> -->
|
||||||
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-40" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
|
||||||
|
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
|
||||||
|
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||||
|
|
||||||
<nuxt-link :to="`/library/${currentLibraryId}`" 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="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
<nuxt-link :to="`/library/${currentLibraryId}`" 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="homePage ? '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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
@@ -79,8 +82,12 @@ export default {
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
showExperimentalFeatures() {
|
isShowingBookshelfToolbar() {
|
||||||
return this.$store.state.showExperimentalFeatures
|
if (!this.$route.name) return false
|
||||||
|
return this.$route.name.startsWith('library')
|
||||||
|
},
|
||||||
|
offsetTop() {
|
||||||
|
return 64
|
||||||
},
|
},
|
||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<span class="material-icons text-sm">person</span>
|
<span class="material-icons text-sm">person</span>
|
||||||
<p v-if="podcastAuthor">{{ podcastAuthor }}</p>
|
<p v-if="podcastAuthor">{{ podcastAuthor }}</p>
|
||||||
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
|
<p v-else-if="authors.length" class="pl-1.5 text-sm sm:text-base">
|
||||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
|
<p v-else class="text-sm sm:text-base cursor-pointer pl-2">Unknown</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,9 +74,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
coverAspectRatio() {
|
coverAspectRatio() {
|
||||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||||
},
|
},
|
||||||
@@ -148,6 +145,7 @@ export default {
|
|||||||
setPlaying(isPlaying) {
|
setPlaying(isPlaying) {
|
||||||
this.isPlaying = isPlaying
|
this.isPlaying = isPlaying
|
||||||
this.$store.commit('setIsPlaying', isPlaying)
|
this.$store.commit('setIsPlaying', isPlaying)
|
||||||
|
this.updateMediaSessionPlaybackState()
|
||||||
},
|
},
|
||||||
setSleepTimer(seconds) {
|
setSleepTimer(seconds) {
|
||||||
this.sleepTimerSet = true
|
this.sleepTimerSet = true
|
||||||
@@ -240,6 +238,71 @@ export default {
|
|||||||
this.playerHandler.closePlayer()
|
this.playerHandler.closePlayer()
|
||||||
this.$store.commit('setMediaPlaying', null)
|
this.$store.commit('setMediaPlaying', null)
|
||||||
},
|
},
|
||||||
|
mediaSessionPlay() {
|
||||||
|
console.log('Media session play')
|
||||||
|
this.playerHandler.play()
|
||||||
|
},
|
||||||
|
mediaSessionPause() {
|
||||||
|
console.log('Media session pause')
|
||||||
|
this.playerHandler.pause()
|
||||||
|
},
|
||||||
|
mediaSessionStop() {
|
||||||
|
console.log('Media session stop')
|
||||||
|
this.playerHandler.pause()
|
||||||
|
},
|
||||||
|
mediaSessionSeekBackward() {
|
||||||
|
console.log('Media session seek backward')
|
||||||
|
this.playerHandler.jumpBackward()
|
||||||
|
},
|
||||||
|
mediaSessionSeekForward() {
|
||||||
|
console.log('Media session seek forward')
|
||||||
|
this.playerHandler.jumpForward()
|
||||||
|
},
|
||||||
|
mediaSessionSeekTo(e) {
|
||||||
|
console.log('Media session seek to', e)
|
||||||
|
if (e.seekTime !== null && !isNaN(e.seekTime)) {
|
||||||
|
this.playerHandler.seek(e.seekTime)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateMediaSessionPlaybackState() {
|
||||||
|
if ('mediaSession' in navigator) {
|
||||||
|
navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setMediaSession() {
|
||||||
|
if (!this.streamLibraryItem) {
|
||||||
|
console.error('setMediaSession: No library item set')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('mediaSession' in navigator) {
|
||||||
|
var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png')
|
||||||
|
const artwork = [
|
||||||
|
{
|
||||||
|
src: coverImageSrc
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
title: this.title,
|
||||||
|
artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown',
|
||||||
|
album: this.mediaMetadata.seriesName || '',
|
||||||
|
artwork
|
||||||
|
})
|
||||||
|
console.log('Set media session metadata', navigator.mediaSession.metadata)
|
||||||
|
|
||||||
|
navigator.mediaSession.setActionHandler('play', this.mediaSessionPlay)
|
||||||
|
navigator.mediaSession.setActionHandler('pause', this.mediaSessionPause)
|
||||||
|
navigator.mediaSession.setActionHandler('stop', this.mediaSessionStop)
|
||||||
|
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
||||||
|
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
||||||
|
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
||||||
|
// navigator.mediaSession.setActionHandler('previoustrack')
|
||||||
|
// navigator.mediaSession.setActionHandler('nexttrack')
|
||||||
|
} else {
|
||||||
|
console.warn('Media session not available')
|
||||||
|
}
|
||||||
|
},
|
||||||
streamProgress(data) {
|
streamProgress(data) {
|
||||||
if (!data.numSegments) return
|
if (!data.numSegments) return
|
||||||
var chunks = data.chunks
|
var chunks = data.chunks
|
||||||
@@ -312,7 +375,6 @@ export default {
|
|||||||
libraryItem,
|
libraryItem,
|
||||||
episodeId
|
episodeId
|
||||||
})
|
})
|
||||||
|
|
||||||
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
|
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate)
|
||||||
},
|
},
|
||||||
pauseItem() {
|
pauseItem() {
|
||||||
|
|||||||
@@ -1,32 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div @mouseover="mouseover" @mouseout="mouseout">
|
<nuxt-link :to="`/author/${author.id}`">
|
||||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
<div @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<!-- Image or placeholder -->
|
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||||
<covers-author-image :author="author" />
|
<!-- Image or placeholder -->
|
||||||
|
<covers-author-image :author="author" />
|
||||||
|
|
||||||
<!-- Author name & num books overlay -->
|
<!-- Author name & num books overlay -->
|
||||||
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search icon btn -->
|
<!-- Search icon btn -->
|
||||||
<div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="searchAuthor">
|
<div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor">
|
||||||
<span class="material-icons text-lg">search</span>
|
<span class="material-icons text-lg">search</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform transition-transform hover:scale-125" @click.prevent.stop="$emit('edit', author)">
|
<div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)">
|
||||||
<span class="material-icons text-lg">edit</span>
|
<span class="material-icons text-lg">edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading spinner -->
|
<!-- Loading spinner -->
|
||||||
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
||||||
<widgets-loading-spinner size="" />
|
<widgets-loading-spinner size="" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-show="nameBelow" class="w-full py-1 px-2">
|
||||||
|
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="nameBelow" class="w-full py-1 px-2">
|
</nuxt-link>
|
||||||
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -63,6 +65,9 @@ export default {
|
|||||||
name() {
|
name() {
|
||||||
return this._author.name || ''
|
return this._author.name || ''
|
||||||
},
|
},
|
||||||
|
asin() {
|
||||||
|
return this._author.asin || ''
|
||||||
|
},
|
||||||
numBooks() {
|
numBooks() {
|
||||||
return this._author.numBooks || 0
|
return this._author.numBooks || 0
|
||||||
},
|
},
|
||||||
@@ -74,12 +79,16 @@ export default {
|
|||||||
mouseover() {
|
mouseover() {
|
||||||
this.isHovering = true
|
this.isHovering = true
|
||||||
},
|
},
|
||||||
mouseout() {
|
mouseleave() {
|
||||||
this.isHovering = false
|
this.isHovering = false
|
||||||
},
|
},
|
||||||
async searchAuthor() {
|
async searchAuthor() {
|
||||||
this.searching = true
|
this.searching = true
|
||||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.name }).catch((error) => {
|
const payload = {}
|
||||||
|
if (this.asin) payload.asin = this.asin
|
||||||
|
else payload.q = this.name
|
||||||
|
|
||||||
|
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -109,19 +109,14 @@ export default {
|
|||||||
hasValidCovers() {
|
hasValidCovers() {
|
||||||
var validCovers = this.bookItems.map((bookItem) => bookItem.media.coverPath)
|
var validCovers = this.bookItems.map((bookItem) => bookItem.media.coverPath)
|
||||||
return !!validCovers.length
|
return !!validCovers.length
|
||||||
},
|
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
mouseoverCard() {
|
mouseoverCard() {
|
||||||
this.isHovering = true
|
this.isHovering = true
|
||||||
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(true)
|
|
||||||
},
|
},
|
||||||
mouseleaveCard() {
|
mouseleaveCard() {
|
||||||
this.isHovering = false
|
this.isHovering = false
|
||||||
// if (this.$refs.groupcover) this.$refs.groupcover.setHover(false)
|
|
||||||
},
|
},
|
||||||
clickCard() {
|
clickCard() {
|
||||||
this.$emit('click', this.group)
|
this.$emit('click', this.group)
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alternative bookshelf title/author/sort -->
|
<!-- Alternative bookshelf title/author/sort -->
|
||||||
<div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||||
{{ displayTitle }}
|
{{ displayTitle }}
|
||||||
</p>
|
</p>
|
||||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor || ' ' }}</p>
|
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
||||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-150 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
||||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- More Menu Icon -->
|
<!-- More Menu Icon -->
|
||||||
<div ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-100" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
<div ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
||||||
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,6 +147,9 @@ export default {
|
|||||||
showExperimentalFeatures() {
|
showExperimentalFeatures() {
|
||||||
return this.store.state.showExperimentalFeatures
|
return this.store.state.showExperimentalFeatures
|
||||||
},
|
},
|
||||||
|
enableEReader() {
|
||||||
|
return this.store.getters['getServerSetting']('enableEReader')
|
||||||
|
},
|
||||||
_libraryItem() {
|
_libraryItem() {
|
||||||
return this.libraryItem || {}
|
return this.libraryItem || {}
|
||||||
},
|
},
|
||||||
@@ -247,8 +250,11 @@ export default {
|
|||||||
}
|
}
|
||||||
return this.title
|
return this.title
|
||||||
},
|
},
|
||||||
displayAuthor() {
|
displayLineTwo() {
|
||||||
if (this.isPodcast) return this.author
|
if (this.isPodcast) return this.author
|
||||||
|
if (this.isAuthorBookshelfView) {
|
||||||
|
return this.mediaMetadata.publishedYear || ''
|
||||||
|
}
|
||||||
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
||||||
return this.author
|
return this.author
|
||||||
},
|
},
|
||||||
@@ -284,13 +290,13 @@ export default {
|
|||||||
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
||||||
},
|
},
|
||||||
showReadButton() {
|
showReadButton() {
|
||||||
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
|
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
|
||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
||||||
},
|
},
|
||||||
showSmallEBookIcon() {
|
showSmallEBookIcon() {
|
||||||
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
|
return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
|
||||||
},
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this._libraryItem.isMissing
|
return this._libraryItem.isMissing
|
||||||
@@ -424,8 +430,12 @@ export default {
|
|||||||
var constants = this.$constants || this.$nuxt.$constants
|
var constants = this.$constants || this.$nuxt.$constants
|
||||||
return this.bookshelfView === constants.BookshelfView.TITLES
|
return this.bookshelfView === constants.BookshelfView.TITLES
|
||||||
},
|
},
|
||||||
|
isAuthorBookshelfView() {
|
||||||
|
var constants = this.$constants || this.$nuxt.$constants
|
||||||
|
return this.bookshelfView === constants.BookshelfView.AUTHOR
|
||||||
|
},
|
||||||
titleDisplayBottomOffset() {
|
titleDisplayBottomOffset() {
|
||||||
if (!this.isAlternativeBookshelfView) return 0
|
if (!this.isAlternativeBookshelfView && !this.isAuthorBookshelfView) return 0
|
||||||
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
||||||
return 4.25 * this.sizeMultiplier
|
return 4.25 * this.sizeMultiplier
|
||||||
}
|
}
|
||||||
@@ -435,7 +445,34 @@ export default {
|
|||||||
this.isSelectionMode = val
|
this.isSelectionMode = val
|
||||||
if (!val) this.selected = false
|
if (!val) this.selected = false
|
||||||
},
|
},
|
||||||
setEntity(libraryItem) {
|
setEntity(_libraryItem) {
|
||||||
|
var libraryItem = _libraryItem
|
||||||
|
|
||||||
|
// this code block is only necessary when showing a selected series with sequence #
|
||||||
|
// it will update the selected series so we get realtime updates for series sequence changes
|
||||||
|
if (this.series) {
|
||||||
|
// i know.. but the libraryItem passed to this func cannot be modified so we need to create a copy
|
||||||
|
libraryItem = {
|
||||||
|
..._libraryItem,
|
||||||
|
media: {
|
||||||
|
..._libraryItem.media,
|
||||||
|
metadata: {
|
||||||
|
..._libraryItem.media.metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var mediaMetadata = libraryItem.media.metadata
|
||||||
|
if (mediaMetadata.series) {
|
||||||
|
var newSeries = mediaMetadata.series.find((se) => se.id === this.series.id)
|
||||||
|
if (newSeries) {
|
||||||
|
// update selected series
|
||||||
|
libraryItem.media.metadata.series = newSeries
|
||||||
|
this.libraryItem = libraryItem
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.libraryItem = libraryItem
|
this.libraryItem = libraryItem
|
||||||
},
|
},
|
||||||
clickCard(e) {
|
clickCard(e) {
|
||||||
|
|||||||
@@ -5,20 +5,18 @@
|
|||||||
<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" 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', left: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="toggleSelected">
|
|
||||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">radio_button_unchecked</span>
|
|
||||||
</div> -->
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div v-if="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
|
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||||
</div> -->
|
|
||||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||||
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -28,7 +26,11 @@ export default {
|
|||||||
index: Number,
|
index: Number,
|
||||||
width: Number,
|
width: Number,
|
||||||
height: Number,
|
height: Number,
|
||||||
bookCoverAspectRatio: Number
|
bookCoverAspectRatio: Number,
|
||||||
|
bookshelfView: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -58,6 +60,10 @@ export default {
|
|||||||
},
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.store.state.libraries.currentLibraryId
|
return this.store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
isAlternativeBookshelfView() {
|
||||||
|
const constants = this.$constants || this.$nuxt.$constants
|
||||||
|
return this.bookshelfView == constants.BookshelfView.TITLES
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -13,11 +13,14 @@
|
|||||||
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||||
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -28,6 +31,10 @@ export default {
|
|||||||
width: Number,
|
width: Number,
|
||||||
height: Number,
|
height: Number,
|
||||||
bookCoverAspectRatio: Number,
|
bookCoverAspectRatio: Number,
|
||||||
|
bookshelfView: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
isCategorized: Boolean,
|
isCategorized: Boolean,
|
||||||
seriesMount: {
|
seriesMount: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -89,6 +96,10 @@ export default {
|
|||||||
hasValidCovers() {
|
hasValidCovers() {
|
||||||
var validCovers = this.books.map((bookItem) => bookItem.media.coverPath)
|
var validCovers = this.books.map((bookItem) => bookItem.media.coverPath)
|
||||||
return !!validCovers.length
|
return !!validCovers.length
|
||||||
|
},
|
||||||
|
isAlternativeBookshelfView() {
|
||||||
|
const constants = this.$constants || this.$nuxt.$constants
|
||||||
|
return this.bookshelfView == constants.BookshelfView.TITLES
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<form @submit.prevent="submitSearch">
|
|
||||||
<div class="flex items-center justify-start -mx-1 h-20">
|
|
||||||
<!-- <div class="w-40 px-1">
|
|
||||||
<ui-dropdown v-model="provider" :items="providers" label="Provider" small />
|
|
||||||
</div> -->
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label v-model="searchAuthor" label="Author" />
|
|
||||||
</div>
|
|
||||||
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
authorName: String
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
searchAuthor: null,
|
|
||||||
lastSearch: null,
|
|
||||||
isProcessing: false,
|
|
||||||
provider: 'audnexus',
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
text: 'Audnexus',
|
|
||||||
value: 'audnexus'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
authorName: {
|
|
||||||
immediate: true,
|
|
||||||
handler(newVal) {
|
|
||||||
this.searchAuthor = newVal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {},
|
|
||||||
methods: {
|
|
||||||
getSearchQuery() {
|
|
||||||
return `q=${this.searchAuthor}`
|
|
||||||
},
|
|
||||||
submitSearch() {
|
|
||||||
if (!this.searchAuthor) {
|
|
||||||
this.$toast.warning('Author name is required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.runSearch()
|
|
||||||
},
|
|
||||||
async runSearch() {
|
|
||||||
var searchQuery = this.getSearchQuery()
|
|
||||||
if (this.lastSearch === searchQuery) return
|
|
||||||
this.isProcessing = true
|
|
||||||
this.lastSearch = searchQuery
|
|
||||||
var result = await this.$axios.$get(`/api/authors/search?${searchQuery}`).catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
return []
|
|
||||||
})
|
|
||||||
this.isProcessing = false
|
|
||||||
if (result) {
|
|
||||||
this.$emit('match', result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -33,8 +33,8 @@ export default {
|
|||||||
showMenu: false,
|
showMenu: false,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
text: 'Current',
|
text: 'Pub Date',
|
||||||
value: 'index'
|
value: 'publishedAt'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Title',
|
text: 'Title',
|
||||||
@@ -47,10 +47,6 @@ export default {
|
|||||||
{
|
{
|
||||||
text: 'Episode',
|
text: 'Episode',
|
||||||
value: 'episode'
|
value: 'episode'
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Pub Date',
|
|
||||||
value: 'publishedAt'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,9 +59,6 @@ export default {
|
|||||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||||
return this.width / 240
|
return this.width / 240
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
store() {
|
store() {
|
||||||
return this.$store || this.$nuxt.$store
|
return this.$store || this.$nuxt.$store
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -93,12 +93,13 @@ export default {
|
|||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
clickBg(ev) {
|
clickBg(ev) {
|
||||||
|
if (!this.show) return
|
||||||
if (this.preventClickoutside) {
|
if (this.preventClickoutside) {
|
||||||
this.preventClickoutside = false
|
this.preventClickoutside = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.processing && this.persistent) return
|
if (this.processing && this.persistent) return
|
||||||
if (ev.srcElement.classList.contains('modal-bg')) {
|
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
|
||||||
this.show = false
|
this.show = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||||
<form @submit.prevent="submitForm">
|
<form v-if="author" @submit.prevent="submitForm">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div class="w-40 p-2">
|
<div class="w-40 p-2">
|
||||||
<div class="w-full h-45 relative">
|
<div class="w-full h-45 relative">
|
||||||
<covers-author-image :author="author" />
|
<covers-author-image :author="author" />
|
||||||
<div v-show="!processing" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
|
<div v-show="!processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
|
||||||
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,13 +43,13 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
// props: {
|
||||||
value: Boolean,
|
// value: Boolean,
|
||||||
author: {
|
// author: {
|
||||||
type: Object,
|
// type: Object,
|
||||||
default: () => {}
|
// default: () => {}
|
||||||
}
|
// }
|
||||||
},
|
// },
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
authorCopy: {
|
authorCopy: {
|
||||||
@@ -73,12 +73,15 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
show: {
|
show: {
|
||||||
get() {
|
get() {
|
||||||
return this.value
|
return this.$store.state.globals.showEditAuthorModal
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
this.$emit('input', val)
|
this.$store.commit('globals/setShowEditAuthorModal', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
author() {
|
||||||
|
return this.$store.state.globals.selectedAuthor
|
||||||
|
},
|
||||||
authorId() {
|
authorId() {
|
||||||
if (!this.author) return ''
|
if (!this.author) return ''
|
||||||
return this.author.id
|
return this.author.id
|
||||||
@@ -136,12 +139,17 @@ export default {
|
|||||||
this.processing = false
|
this.processing = false
|
||||||
},
|
},
|
||||||
async searchAuthor() {
|
async searchAuthor() {
|
||||||
if (!this.authorCopy.name) {
|
if (!this.authorCopy.name && !this.authorCopy.asin) {
|
||||||
this.$toast.error('Must enter an author name')
|
this.$toast.error('Must enter an author name')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.processing = true
|
this.processing = true
|
||||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.authorCopy.name }).catch((error) => {
|
|
||||||
|
const payload = {}
|
||||||
|
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
|
||||||
|
else payload.q = this.authorCopy.name
|
||||||
|
|
||||||
|
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
|
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
|
<div class="w-full h-full max-h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
|
||||||
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
@@ -64,8 +64,7 @@ export default {
|
|||||||
{
|
{
|
||||||
id: 'manage',
|
id: 'manage',
|
||||||
title: 'Manage',
|
title: 'Manage',
|
||||||
component: 'modals-item-tabs-manage',
|
component: 'modals-item-tabs-manage'
|
||||||
experimental: true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<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="chapters.length" class="w-full p-4 bg-primary">
|
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" keep-open />
|
||||||
<p>Audiobook Chapters</p>
|
<div v-if="!chapters.length" class="py-4 text-center">
|
||||||
|
<p class="mb-8 text-xl">No Chapters</p>
|
||||||
|
<ui-btn v-if="userCanUpdate" :to="`/audiobook/${libraryItem.id}/chapters`">Add Chapters</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
|
|
||||||
<table v-else class="text-sm tracksTable">
|
|
||||||
<tr class="font-book">
|
|
||||||
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
|
||||||
<th class="text-left">Title</th>
|
|
||||||
<th class="text-center">Start</th>
|
|
||||||
<th class="text-center">End</th>
|
|
||||||
</tr>
|
|
||||||
<tr v-for="chapter in chapters" :key="chapter.id">
|
|
||||||
<td class="text-left">
|
|
||||||
<p class="px-4">{{ chapter.id }}</p>
|
|
||||||
</td>
|
|
||||||
<td class="font-book">
|
|
||||||
{{ chapter.title }}
|
|
||||||
</td>
|
|
||||||
<td class="font-mono text-center">
|
|
||||||
{{ $secondsToTimestamp(chapter.start) }}
|
|
||||||
</td>
|
|
||||||
<td class="font-mono text-center">
|
|
||||||
{{ $secondsToTimestamp(chapter.end) }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -48,6 +27,9 @@ export default {
|
|||||||
},
|
},
|
||||||
chapters() {
|
chapters() {
|
||||||
return this.media.chapters || []
|
return this.media.chapters || []
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {}
|
methods: {}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full relative">
|
<div class="w-full h-full relative">
|
||||||
<widgets-book-details-edit v-if="mediaType == 'book'" ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" />
|
<div id="formWrapper" class="w-full overflow-y-auto">
|
||||||
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="submitForm" />
|
<widgets-book-details-edit v-if="mediaType == 'book'" ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
|
||||||
|
<widgets-podcast-details-edit v-else ref="itemDetailsEdit" :library-item="libraryItem" @submit="saveAndClose" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
|
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
|
||||||
<div class="flex items-center px-4">
|
<div class="flex items-center px-4">
|
||||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
|
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
|
||||||
|
|
||||||
@@ -17,7 +19,9 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<ui-btn @click="submitForm">Submit</ui-btn>
|
<ui-btn @click="save" class="mx-2">Save</ui-btn>
|
||||||
|
|
||||||
|
<ui-btn @click="saveAndClose">Save & Close</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,19 +146,23 @@ export default {
|
|||||||
this.rescanning = false
|
this.rescanning = false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
submitForm() {
|
async saveAndClose() {
|
||||||
|
const wasUpdated = await this.save()
|
||||||
|
if (wasUpdated !== null) this.$emit('close')
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
if (this.isProcessing) {
|
if (this.isProcessing) {
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
if (!this.$refs.itemDetailsEdit) {
|
if (!this.$refs.itemDetailsEdit) {
|
||||||
return
|
return null
|
||||||
}
|
}
|
||||||
var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
|
var updatedDetails = this.$refs.itemDetailsEdit.getDetails()
|
||||||
if (!updatedDetails.hasChanges) {
|
if (!updatedDetails.hasChanges) {
|
||||||
this.$toast.info('No changes were made')
|
this.$toast.info('No changes were made')
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
this.updateDetails(updatedDetails)
|
return this.updateDetails(updatedDetails)
|
||||||
},
|
},
|
||||||
async updateDetails(updatedDetails) {
|
async updateDetails(updatedDetails) {
|
||||||
this.isProcessing = true
|
this.isProcessing = true
|
||||||
@@ -166,11 +174,12 @@ export default {
|
|||||||
if (updateResult) {
|
if (updateResult) {
|
||||||
if (updateResult.updated) {
|
if (updateResult.updated) {
|
||||||
this.$toast.success('Item details updated')
|
this.$toast.success('Item details updated')
|
||||||
this.$emit('close')
|
return true
|
||||||
} else {
|
} else {
|
||||||
this.$toast.info('No updates were necessary')
|
this.$toast.info('No updates were necessary')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
},
|
},
|
||||||
removeItem() {
|
removeItem() {
|
||||||
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
|
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
|
||||||
@@ -224,8 +233,8 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.details-form-wrapper {
|
#formWrapper {
|
||||||
height: calc(100% - 70px);
|
height: calc(100% - 80px);
|
||||||
max-height: calc(100% - 70px);
|
max-height: calc(100% - 80px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Split to mp3 -->
|
<!-- Split to mp3 -->
|
||||||
<div v-if="showMp3Split" class="w-full border border-black-200 p-4 my-8">
|
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-lg">Split M4B to MP3's</p>
|
<p class="text-lg">Split M4B to MP3's</p>
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Embed Metadata -->
|
<!-- Embed Metadata -->
|
||||||
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
|
<div v-if="mediaTracks.length && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-lg">Embed Metadata</p>
|
<p class="text-lg">Embed Metadata</p>
|
||||||
@@ -113,6 +113,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
libraryItemId() {
|
libraryItemId() {
|
||||||
return this.libraryItem ? this.libraryItem.id : null
|
return this.libraryItem ? this.libraryItem.id : null
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,10 +28,9 @@
|
|||||||
<ui-editable-text v-model="newFolderPath" placeholder="New folder path" type="text" class="w-full" @blur="newFolderInputBlurred" />
|
<ui-editable-text v-model="newFolderPath" placeholder="New folder path" type="text" class="w-full" @blur="newFolderInputBlurred" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn>
|
<ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">Browse for Folder</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-libraries-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
|
<modals-libraries-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -77,6 +76,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
browseForFolder() {
|
||||||
|
this.showDirectoryPicker = true
|
||||||
|
},
|
||||||
getLibraryData() {
|
getLibraryData() {
|
||||||
return {
|
return {
|
||||||
name: this.name,
|
name: this.name,
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export default {
|
|||||||
settings: {
|
settings: {
|
||||||
disableWatcher: false,
|
disableWatcher: false,
|
||||||
skipMatchingMediaWithAsin: false,
|
skipMatchingMediaWithAsin: false,
|
||||||
skipMatchingMediaWithIsbn: false,
|
skipMatchingMediaWithIsbn: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -193,6 +193,11 @@ export default {
|
|||||||
this.processing = false
|
this.processing = false
|
||||||
this.show = false
|
this.show = false
|
||||||
this.$toast.success(`Library "${res.name}" created successfully`)
|
this.$toast.success(`Library "${res.name}" created successfully`)
|
||||||
|
if (!this.$store.state.libraries.currentLibraryId) {
|
||||||
|
console.log('Setting initially library id', res.id)
|
||||||
|
// First library added
|
||||||
|
this.$store.dispatch('libraries/fetch', res.id)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
|||||||
@@ -8,13 +8,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
|
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*Watcher is disabled globally in server settings</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="py-3">
|
<div v-if="mediaType == 'book'" class="py-3">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
|
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
|
||||||
<p class="pl-4 text-lg">Skip matching books that already have an ASIN</p>
|
<p class="pl-4 text-lg">Skip matching books that already have an ASIN</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="py-3">
|
<div v-if="mediaType == 'book'" class="py-3">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
|
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
|
||||||
<p class="pl-4 text-lg">Skip matching books that already have an ISBN</p>
|
<p class="pl-4 text-lg">Skip matching books that already have an ISBN</p>
|
||||||
@@ -37,7 +37,7 @@ export default {
|
|||||||
provider: null,
|
provider: null,
|
||||||
disableWatcher: false,
|
disableWatcher: false,
|
||||||
skipMatchingMediaWithAsin: false,
|
skipMatchingMediaWithAsin: false,
|
||||||
skipMatchingMediaWithIsbn: false,
|
skipMatchingMediaWithIsbn: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
|
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt) ? 1 : -1)
|
||||||
for (let i = 0; i < this.episodes.length; i++) {
|
for (let i = 0; i < this.episodes.length; i++) {
|
||||||
var episode = this.episodes[i]
|
var episode = this.episodes[i]
|
||||||
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
|
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export default {
|
|||||||
this.fullPath = ''
|
this.fullPath = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.fullPath = Path.join(this.selectedFolderPath, this.podcast.title)
|
this.fullPath = Path.join(this.selectedFolderPath, this.$sanitizeFilename(this.podcast.title))
|
||||||
},
|
},
|
||||||
submit() {
|
submit() {
|
||||||
const podcastPayload = {
|
const podcastPayload = {
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<modals-modal v-model="show" name="podcast-episode-remove-modal" :width="500" :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>
|
||||||
|
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-lg text-gray-200 mb-4">
|
||||||
|
Are you sure you want to remove episode<br /><span class="text-base">{{ episodeTitle }}</span
|
||||||
|
>?
|
||||||
|
</p>
|
||||||
|
<p class="text-xs font-semibold text-warning text-opacity-90">Note: This does not delete the audio file unless toggling "Hard delete file"</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center pt-4">
|
||||||
|
<ui-checkbox v-model="hardDeleteFile" label="Hard delete file" check-color="error" checkbox-bg="bg" small label-class="text-base text-gray-200 pl-3" />
|
||||||
|
|
||||||
|
<ui-btn @click="submit">{{ hardDeleteFile ? 'Delete episode' : 'Remove episode' }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
episode: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
hardDeleteFile: false,
|
||||||
|
processing: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
value(newVal) {
|
||||||
|
if (newVal) this.hardDeleteFile = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return 'Remove Episode'
|
||||||
|
},
|
||||||
|
episodeId() {
|
||||||
|
return this.episode ? this.episode.id : null
|
||||||
|
},
|
||||||
|
episodeTitle() {
|
||||||
|
return this.episode ? this.episode.title : null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
submit() {
|
||||||
|
this.processing = true
|
||||||
|
|
||||||
|
var queryString = this.hardDeleteFile ? '?hard=1' : ''
|
||||||
|
this.$axios
|
||||||
|
.$delete(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}${queryString}`)
|
||||||
|
.then(() => {
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.success('Podcast episode removed')
|
||||||
|
this.show = false
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed remove episode'
|
||||||
|
console.error('Failed update episode', error)
|
||||||
|
this.processing = false
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -18,12 +18,16 @@
|
|||||||
<div v-else class="w-full">
|
<div v-else class="w-full">
|
||||||
<p class="text-lg font-semibold mb-4">Open RSS Feed</p>
|
<p class="text-lg font-semibold mb-4">Open RSS Feed</p>
|
||||||
|
|
||||||
<div class="w-full relative">
|
<div class="w-full relative mb-2">
|
||||||
<ui-text-input-with-label v-model="newFeedSlug" label="RSS Feed Slug" />
|
<ui-text-input-with-label v-model="newFeedSlug" label="RSS Feed Slug" />
|
||||||
<p class="text-xs text-gray-400 py-0.5 px-1">Feed will be {{ demoFeedUrl }}</p>
|
<p class="text-xs text-gray-400 py-0.5 px-1">Feed will be {{ demoFeedUrl }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p v-if="isHttp" class="w-full pt-2 text-warning text-xs">Warning: Most podcast apps will require the RSS feed URL is using HTTPS</p>
|
||||||
|
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">Warning: 1 or more of your episodes do not have a Pub Date. Some podcast apps require this.</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
|
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
|
||||||
|
<p class="text-xs text-gray-300">Note: RSS feed URLs are not authenticated</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
|
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">Close RSS Feed</ui-btn>
|
||||||
<ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn>
|
<ui-btn v-else color="success" small @click="openFeed">Open RSS Feed</ui-btn>
|
||||||
@@ -85,6 +89,15 @@ export default {
|
|||||||
},
|
},
|
||||||
demoFeedUrl() {
|
demoFeedUrl() {
|
||||||
return `${window.origin}/feed/${this.newFeedSlug}`
|
return `${window.origin}/feed/${this.newFeedSlug}`
|
||||||
|
},
|
||||||
|
isHttp() {
|
||||||
|
return window.origin.startsWith('http://')
|
||||||
|
},
|
||||||
|
episodes() {
|
||||||
|
return this.media.episodes || []
|
||||||
|
},
|
||||||
|
hasEpisodesWithoutPubDate() {
|
||||||
|
return this.episodes.some((ep) => !ep.pubDate)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -109,7 +122,7 @@ export default {
|
|||||||
|
|
||||||
console.log('Payload', payload)
|
console.log('Payload', payload)
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/podcasts/${this.libraryItemId}/open-feed`, payload)
|
.$post(`/api/items/${this.libraryItemId}/open-feed`, payload)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
console.log('Opened RSS Feed', data)
|
console.log('Opened RSS Feed', data)
|
||||||
@@ -130,7 +143,7 @@ export default {
|
|||||||
closeFeed() {
|
closeFeed() {
|
||||||
this.processing = true
|
this.processing = true
|
||||||
this.$axios
|
this.$axios
|
||||||
.$post(`/api/podcasts/${this.libraryItem.id}/close-feed`)
|
.$post(`/api/items/${this.libraryItem.id}/close-feed`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('RSS Feed Closed')
|
this.$toast.success('RSS Feed Closed')
|
||||||
this.show = false
|
this.show = false
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
<template>
|
||||||
|
<div id="heatmap" class="w-full">
|
||||||
|
<div class="mx-auto" :style="{ height: innerHeight + 160 + 'px', width: innerWidth + 52 + 'px' }" style="background-color: rgba(13, 17, 23, 0)">
|
||||||
|
<p class="mb-2 px-1 text-sm text-gray-200">{{ Object.values(daysListening).length }} listening sessions in the last year</p>
|
||||||
|
<div class="border border-opacity-25 rounded py-2 w-full" style="background-color: #232323" :style="{ height: innerHeight + 80 + 'px' }">
|
||||||
|
<div :style="{ width: innerWidth + 'px', height: innerHeight + 'px' }" class="ml-10 mt-5 absolute" @mouseover="mouseover" @mouseout="mouseout">
|
||||||
|
<div v-for="dayLabel in dayLabels" :key="dayLabel.label" :style="dayLabel.style" class="absolute top-0 left-0 text-gray-300">{{ dayLabel.label }}</div>
|
||||||
|
|
||||||
|
<div v-for="monthLabel in monthLabels" :key="monthLabel.id" :style="monthLabel.style" class="absolute top-0 left-0 text-gray-300">{{ monthLabel.label }}</div>
|
||||||
|
|
||||||
|
<div v-for="(block, index) in data" :key="block.dateString" :style="block.style" :data-index="index" class="absolute top-0 left-0 h-2.5 w-2.5 rounded-sm" />
|
||||||
|
|
||||||
|
<div class="flex py-2 px-4" :style="{ marginTop: innerHeight + 'px' }">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">Less</p>
|
||||||
|
<div v-for="block in legendBlocks" :key="block.id" :style="block.style" class="h-2.5 w-2.5 rounded-sm" style="margin-left: 1.5px; margin-right: 1.5px" />
|
||||||
|
<p style="font-size: 10px; line-height: 10px" class="text-gray-400 px-1">More</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
daysListening: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
contentWidth: 0,
|
||||||
|
maxInnerWidth: 0,
|
||||||
|
innerHeight: 13 * 7,
|
||||||
|
blockWidth: 13,
|
||||||
|
data: [],
|
||||||
|
monthLabels: [],
|
||||||
|
tooltipEl: null,
|
||||||
|
tooltipTextEl: null,
|
||||||
|
tooltipArrowEl: null,
|
||||||
|
showingTooltipIndex: -1,
|
||||||
|
outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.03)'],
|
||||||
|
bgColors: ['rgb(45,45,45)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
|
||||||
|
// GH Colors
|
||||||
|
// outlineColors: ['rgba(27, 31, 35, 0.06)', 'rgba(255,255,255,0.05)'],
|
||||||
|
// bgColors: ['rgb(22, 27, 34)', 'rgb(14, 68, 41)', 'rgb(0, 109, 50)', 'rgb(38, 166, 65)', 'rgb(57, 211, 83)']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
weeksToShow() {
|
||||||
|
return Math.min(52, Math.floor(this.maxInnerWidth / this.blockWidth) - 1)
|
||||||
|
},
|
||||||
|
innerWidth() {
|
||||||
|
return (this.weeksToShow + 1) * 13
|
||||||
|
},
|
||||||
|
daysToShow() {
|
||||||
|
return this.weeksToShow * 7 + this.dayOfWeekToday
|
||||||
|
},
|
||||||
|
dayOfWeekToday() {
|
||||||
|
return new Date().getDay()
|
||||||
|
},
|
||||||
|
firstWeekStart() {
|
||||||
|
return this.$addDaysToToday(-this.daysToShow)
|
||||||
|
},
|
||||||
|
dayLabels() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: 'Mon',
|
||||||
|
style: {
|
||||||
|
transform: `translate(${-25}px, ${13}px)`,
|
||||||
|
lineHeight: '10px',
|
||||||
|
fontSize: '10px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Wed',
|
||||||
|
style: {
|
||||||
|
transform: `translate(${-25}px, ${13 * 3}px)`,
|
||||||
|
lineHeight: '10px',
|
||||||
|
fontSize: '10px'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Fri',
|
||||||
|
style: {
|
||||||
|
transform: `translate(${-25}px, ${13 * 5}px)`,
|
||||||
|
lineHeight: '10px',
|
||||||
|
fontSize: '10px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
legendBlocks() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'legend-0',
|
||||||
|
style: `background-color:${this.bgColors[0]};outline:1px solid ${this.outlineColors[0]};outline-offset:-1px;`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'legend-1',
|
||||||
|
style: `background-color:${this.bgColors[1]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'legend-2',
|
||||||
|
style: `background-color:${this.bgColors[2]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'legend-3',
|
||||||
|
style: `background-color:${this.bgColors[3]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'legend-4',
|
||||||
|
style: `background-color:${this.bgColors[4]};outline:1px solid ${this.outlineColors[1]};outline-offset:-1px;`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
destroyTooltip() {
|
||||||
|
if (this.tooltipEl) this.tooltipEl.remove()
|
||||||
|
this.tooltipEl = null
|
||||||
|
this.showingTooltipIndex = -1
|
||||||
|
},
|
||||||
|
createTooltip() {
|
||||||
|
const tooltip = document.createElement('div')
|
||||||
|
tooltip.className = 'absolute top-0 left-0 rounded bg-gray-500 text-white p-2 text-white max-w-xs pointer-events-none'
|
||||||
|
tooltip.style.display = 'none'
|
||||||
|
tooltip.id = 'heatmap-tooltip'
|
||||||
|
|
||||||
|
const tooltipText = document.createElement('p')
|
||||||
|
tooltipText.innerText = 'Tooltip'
|
||||||
|
tooltipText.style.fontSize = '10px'
|
||||||
|
tooltipText.style.lineHeight = '10px'
|
||||||
|
tooltip.appendChild(tooltipText)
|
||||||
|
|
||||||
|
const tooltipArrow = document.createElement('div')
|
||||||
|
tooltipArrow.className = 'text-gray-500 arrow-down-small absolute -bottom-1 left-0 right-0 mx-auto'
|
||||||
|
tooltip.appendChild(tooltipArrow)
|
||||||
|
|
||||||
|
this.tooltipEl = tooltip
|
||||||
|
this.tooltipTextEl = tooltipText
|
||||||
|
this.tooltipArrowEl = tooltipArrow
|
||||||
|
|
||||||
|
document.body.appendChild(this.tooltipEl)
|
||||||
|
},
|
||||||
|
showTooltip(index, block, rect) {
|
||||||
|
if (this.tooltipEl && this.showingTooltipIndex === index) return
|
||||||
|
if (!this.tooltipEl) {
|
||||||
|
this.createTooltip()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showingTooltipIndex = index
|
||||||
|
this.tooltipEl.style.display = 'block'
|
||||||
|
this.tooltipTextEl.innerHTML = block.value ? `<strong>${this.$elapsedPretty(block.value, true)} listening</strong> on ${block.datePretty}` : `No listening sessions on ${block.datePretty}`
|
||||||
|
|
||||||
|
const calculateRect = this.tooltipEl.getBoundingClientRect()
|
||||||
|
|
||||||
|
const w = calculateRect.width / 2
|
||||||
|
var left = rect.x - w
|
||||||
|
var offsetX = 0
|
||||||
|
if (left < 0) {
|
||||||
|
offsetX = Math.abs(left)
|
||||||
|
left = 0
|
||||||
|
} else if (rect.x + w > window.innerWidth - 10) {
|
||||||
|
offsetX = window.innerWidth - 10 - (rect.x + w)
|
||||||
|
left += offsetX
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tooltipEl.style.transform = `translate(${left}px, ${rect.y - 32}px)`
|
||||||
|
this.tooltipArrowEl.style.transform = `translate(${5 - offsetX}px, 0px)`
|
||||||
|
},
|
||||||
|
hideTooltip() {
|
||||||
|
if (this.showingTooltipIndex >= 0 && this.tooltipEl) {
|
||||||
|
this.tooltipEl.style.display = 'none'
|
||||||
|
this.showingTooltipIndex = -1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mouseover(e) {
|
||||||
|
if (isNaN(e.target.dataset.index)) {
|
||||||
|
this.hideTooltip()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var block = this.data[e.target.dataset.index]
|
||||||
|
var rect = e.target.getBoundingClientRect()
|
||||||
|
this.showTooltip(e.target.dataset.index, block, rect)
|
||||||
|
},
|
||||||
|
mouseout(e) {
|
||||||
|
this.hideTooltip()
|
||||||
|
},
|
||||||
|
buildData() {
|
||||||
|
this.data = []
|
||||||
|
|
||||||
|
var maxValue = 0
|
||||||
|
var minValue = 0
|
||||||
|
Object.values(this.daysListening).forEach((val) => {
|
||||||
|
if (val > maxValue) maxValue = val
|
||||||
|
if (!minValue || val < minValue) minValue = val
|
||||||
|
})
|
||||||
|
const range = maxValue - minValue + 0.01
|
||||||
|
|
||||||
|
for (let i = 0; i < this.daysToShow + 1; i++) {
|
||||||
|
const col = Math.floor(i / 7)
|
||||||
|
const row = i % 7
|
||||||
|
|
||||||
|
const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i)
|
||||||
|
const dateString = this.$formatJsDate(date, 'yyyy-MM-dd')
|
||||||
|
const datePretty = this.$formatJsDate(date, 'MMM d, yyyy')
|
||||||
|
const monthString = this.$formatJsDate(date, 'MMM')
|
||||||
|
const value = this.daysListening[dateString] || 0
|
||||||
|
const x = col * 13
|
||||||
|
const y = row * 13
|
||||||
|
|
||||||
|
var bgColor = this.bgColors[0]
|
||||||
|
var outlineColor = this.outlineColors[0]
|
||||||
|
if (value) {
|
||||||
|
outlineColor = this.outlineColors[1]
|
||||||
|
var percentOfAvg = (value - minValue) / range
|
||||||
|
var bgIndex = Math.floor(percentOfAvg * 4) + 1
|
||||||
|
bgColor = this.bgColors[bgIndex] || 'red'
|
||||||
|
}
|
||||||
|
|
||||||
|
this.data.push({
|
||||||
|
date,
|
||||||
|
dateString,
|
||||||
|
datePretty,
|
||||||
|
monthString,
|
||||||
|
dayOfMonth: Number(dateString.split('-').pop()),
|
||||||
|
yearString: dateString.split('-').shift(),
|
||||||
|
value,
|
||||||
|
col,
|
||||||
|
row,
|
||||||
|
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
console.log('Data', this.data)
|
||||||
|
|
||||||
|
this.monthLabels = []
|
||||||
|
var lastMonth = null
|
||||||
|
for (let i = 0; i < this.data.length; i++) {
|
||||||
|
if (this.data[i].monthString !== lastMonth) {
|
||||||
|
const weekOfMonth = Math.floor(this.data[i].dayOfMonth / 7)
|
||||||
|
if (weekOfMonth <= 2) {
|
||||||
|
this.monthLabels.push({
|
||||||
|
id: this.data[i].dateString + '-ml',
|
||||||
|
label: this.data[i].monthString,
|
||||||
|
style: {
|
||||||
|
transform: `translate(${this.data[i].col * 13}px, -15px)`,
|
||||||
|
lineHeight: '10px',
|
||||||
|
fontSize: '10px'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
lastMonth = this.data[i].monthString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
const heatmapEl = document.getElementById('heatmap')
|
||||||
|
this.contentWidth = heatmapEl.clientWidth
|
||||||
|
this.maxInnerWidth = this.contentWidth - 52
|
||||||
|
this.buildData()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updated() {},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full my-2">
|
||||||
|
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||||
|
<p class="pr-4">Chapters</p>
|
||||||
|
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ chapters.length }}</span>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn v-if="userCanUpdate" small :to="`/audiobook/${libraryItemId}/chapters`" color="primary" class="mr-2">Edit Chapters</ui-btn>
|
||||||
|
<div v-if="!keepOpen" class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="expanded ? 'transform rotate-180' : ''">
|
||||||
|
<span class="material-icons text-4xl">expand_more</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<transition name="slide">
|
||||||
|
<table class="text-sm tracksTable" v-show="expanded || keepOpen">
|
||||||
|
<tr class="font-book">
|
||||||
|
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
||||||
|
<th class="text-left">Title</th>
|
||||||
|
<th class="text-center">Start</th>
|
||||||
|
<th class="text-center">End</th>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="chapter in chapters" :key="chapter.id">
|
||||||
|
<td class="text-left">
|
||||||
|
<p class="px-4">{{ chapter.id }}</p>
|
||||||
|
</td>
|
||||||
|
<td class="font-book">
|
||||||
|
{{ chapter.title }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-center">
|
||||||
|
{{ $secondsToTimestamp(chapter.start) }}
|
||||||
|
</td>
|
||||||
|
<td class="font-mono text-center">
|
||||||
|
{{ $secondsToTimestamp(chapter.end) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
keepOpen: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
expanded: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
libraryItemId() {
|
||||||
|
return this.libraryItem.id
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||||
|
},
|
||||||
|
chapters() {
|
||||||
|
return this.media.chapters || []
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clickBar() {
|
||||||
|
this.expanded = !this.expanded
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -6,18 +6,20 @@
|
|||||||
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<draggable :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
<draggable v-if="libraryCopies.length" :list="libraryCopies" v-bind="dragOptions" class="list-group" draggable=".item" tag="div" @start="startDrag" @end="endDrag">
|
||||||
<template v-for="library in libraryCopies">
|
<template v-for="library in libraryCopies">
|
||||||
<div :key="library.id" class="item">
|
<div :key="library.id" class="item">
|
||||||
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
<tables-library-item :library="library" :selected="currentLibraryId === library.id" :show-edit="true" :dragging="drag" @edit="editLibrary" @click="setLibrary" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
<div v-if="!libraries.length" class="pb-4">
|
||||||
|
<ui-btn @click="clickAddLibrary">Add your first library</ui-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
|
<p v-if="libraries.length" class="text-xs mt-4 text-gray-200">*<strong>Force Re-Scan</strong> will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed and used for book details.</p>
|
||||||
|
|
||||||
<p class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p>
|
<p v-if="libraries.length && libraries.some((li) => li.mediaType === 'book')" class="text-xs mt-4 text-gray-200">**<strong>Match Books</strong> will attempt to match books in library with a book from the selected search provider and fill in empty details and cover art. Does not overwrite details.</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -32,8 +34,6 @@ export default {
|
|||||||
return {
|
return {
|
||||||
libraryCopies: [],
|
libraryCopies: [],
|
||||||
currentOrder: [],
|
currentOrder: [],
|
||||||
showLibraryModal: false,
|
|
||||||
selectedLibrary: null,
|
|
||||||
drag: false,
|
drag: false,
|
||||||
dragOptions: {
|
dragOptions: {
|
||||||
animation: 200,
|
animation: 200,
|
||||||
@@ -97,12 +97,10 @@ export default {
|
|||||||
this.$router.push(`/library/${library.id}`)
|
this.$router.push(`/library/${library.id}`)
|
||||||
},
|
},
|
||||||
clickAddLibrary() {
|
clickAddLibrary() {
|
||||||
this.selectedLibrary = null
|
this.$emit('showLibraryModal', null)
|
||||||
this.showLibraryModal = true
|
|
||||||
},
|
},
|
||||||
editLibrary(library) {
|
editLibrary(library) {
|
||||||
this.selectedLibrary = library
|
this.$emit('showLibraryModal', library)
|
||||||
this.showLibraryModal = true
|
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.libraryCopies = this.libraries.map((lib) => {
|
this.libraryCopies = this.libraries.map((lib) => {
|
||||||
|
|||||||
@@ -73,10 +73,28 @@ export default {
|
|||||||
this.$emit('edit', this.library)
|
this.$emit('edit', this.library)
|
||||||
},
|
},
|
||||||
scan() {
|
scan() {
|
||||||
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id })
|
this.$store
|
||||||
|
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id })
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Library scan started')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to start scan', error)
|
||||||
|
this.$toast.error('Failed to start scan')
|
||||||
|
})
|
||||||
},
|
},
|
||||||
forceScan() {
|
forceScan() {
|
||||||
this.$store.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
|
if (confirm(`Force Re-Scan will scan all files again like a fresh scan. Audio file ID3 tags, OPF files, and text files will be probed/parsed to be used for the library item.\n\nAre you sure you want to force re-scan?`)) {
|
||||||
|
this.$store
|
||||||
|
.dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force: 1 })
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Library scan started')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to start scan', error)
|
||||||
|
this.$toast.error('Failed to start scan')
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
deleteClick() {
|
deleteClick() {
|
||||||
if (this.isMain) return
|
if (this.isMain) return
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
|
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||||
<div v-if="episode" class="flex items-center h-24">
|
<div v-if="episode" class="flex items-center h-24">
|
||||||
<div v-show="userCanUpdate" class="w-12 min-w-12 max-w-16 h-full">
|
|
||||||
<div class="flex h-full items-center justify-center">
|
|
||||||
<span class="material-icons drag-handle text-lg text-white text-opacity-50 hover:text-opacity-100">menu</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-2">
|
<div class="flex-grow px-2">
|
||||||
<p class="text-sm font-semibold">
|
<p class="text-sm font-semibold">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
@@ -49,8 +44,7 @@ export default {
|
|||||||
episode: {
|
episode: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
},
|
}
|
||||||
isDragging: Boolean
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -59,15 +53,6 @@ export default {
|
|||||||
isHovering: false
|
isHovering: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
|
||||||
isDragging: {
|
|
||||||
handler(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
this.isHovering = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
@@ -117,7 +102,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
mouseover() {
|
mouseover() {
|
||||||
if (this.isDragging) return
|
// if (this.isDragging) return
|
||||||
this.isHovering = true
|
this.isHovering = true
|
||||||
},
|
},
|
||||||
mouseleave() {
|
mouseleave() {
|
||||||
@@ -154,22 +139,7 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
removeClick() {
|
removeClick() {
|
||||||
if (confirm(`Are you sure you want to remove episode ${this.title}?\nNote: Does not delete from file system`)) {
|
this.$emit('remove', this.episode)
|
||||||
this.processingRemove = true
|
|
||||||
|
|
||||||
this.$axios
|
|
||||||
.$delete(`/api/items/${this.libraryItemId}/episode/${this.episode.id}`)
|
|
||||||
.then((updatedPodcast) => {
|
|
||||||
console.log(`Episode removed from podcast`, updatedPodcast)
|
|
||||||
this.$toast.success('Episode removed from podcast')
|
|
||||||
this.processingRemove = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to remove episode from podcast', error)
|
|
||||||
this.$toast.error('Failed to remove episode from podcast')
|
|
||||||
this.processingRemove = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,29 +3,19 @@
|
|||||||
<div class="flex items-center mb-4">
|
<div class="flex items-center mb-4">
|
||||||
<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" />
|
||||||
<controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" @change="changeSort" />
|
<controls-episode-sort-select v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" />
|
||||||
<div v-if="userCanUpdate" class="w-12">
|
|
||||||
<ui-icon-btn v-if="orderChanged" :loading="savingOrder" icon="save" bg-color="primary" class="ml-auto" @click="saveOrder" />
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
<draggable v-model="episodesCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
<template v-for="episode in episodesSorted">
|
||||||
<transition-group type="transition" :name="!drag ? 'episode' : null">
|
<tables-podcast-episode-table-row :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" class="item" @remove="removeEpisode" @edit="editEpisode" />
|
||||||
<template v-for="episode in episodesCopy">
|
</template>
|
||||||
<tables-podcast-episode-table-row :key="episode.id" :is-dragging="drag" :episode="episode" :library-item-id="libraryItem.id" class="item" :class="drag ? '' : 'episode'" @edit="editEpisode" />
|
|
||||||
</template>
|
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" :library-item="libraryItem" :episode="selectedEpisode" />
|
||||||
</transition-group>
|
|
||||||
</draggable>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import draggable from 'vuedraggable'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
|
||||||
draggable
|
|
||||||
},
|
|
||||||
props: {
|
props: {
|
||||||
libraryItem: {
|
libraryItem: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -34,30 +24,19 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
sortKey: 'index',
|
|
||||||
sortDesc: true,
|
|
||||||
drag: false,
|
|
||||||
episodesCopy: [],
|
episodesCopy: [],
|
||||||
orderChanged: false,
|
sortKey: 'publishedAt',
|
||||||
savingOrder: false
|
sortDesc: true,
|
||||||
|
selectedEpisode: null,
|
||||||
|
showPodcastRemoveModal: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
libraryItem: {
|
libraryItem() {
|
||||||
handler(newVal) {
|
this.init()
|
||||||
this.init()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
dragOptions() {
|
|
||||||
return {
|
|
||||||
animation: 200,
|
|
||||||
group: 'description',
|
|
||||||
ghostClass: 'ghost',
|
|
||||||
disabled: !this.userCanUpdate
|
|
||||||
}
|
|
||||||
},
|
|
||||||
userCanUpdate() {
|
userCanUpdate() {
|
||||||
return this.$store.getters['user/getUserCanUpdate']
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
},
|
},
|
||||||
@@ -69,64 +48,28 @@ export default {
|
|||||||
},
|
},
|
||||||
episodes() {
|
episodes() {
|
||||||
return this.media.episodes || []
|
return this.media.episodes || []
|
||||||
}
|
},
|
||||||
},
|
episodesSorted() {
|
||||||
methods: {
|
return this.episodesCopy.sort((a, b) => {
|
||||||
changeSort() {
|
|
||||||
this.episodesCopy.sort((a, b) => {
|
|
||||||
if (this.sortDesc) {
|
if (this.sortDesc) {
|
||||||
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
return String(b[this.sortKey]).localeCompare(String(a[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
|
||||||
}
|
}
|
||||||
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' })
|
||||||
})
|
})
|
||||||
|
}
|
||||||
this.orderChanged = this.checkHasOrderChanged()
|
},
|
||||||
},
|
methods: {
|
||||||
checkHasOrderChanged() {
|
removeEpisode(episode) {
|
||||||
for (let i = 0; i < this.episodesCopy.length; i++) {
|
this.selectedEpisode = episode
|
||||||
var epc = this.episodesCopy[i]
|
this.showPodcastRemoveModal = true
|
||||||
var ep = this.episodes[i]
|
|
||||||
if (epc.index != ep.index) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
},
|
||||||
editEpisode(episode) {
|
editEpisode(episode) {
|
||||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||||
this.$store.commit('globals/setSelectedEpisode', episode)
|
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||||
},
|
},
|
||||||
draggableUpdate() {
|
|
||||||
this.orderChanged = this.checkHasOrderChanged()
|
|
||||||
},
|
|
||||||
async saveOrder() {
|
|
||||||
if (!this.userCanUpdate) return
|
|
||||||
|
|
||||||
this.savingOrder = true
|
|
||||||
|
|
||||||
var episodesUpdate = {
|
|
||||||
episodes: this.episodesCopy.map((b) => b.id)
|
|
||||||
}
|
|
||||||
await this.$axios
|
|
||||||
.$patch(`/api/items/${this.libraryItem.id}/episodes`, episodesUpdate)
|
|
||||||
.then((podcast) => {
|
|
||||||
console.log('Podcast updated', podcast)
|
|
||||||
this.$toast.success('Saved episode order')
|
|
||||||
this.orderChanged = false
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to update podcast', error)
|
|
||||||
this.$toast.error('Failed to save podcast episode order')
|
|
||||||
})
|
|
||||||
this.savingOrder = false
|
|
||||||
},
|
|
||||||
init() {
|
init() {
|
||||||
this.episodesCopy = this.episodes.map((ep) => {
|
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
|
||||||
return {
|
|
||||||
...ep
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div ref="wrapper" class="relative">
|
||||||
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
<input ref="input" v-model="inputValue" :type="actualType" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-300 focus:bg-bg focus:outline-none border border-gray-600 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||||
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
||||||
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="type === 'password' && isHovering" class="absolute top-0 right-0 h-full px-4 flex items-center justify-center">
|
||||||
|
<span class="material-icons-outlined text-gray-400 cursor-pointer text-lg" @click.stop.prevent="showPassword = !showPassword">{{ !showPassword ? 'visibility' : 'visibility_off' }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -31,7 +34,10 @@ export default {
|
|||||||
clearable: Boolean
|
clearable: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
showPassword: false,
|
||||||
|
isHovering: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
inputValue: {
|
inputValue: {
|
||||||
@@ -49,6 +55,10 @@ export default {
|
|||||||
if (this.noSpinner) _list.push('no-spinner')
|
if (this.noSpinner) _list.push('no-spinner')
|
||||||
if (this.textCenter) _list.push('text-center')
|
if (this.textCenter) _list.push('text-center')
|
||||||
return _list.join(' ')
|
return _list.join(' ')
|
||||||
|
},
|
||||||
|
actualType() {
|
||||||
|
if (this.type === 'password' && this.showPassword) return 'text'
|
||||||
|
return this.type
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -69,9 +79,20 @@ export default {
|
|||||||
},
|
},
|
||||||
blur() {
|
blur() {
|
||||||
if (this.$refs.input) this.$refs.input.blur()
|
if (this.$refs.input) this.$refs.input.blur()
|
||||||
|
},
|
||||||
|
mouseover() {
|
||||||
|
this.isHovering = true
|
||||||
|
},
|
||||||
|
mouseleave() {
|
||||||
|
this.isHovering = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {
|
||||||
|
if (this.type === 'password' && this.$refs.wrapper) {
|
||||||
|
this.$refs.wrapper.addEventListener('mouseover', this.mouseover)
|
||||||
|
this.$refs.wrapper.addEventListener('mouseleave', this.mouseleave)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">
|
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': disabled }">
|
||||||
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||||
</p>
|
</p>
|
||||||
<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" @blur="inputBlurred" />
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex items-center py-3">
|
||||||
|
<slot />
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
||||||
|
<span class="material-icons text-2xl">chevron_left</span>
|
||||||
|
</button>
|
||||||
|
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
||||||
|
<span class="material-icons text-2xl">chevron_right</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||||
|
<div class="flex" :style="{ height: height + 'px' }">
|
||||||
|
<template v-for="(item, index) in items">
|
||||||
|
<cards-author-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :author="item" :height="cardHeight" :width="cardWidth" class="relative mx-2" @edit="editAuthor" @hook:updated="setScrollVars" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 192
|
||||||
|
},
|
||||||
|
bookshelfView: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isScrollable: false,
|
||||||
|
canScrollLeft: false,
|
||||||
|
canScrollRight: false,
|
||||||
|
clientWidth: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
return this.$store.getters['getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
cardScaleMulitiplier() {
|
||||||
|
return this.height / 192
|
||||||
|
},
|
||||||
|
cardHeight() {
|
||||||
|
return this.height
|
||||||
|
},
|
||||||
|
cardWidth() {
|
||||||
|
return this.cardHeight / this.bookCoverAspectRatio / 1.25
|
||||||
|
},
|
||||||
|
booksPerPage() {
|
||||||
|
return Math.floor(this.clientWidth / (this.cardWidth + 16))
|
||||||
|
},
|
||||||
|
isSelectionMode() {
|
||||||
|
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
editAuthor(author) {
|
||||||
|
this.$store.commit('globals/showEditAuthorModal', author)
|
||||||
|
},
|
||||||
|
scrolled() {
|
||||||
|
this.setScrollVars()
|
||||||
|
},
|
||||||
|
scrollRight() {
|
||||||
|
if (!this.canScrollRight) return
|
||||||
|
const slider = this.$refs.slider
|
||||||
|
if (!slider) return
|
||||||
|
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||||
|
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
|
||||||
|
|
||||||
|
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
|
||||||
|
slider.scrollLeft = newScrollLeft
|
||||||
|
},
|
||||||
|
scrollLeft() {
|
||||||
|
if (!this.canScrollLeft) return
|
||||||
|
const slider = this.$refs.slider
|
||||||
|
if (!slider) return
|
||||||
|
|
||||||
|
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||||
|
|
||||||
|
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
|
||||||
|
slider.scrollLeft = newScrollLeft
|
||||||
|
},
|
||||||
|
setScrollVars() {
|
||||||
|
const slider = this.$refs.slider
|
||||||
|
if (!slider) return
|
||||||
|
const { scrollLeft, scrollWidth, clientWidth } = slider
|
||||||
|
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
|
||||||
|
|
||||||
|
this.clientWidth = clientWidth
|
||||||
|
this.isScrollable = scrollWidth > clientWidth
|
||||||
|
this.canScrollRight = scrollPercent < 1
|
||||||
|
this.canScrollLeft = scrollLeft > 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updated() {
|
||||||
|
this.setScrollVars()
|
||||||
|
},
|
||||||
|
mounted() {},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,66 +1,64 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full relative">
|
<div class="w-full h-full relative">
|
||||||
<form class="w-full h-full" @submit.prevent="submitForm">
|
<form class="w-full h-full px-4 py-6" @submit.prevent="submitForm">
|
||||||
<div id="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
|
<div class="flex -mx-1">
|
||||||
<div class="flex -mx-1">
|
<div class="w-1/2 px-1">
|
||||||
<div class="w-1/2 px-1">
|
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||||
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-grow px-1">
|
||||||
<div class="flex mt-2 -mx-1">
|
<ui-text-input-with-label ref="subtitleInput" v-model="details.subtitle" label="Subtitle" />
|
||||||
<div class="w-3/4 px-1">
|
|
||||||
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
|
||||||
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex mt-2 -mx-1">
|
||||||
<div class="flex-grow px-1">
|
<div class="w-3/4 px-1">
|
||||||
<ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
|
<!-- Authors filter only contains authors in this library, use query input to query all authors -->
|
||||||
</div>
|
<ui-multi-select-query-input ref="authorsSelect" v-model="details.authors" label="Authors" endpoint="authors/search" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-grow px-1">
|
||||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
<ui-text-input-with-label ref="publishYearInput" v-model="details.publishedYear" type="number" label="Publish Year" />
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
|
||||||
<div class="w-1/2 px-1">
|
|
||||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<div class="flex mt-2 -mx-1">
|
||||||
<div class="w-1/2 px-1">
|
<div class="flex-grow px-1">
|
||||||
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
|
<ui-multi-select-query-input ref="seriesSelect" v-model="seriesItems" text-key="displayName" label="Series" readonly show-edit @edit="editSeriesItem" @add="addNewSeries" />
|
||||||
</div>
|
|
||||||
<div class="w-1/4 px-1">
|
|
||||||
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
|
|
||||||
</div>
|
|
||||||
<div class="w-1/4 px-1">
|
|
||||||
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||||
<div class="w-1/2 px-1">
|
|
||||||
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
|
<div class="flex mt-2 -mx-1">
|
||||||
</div>
|
<div class="w-1/2 px-1">
|
||||||
<div class="w-1/4 px-1">
|
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||||
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
</div>
|
||||||
</div>
|
<div class="flex-grow px-1">
|
||||||
<div class="flex-grow px-1 pt-6">
|
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||||
<div class="flex justify-center">
|
</div>
|
||||||
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
</div>
|
||||||
</div>
|
|
||||||
|
<div class="flex mt-2 -mx-1">
|
||||||
|
<div class="w-1/2 px-1">
|
||||||
|
<ui-multi-select ref="narratorsSelect" v-model="details.narrators" label="Narrators" :items="narrators" />
|
||||||
|
</div>
|
||||||
|
<div class="w-1/4 px-1">
|
||||||
|
<ui-text-input-with-label ref="isbnInput" v-model="details.isbn" label="ISBN" />
|
||||||
|
</div>
|
||||||
|
<div class="w-1/4 px-1">
|
||||||
|
<ui-text-input-with-label ref="asinInput" v-model="details.asin" label="ASIN" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex mt-2 -mx-1">
|
||||||
|
<div class="w-1/2 px-1">
|
||||||
|
<ui-text-input-with-label ref="publisherInput" v-model="details.publisher" label="Publisher" />
|
||||||
|
</div>
|
||||||
|
<div class="w-1/4 px-1">
|
||||||
|
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-1 pt-6">
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex items-center py-3">
|
||||||
|
<slot />
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
||||||
|
<span class="material-icons text-2xl">chevron_left</span>
|
||||||
|
</button>
|
||||||
|
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
||||||
|
<span class="material-icons text-2xl">chevron_right</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||||
|
<div class="flex" :style="{ height: height + 'px' }">
|
||||||
|
<template v-for="(item, index) in items">
|
||||||
|
<cards-lazy-book-card :key="item.recentEpisode.id" :ref="`slider-episode-${item.recentEpisode.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editEpisode" @editPodcast="editPodcast" @select="selectItem" @hook:updated="setScrollVars" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 192
|
||||||
|
},
|
||||||
|
bookshelfView: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isScrollable: false,
|
||||||
|
canScrollLeft: false,
|
||||||
|
canScrollRight: false,
|
||||||
|
clientWidth: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
return this.$store.getters['getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
cardScaleMulitiplier() {
|
||||||
|
return this.height / 192
|
||||||
|
},
|
||||||
|
cardHeight() {
|
||||||
|
return this.height - 40 * this.cardScaleMulitiplier
|
||||||
|
},
|
||||||
|
cardWidth() {
|
||||||
|
return this.cardHeight / this.bookCoverAspectRatio
|
||||||
|
},
|
||||||
|
booksPerPage() {
|
||||||
|
return Math.floor(this.clientWidth / (this.cardWidth + 16))
|
||||||
|
},
|
||||||
|
isSelectionMode() {
|
||||||
|
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clearSelectedEntities() {
|
||||||
|
this.updateSelectionMode(false)
|
||||||
|
},
|
||||||
|
editEpisode({ libraryItem, episode }) {
|
||||||
|
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||||
|
this.$store.commit('globals/setSelectedEpisode', episode)
|
||||||
|
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||||
|
},
|
||||||
|
editPodcast(libraryItem) {
|
||||||
|
var itemIds = this.items.map((e) => e.id)
|
||||||
|
this.$store.commit('setBookshelfBookIds', itemIds)
|
||||||
|
this.$store.commit('showEditModal', libraryItem)
|
||||||
|
},
|
||||||
|
selectItem(libraryItem) {
|
||||||
|
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$eventBus.$emit('item-selected', libraryItem)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
itemSelectedEvt() {
|
||||||
|
this.updateSelectionMode(this.isSelectionMode)
|
||||||
|
},
|
||||||
|
updateSelectionMode(val) {
|
||||||
|
var selectedLibraryItems = this.$store.state.selectedLibraryItems
|
||||||
|
|
||||||
|
this.items.forEach((ent) => {
|
||||||
|
var component = this.$refs[`slider-episode-${ent.recentEpisode.id}`]
|
||||||
|
if (!component || !component.length) return
|
||||||
|
component = component[0]
|
||||||
|
component.setSelectionMode(val)
|
||||||
|
component.selected = selectedLibraryItems.includes(ent.id)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
scrolled() {
|
||||||
|
this.setScrollVars()
|
||||||
|
},
|
||||||
|
scrollRight() {
|
||||||
|
if (!this.canScrollRight) return
|
||||||
|
const slider = this.$refs.slider
|
||||||
|
if (!slider) return
|
||||||
|
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||||
|
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
|
||||||
|
|
||||||
|
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
|
||||||
|
slider.scrollLeft = newScrollLeft
|
||||||
|
},
|
||||||
|
scrollLeft() {
|
||||||
|
if (!this.canScrollLeft) return
|
||||||
|
const slider = this.$refs.slider
|
||||||
|
if (!slider) return
|
||||||
|
|
||||||
|
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||||
|
|
||||||
|
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
|
||||||
|
slider.scrollLeft = newScrollLeft
|
||||||
|
},
|
||||||
|
setScrollVars() {
|
||||||
|
const slider = this.$refs.slider
|
||||||
|
if (!slider) return
|
||||||
|
const { scrollLeft, scrollWidth, clientWidth } = slider
|
||||||
|
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
|
||||||
|
|
||||||
|
this.clientWidth = clientWidth
|
||||||
|
this.isScrollable = scrollWidth > clientWidth
|
||||||
|
this.canScrollRight = scrollPercent < 1
|
||||||
|
this.canScrollLeft = scrollLeft > 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updated() {
|
||||||
|
this.setScrollVars()
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||||
|
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||||
|
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex items-center py-3">
|
||||||
|
<slot />
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
||||||
|
<span class="material-icons text-2xl">chevron_left</span>
|
||||||
|
</button>
|
||||||
|
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
||||||
|
<span class="material-icons text-2xl">chevron_right</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||||
|
<div class="flex" :style="{ height: height + 'px' }">
|
||||||
|
<template v-for="(item, index) in items">
|
||||||
|
<cards-lazy-book-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :book-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="bookshelfView" class="relative mx-2" @edit="editItem" @select="selectItem" @hook:updated="setScrollVars" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 192
|
||||||
|
},
|
||||||
|
bookshelfView: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isScrollable: false,
|
||||||
|
canScrollLeft: false,
|
||||||
|
canScrollRight: false,
|
||||||
|
clientWidth: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
return this.$store.getters['getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
cardScaleMulitiplier() {
|
||||||
|
return this.height / 192
|
||||||
|
},
|
||||||
|
cardHeight() {
|
||||||
|
return this.height - 40 * this.cardScaleMulitiplier
|
||||||
|
},
|
||||||
|
cardWidth() {
|
||||||
|
return this.cardHeight / this.bookCoverAspectRatio
|
||||||
|
},
|
||||||
|
booksPerPage() {
|
||||||
|
return Math.floor(this.clientWidth / (this.cardWidth + 16))
|
||||||
|
},
|
||||||
|
isSelectionMode() {
|
||||||
|
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clearSelectedEntities() {
|
||||||
|
this.updateSelectionMode(false)
|
||||||
|
},
|
||||||
|
editItem(libraryItem) {
|
||||||
|
var itemIds = this.items.map((e) => e.id)
|
||||||
|
this.$store.commit('setBookshelfBookIds', itemIds)
|
||||||
|
this.$store.commit('showEditModal', libraryItem)
|
||||||
|
},
|
||||||
|
selectItem(libraryItem) {
|
||||||
|
this.$store.commit('toggleLibraryItemSelected', libraryItem.id)
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$eventBus.$emit('item-selected', libraryItem)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
itemSelectedEvt() {
|
||||||
|
this.updateSelectionMode(this.isSelectionMode)
|
||||||
|
},
|
||||||
|
updateSelectionMode(val) {
|
||||||
|
var selectedLibraryItems = this.$store.state.selectedLibraryItems
|
||||||
|
|
||||||
|
this.items.forEach((item) => {
|
||||||
|
var component = this.$refs[`slider-item-${item.id}`]
|
||||||
|
if (!component || !component.length) return
|
||||||
|
component = component[0]
|
||||||
|
component.setSelectionMode(val)
|
||||||
|
component.selected = selectedLibraryItems.includes(item.id)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
scrolled() {
|
||||||
|
this.setScrollVars()
|
||||||
|
},
|
||||||
|
scrollRight() {
|
||||||
|
if (!this.canScrollRight) return
|
||||||
|
const slider = this.$refs.slider
|
||||||
|
if (!slider) return
|
||||||
|
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||||
|
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
|
||||||
|
|
||||||
|
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
|
||||||
|
slider.scrollLeft = newScrollLeft
|
||||||
|
},
|
||||||
|
scrollLeft() {
|
||||||
|
if (!this.canScrollLeft) return
|
||||||
|
const slider = this.$refs.slider
|
||||||
|
if (!slider) return
|
||||||
|
|
||||||
|
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||||
|
|
||||||
|
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
|
||||||
|
slider.scrollLeft = newScrollLeft
|
||||||
|
},
|
||||||
|
setScrollVars() {
|
||||||
|
const slider = this.$refs.slider
|
||||||
|
if (!slider) return
|
||||||
|
const { scrollLeft, scrollWidth, clientWidth } = slider
|
||||||
|
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
|
||||||
|
|
||||||
|
this.clientWidth = clientWidth
|
||||||
|
this.isScrollable = scrollWidth > clientWidth
|
||||||
|
this.canScrollRight = scrollPercent < 1
|
||||||
|
this.canScrollLeft = scrollLeft > 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updated() {
|
||||||
|
this.setScrollVars()
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||||
|
this.$eventBus.$on('item-selected', this.itemSelectedEvt)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||||
|
this.$eventBus.$off('item-selected', this.itemSelectedEvt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="absolute w-32 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="clickAction(item.func)">
|
<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="clickAction(item.func)">
|
||||||
<p>{{ item.text }}</p>
|
<p>{{ item.text }}</p>
|
||||||
|
|||||||
@@ -1,50 +1,48 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full relative">
|
<div class="w-full h-full relative">
|
||||||
<form class="w-full h-full" @submit.prevent="submitForm">
|
<form class="w-full h-full px-4 py-6" @submit.prevent="submitForm">
|
||||||
<div id="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
|
<div class="flex -mx-1">
|
||||||
<div class="flex -mx-1">
|
<div class="w-1/2 px-1">
|
||||||
<div class="w-1/2 px-1">
|
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
||||||
<ui-text-input-with-label ref="titleInput" v-model="details.title" label="Title" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-text-input-with-label ref="authorInput" v-model="details.author" label="Author" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-grow px-1">
|
||||||
<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" label="RSS Feed URL" class="mt-2" />
|
<ui-text-input-with-label ref="authorInput" v-model="details.author" label="Author" />
|
||||||
|
|
||||||
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
|
||||||
<div class="w-1/2 px-1">
|
|
||||||
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1">
|
|
||||||
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex mt-2 -mx-1">
|
<ui-text-input-with-label ref="feedUrlInput" v-model="details.feedUrl" label="RSS Feed URL" class="mt-2" />
|
||||||
<div class="w-1/4 px-1">
|
|
||||||
<ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" label="Release Date" />
|
<ui-textarea-with-label ref="descriptionInput" v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||||
</div>
|
|
||||||
<div class="w-1/4 px-1">
|
<div class="flex mt-2 -mx-1">
|
||||||
<ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" />
|
<div class="w-1/2 px-1">
|
||||||
</div>
|
<ui-multi-select ref="genresSelect" v-model="details.genres" label="Genres" :items="genres" />
|
||||||
<div class="w-1/4 px-1">
|
|
||||||
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-1 pt-6">
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex-grow px-1">
|
||||||
|
<ui-multi-select ref="tagsSelect" v-model="newTags" label="Tags" :items="tags" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex mt-2 -mx-1">
|
||||||
|
<div class="w-1/4 px-1">
|
||||||
|
<ui-text-input-with-label ref="releaseDateInput" v-model="details.releaseDate" label="Release Date" />
|
||||||
|
</div>
|
||||||
|
<div class="w-1/4 px-1">
|
||||||
|
<ui-text-input-with-label ref="itunesIdInput" v-model="details.itunesId" label="iTunes ID" />
|
||||||
|
</div>
|
||||||
|
<div class="w-1/4 px-1">
|
||||||
|
<ui-text-input-with-label ref="languageInput" v-model="details.language" label="Language" />
|
||||||
|
</div>
|
||||||
<div class="flex-grow px-1 pt-6">
|
<div class="flex-grow px-1 pt-6">
|
||||||
<ui-checkbox v-model="autoDownloadEpisodes" label="Auto Download New Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
<div class="flex justify-center">
|
||||||
|
<ui-checkbox v-model="details.explicit" label="Explicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-grow px-1 pt-6">
|
||||||
|
<ui-checkbox v-model="autoDownloadEpisodes" label="Auto Download New Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="flex items-center py-3">
|
||||||
|
<slot />
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
|
||||||
|
<span class="material-icons text-2xl">chevron_left</span>
|
||||||
|
</button>
|
||||||
|
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
|
||||||
|
<span class="material-icons text-2xl">chevron_right</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
|
||||||
|
<div class="flex" :style="{ height: height + 'px' }">
|
||||||
|
<template v-for="(item, index) in items">
|
||||||
|
<cards-lazy-series-card :key="item.id" :ref="`slider-item-${item.id}`" :index="index" :series-mount="item" :height="cardHeight" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" :bookshelf-view="$constants.BookshelfView.TITLES" class="relative mx-2" @hook:updated="setScrollVars" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 192
|
||||||
|
},
|
||||||
|
bookshelfView: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isScrollable: false,
|
||||||
|
canScrollLeft: false,
|
||||||
|
canScrollRight: false,
|
||||||
|
clientWidth: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
return this.$store.getters['getBookCoverAspectRatio']
|
||||||
|
},
|
||||||
|
cardScaleMulitiplier() {
|
||||||
|
return this.height / 192
|
||||||
|
},
|
||||||
|
cardHeight() {
|
||||||
|
return this.height - 40 * this.cardScaleMulitiplier
|
||||||
|
},
|
||||||
|
cardWidth() {
|
||||||
|
return 2 * (this.cardHeight / this.bookCoverAspectRatio)
|
||||||
|
},
|
||||||
|
booksPerPage() {
|
||||||
|
return Math.floor(this.clientWidth / (this.cardWidth + 16))
|
||||||
|
},
|
||||||
|
isSelectionMode() {
|
||||||
|
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
scrolled() {
|
||||||
|
this.setScrollVars()
|
||||||
|
},
|
||||||
|
scrollRight() {
|
||||||
|
if (!this.canScrollRight) return
|
||||||
|
const slider = this.$refs.slider
|
||||||
|
if (!slider) return
|
||||||
|
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||||
|
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
|
||||||
|
|
||||||
|
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
|
||||||
|
slider.scrollLeft = newScrollLeft
|
||||||
|
},
|
||||||
|
scrollLeft() {
|
||||||
|
if (!this.canScrollLeft) return
|
||||||
|
const slider = this.$refs.slider
|
||||||
|
if (!slider) return
|
||||||
|
|
||||||
|
const scrollAmount = this.booksPerPage * this.cardWidth
|
||||||
|
|
||||||
|
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
|
||||||
|
slider.scrollLeft = newScrollLeft
|
||||||
|
},
|
||||||
|
setScrollVars() {
|
||||||
|
const slider = this.$refs.slider
|
||||||
|
if (!slider) return
|
||||||
|
const { scrollLeft, scrollWidth, clientWidth } = slider
|
||||||
|
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
|
||||||
|
|
||||||
|
this.clientWidth = clientWidth
|
||||||
|
this.isScrollable = scrollWidth > clientWidth
|
||||||
|
this.canScrollRight = scrollPercent < 1
|
||||||
|
this.canScrollLeft = scrollLeft > 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updated() {
|
||||||
|
this.setScrollVars()
|
||||||
|
},
|
||||||
|
mounted() {},
|
||||||
|
beforeDestroy() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
|
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
|
||||||
<app-appbar />
|
<app-appbar />
|
||||||
|
|
||||||
<Nuxt />
|
<app-side-rail v-if="isShowingSideRail" class="hidden md:block" />
|
||||||
|
<div id="app-content" class="h-full" :class="{ 'has-siderail': isShowingSideRail }">
|
||||||
|
<Nuxt />
|
||||||
|
</div>
|
||||||
|
|
||||||
<app-stream-container ref="streamContainer" />
|
<app-stream-container ref="streamContainer" />
|
||||||
|
|
||||||
@@ -11,6 +14,7 @@
|
|||||||
<modals-edit-collection-modal />
|
<modals-edit-collection-modal />
|
||||||
<modals-bookshelf-texture-modal />
|
<modals-bookshelf-texture-modal />
|
||||||
<modals-podcast-edit-episode />
|
<modals-podcast-edit-episode />
|
||||||
|
<modals-authors-edit-modal />
|
||||||
<readers-reader />
|
<readers-reader />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -44,6 +48,13 @@ export default {
|
|||||||
},
|
},
|
||||||
isCasting() {
|
isCasting() {
|
||||||
return this.$store.state.globals.isCasting
|
return this.$store.state.globals.isCasting
|
||||||
|
},
|
||||||
|
isShowingSideRail() {
|
||||||
|
if (!this.$route.name) return false
|
||||||
|
return !this.$route.name.startsWith('config') && this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
appContentMarginLeft() {
|
||||||
|
return this.isShowingSideRail ? 80 : 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -162,6 +173,7 @@ export default {
|
|||||||
this.$store.commit('libraries/addUpdate', library)
|
this.$store.commit('libraries/addUpdate', library)
|
||||||
},
|
},
|
||||||
async libraryRemoved(library) {
|
async libraryRemoved(library) {
|
||||||
|
console.log('Library removed', library)
|
||||||
this.$store.commit('libraries/remove', library)
|
this.$store.commit('libraries/remove', library)
|
||||||
|
|
||||||
// When removed currently selected library then set next accessible library
|
// When removed currently selected library then set next accessible library
|
||||||
@@ -180,18 +192,20 @@ export default {
|
|||||||
this.$router.push(`/library/${nextLibrary.id}`)
|
this.$router.push(`/library/${nextLibrary.id}`)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('User has no accessible libraries')
|
console.error('User has no more accessible libraries')
|
||||||
|
this.$store.commit('libraries/setCurrentLibrary', null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
libraryItemAdded(libraryItem) {
|
libraryItemAdded(libraryItem) {
|
||||||
// this.$store.commit('libraries/updateFilterDataWithAudiobook', libraryItem)
|
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
|
||||||
},
|
},
|
||||||
libraryItemUpdated(libraryItem) {
|
libraryItemUpdated(libraryItem) {
|
||||||
if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) {
|
if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) {
|
||||||
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
this.$store.commit('setSelectedLibraryItem', libraryItem)
|
||||||
}
|
}
|
||||||
this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem)
|
this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem)
|
||||||
|
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
|
||||||
},
|
},
|
||||||
libraryItemRemoved(item) {
|
libraryItemRemoved(item) {
|
||||||
if (this.$route.name.startsWith('item')) {
|
if (this.$route.name.startsWith('item')) {
|
||||||
@@ -549,4 +563,20 @@ export default {
|
|||||||
.Vue-Toastification__toast-body.custom-class-1 {
|
.Vue-Toastification__toast-body.custom-class-1 {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#app-content {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#app-content.has-siderail {
|
||||||
|
width: calc(100% - 80px);
|
||||||
|
max-width: calc(100% - 80px);
|
||||||
|
margin-left: 80px;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#app-content.has-siderail {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-left: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
export default function (context) {
|
|
||||||
if (process.client) {
|
|
||||||
var route = context.route
|
|
||||||
var from = context.from
|
|
||||||
var store = context.store
|
|
||||||
|
|
||||||
if (route.name === 'login' || from.name === 'login') return
|
|
||||||
|
|
||||||
if (!route.name) {
|
|
||||||
console.warn('No Route name', route)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (store.state.isRoutingBack) {
|
|
||||||
// pressing back button in appbar do not add to route history
|
|
||||||
store.commit('setIsRoutingBack', false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (route.name.startsWith('config') || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id') || route.name.startsWith('collection-id')) {
|
|
||||||
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && !from.name.startsWith('config') && from.name !== 'upload' && from.name !== 'account') {
|
|
||||||
var _history = [...store.state.routeHistory]
|
|
||||||
if (!_history.length || _history[_history.length - 1] !== from.fullPath) {
|
|
||||||
_history.push(from.fullPath)
|
|
||||||
store.commit('setRouteHistory', _history)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -36,9 +36,7 @@ module.exports = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
router: {
|
router: {},
|
||||||
middleware: ['routed']
|
|
||||||
},
|
|
||||||
|
|
||||||
// Global CSS: https://go.nuxtjs.dev/config-css
|
// Global CSS: https://go.nuxtjs.dev/config-css
|
||||||
css: [
|
css: [
|
||||||
|
|||||||
Generated
+701
-1604
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.0.11",
|
"version": "2.0.16",
|
||||||
"description": "Audiobook manager and player",
|
"description": "Self-hosted audiobook and podcast client",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nuxt",
|
"dev": "nuxt",
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
<template>
|
||||||
|
<div id="page-wrapper" class="bg-bg page overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
|
<div class="flex items-center py-4 max-w-7xl mx-auto">
|
||||||
|
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
|
||||||
|
<h1 class="text-xl">{{ title }}</h1>
|
||||||
|
</nuxt-link>
|
||||||
|
<button class="w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white" @click="editItem">
|
||||||
|
<span class="material-icons text-base">edit</span>
|
||||||
|
</button>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p class="text-base">Duration:</p>
|
||||||
|
<p class="text-base font-mono ml-8">{{ mediaDuration }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap-reverse justify-center py-4">
|
||||||
|
<div class="w-full max-w-3xl py-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<p class="text-lg mb-4 font-semibold">Audiobook Chapters</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">Lookup</ui-btn>
|
||||||
|
<ui-btn color="success" small @click="saveChapters">Save</ui-btn>
|
||||||
|
<div class="w-40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
||||||
|
<div class="w-12"></div>
|
||||||
|
<div class="w-32 px-2">Start</div>
|
||||||
|
<div class="flex-grow px-2">Title</div>
|
||||||
|
<div class="w-40"></div>
|
||||||
|
</div>
|
||||||
|
<template v-for="chapter in newChapters">
|
||||||
|
<div :key="chapter.id" class="flex py-1">
|
||||||
|
<div class="w-12">#{{ chapter.id + 1 }}</div>
|
||||||
|
<div class="w-32 px-1">
|
||||||
|
<ui-text-input v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-1">
|
||||||
|
<ui-text-input v-model="chapter.title" class="text-xs" />
|
||||||
|
</div>
|
||||||
|
<div class="w-40 px-2 py-1">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)">
|
||||||
|
<span class="material-icons-outlined text-base">remove</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ui-tooltip text="Insert chapter below" direction="bottom">
|
||||||
|
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150" @click="addChapter(chapter)">
|
||||||
|
<span class="material-icons text-lg">add</span>
|
||||||
|
</button>
|
||||||
|
</ui-tooltip>
|
||||||
|
|
||||||
|
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150" @click="playChapter(chapter)">
|
||||||
|
<widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" />
|
||||||
|
<span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-icons-outlined text-base">pause</span>
|
||||||
|
<span v-else class="material-icons-outlined text-base">play_arrow</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ui-tooltip v-if="chapter.error" :text="chapter.error" direction="left">
|
||||||
|
<button class="w-7 h-7 rounded-full flex items-center justify-center text-error">
|
||||||
|
<span class="material-icons-outlined text-lg">error_outline</span>
|
||||||
|
</button>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full max-w-xl py-4">
|
||||||
|
<p class="text-lg mb-4 font-semibold py-1">Audio Tracks</p>
|
||||||
|
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
||||||
|
<div class="flex-grow">Filename</div>
|
||||||
|
<div class="w-20">Duration</div>
|
||||||
|
<div class="w-20 text-center">Chapters</div>
|
||||||
|
</div>
|
||||||
|
<template v-for="track in audioTracks">
|
||||||
|
<div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success bg-opacity-10' : ''">
|
||||||
|
<div class="flex-grow">
|
||||||
|
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-20" style="min-width: 80px">
|
||||||
|
<p class="text-xs font-mono text-gray-200">{{ track.duration }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-20 flex justify-center" style="min-width: 80px">
|
||||||
|
<span v-if="(track.chapters || []).length" class="material-icons text-success text-sm">check</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="saving" class="w-full h-full absolute top-0 left-0 bottom-0 right-0 z-30 bg-black bg-opacity-25 flex items-center justify-center">
|
||||||
|
<ui-loading-indicator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<modals-modal v-model="showFindChaptersModal" name="edit-book" :width="500" :processing="findingChapters">
|
||||||
|
<template #outer>
|
||||||
|
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||||
|
<p class="font-book text-3xl text-white truncate pointer-events-none">Find Chapters</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative">
|
||||||
|
<div v-if="!chapterData" class="flex p-20">
|
||||||
|
<ui-text-input-with-label v-model="asinInput" label="ASIN" />
|
||||||
|
<ui-btn small color="primary" class="mt-5 ml-2" @click="findChapters">Find</ui-btn>
|
||||||
|
</div>
|
||||||
|
<div v-else class="w-full p-4">
|
||||||
|
<p class="mb-4">Duration found: {{ chapterData.runtimeLengthSec }}</p>
|
||||||
|
<div v-if="chapterData.runtimeLengthSec > mediaDuration" class="w-full bg-error bg-opacity-25 p-4 text-center mb-2 rounded border border-white border-opacity-10 text-gray-100 text-sm">
|
||||||
|
<p>Chapter data invalid duration<br />Your media duration is shorter than duration found</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="flex-grow px-2">Title</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full max-h-80 overflow-y-auto my-2">
|
||||||
|
<div v-for="(chapter, index) in chapterData.chapters" :key="index" class="flex py-0.5 text-xs" :class="chapter.startOffsetSec > mediaDuration ? 'bg-error bg-opacity-20' : chapter.startOffsetSec + chapter.lengthMs / 1000 > mediaDuration ? 'bg-warning bg-opacity-20' : index % 2 === 0 ? 'bg-primary bg-opacity-30' : ''">
|
||||||
|
<div class="w-24 min-w-24 px-2">
|
||||||
|
<p class="font-mono">{{ $secondsToTimestamp(chapter.startOffsetSec) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-2">
|
||||||
|
<p class="truncate max-w-sm">{{ chapter.title }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex pt-2">
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<ui-btn small color="success" @click="applyChapterData">Apply Chapters</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</modals-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
async asyncData({ store, params, app, redirect, route }) {
|
||||||
|
if (!store.getters['user/getUserCanUpdate']) {
|
||||||
|
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('/')
|
||||||
|
}
|
||||||
|
if (libraryItem.mediaType != 'book') {
|
||||||
|
console.error('Invalid media type')
|
||||||
|
return redirect('/')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
libraryItem
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
newChapters: [],
|
||||||
|
selectedChapter: null,
|
||||||
|
audioEl: null,
|
||||||
|
isPlayingChapter: false,
|
||||||
|
isLoadingChapter: false,
|
||||||
|
currentTrackIndex: 0,
|
||||||
|
saving: false,
|
||||||
|
asinInput: null,
|
||||||
|
findingChapters: false,
|
||||||
|
showFindChaptersModal: false,
|
||||||
|
chapterData: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
streamLibraryItem() {
|
||||||
|
return this.$store.state.streamLibraryItem
|
||||||
|
},
|
||||||
|
userToken() {
|
||||||
|
return this.$store.getters['user/getToken']
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.mediaMetadata.title
|
||||||
|
},
|
||||||
|
mediaDuration() {
|
||||||
|
return this.media.duration
|
||||||
|
},
|
||||||
|
chapters() {
|
||||||
|
return this.media.chapters || []
|
||||||
|
},
|
||||||
|
tracks() {
|
||||||
|
return this.media.tracks || []
|
||||||
|
},
|
||||||
|
audioFiles() {
|
||||||
|
return this.media.audioFiles || []
|
||||||
|
},
|
||||||
|
audioTracks() {
|
||||||
|
return this.audioFiles.filter((af) => !af.exclude && !af.invalid)
|
||||||
|
},
|
||||||
|
selectedChapterId() {
|
||||||
|
return this.selectedChapter ? this.selectedChapter.id : null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
editItem() {
|
||||||
|
this.$store.commit('showEditModal', this.libraryItem)
|
||||||
|
},
|
||||||
|
addChapter(chapter) {
|
||||||
|
console.log('Add chapter', chapter)
|
||||||
|
const newChapter = {
|
||||||
|
id: chapter.id + 1,
|
||||||
|
start: chapter.start,
|
||||||
|
end: chapter.end,
|
||||||
|
title: ''
|
||||||
|
}
|
||||||
|
this.newChapters.splice(chapter.id + 1, 0, newChapter)
|
||||||
|
this.checkChapters()
|
||||||
|
},
|
||||||
|
removeChapter(chapter) {
|
||||||
|
this.newChapters = this.newChapters.filter((ch) => ch.id !== chapter.id)
|
||||||
|
this.checkChapters()
|
||||||
|
},
|
||||||
|
checkChapters() {
|
||||||
|
var previousStart = 0
|
||||||
|
for (let i = 0; i < this.newChapters.length; i++) {
|
||||||
|
this.newChapters[i].id = i
|
||||||
|
this.newChapters[i].start = Number(this.newChapters[i].start)
|
||||||
|
|
||||||
|
if (i === 0 && this.newChapters[i].start !== 0) {
|
||||||
|
this.newChapters[i].error = 'First chapter must start at 0'
|
||||||
|
} else if (this.newChapters[i].start <= previousStart && i > 0) {
|
||||||
|
this.newChapters[i].error = 'Invalid start time must be >= previous chapter start time'
|
||||||
|
} else if (this.newChapters[i].start >= this.mediaDuration) {
|
||||||
|
this.newChapters[i].error = 'Invalid start time must be < duration'
|
||||||
|
} else {
|
||||||
|
this.newChapters[i].error = null
|
||||||
|
}
|
||||||
|
previousStart = this.newChapters[i].start
|
||||||
|
}
|
||||||
|
},
|
||||||
|
playChapter(chapter) {
|
||||||
|
console.log('Play Chapter', chapter.id)
|
||||||
|
if (this.selectedChapterId === chapter.id) {
|
||||||
|
console.log('Chapter already playing', this.isLoadingChapter, this.isPlayingChapter)
|
||||||
|
if (this.isLoadingChapter) return
|
||||||
|
if (this.isPlayingChapter) {
|
||||||
|
console.log('Destroying chapter')
|
||||||
|
this.destroyAudioEl()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.selectedChapterId) {
|
||||||
|
this.destroyAudioEl()
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioTrack = this.tracks.find((at) => {
|
||||||
|
return chapter.start >= at.startOffset && chapter.start < at.startOffset + at.duration
|
||||||
|
})
|
||||||
|
console.log('audio track', audioTrack)
|
||||||
|
|
||||||
|
this.selectedChapter = chapter
|
||||||
|
this.isLoadingChapter = true
|
||||||
|
|
||||||
|
const trackOffset = chapter.start - audioTrack.startOffset
|
||||||
|
this.playTrackAtTime(audioTrack, trackOffset)
|
||||||
|
},
|
||||||
|
playTrackAtTime(audioTrack, trackOffset) {
|
||||||
|
this.currentTrackIndex = audioTrack.index
|
||||||
|
|
||||||
|
const audioEl = this.audioEl || document.createElement('audio')
|
||||||
|
var src = audioTrack.contentUrl + `?token=${this.userToken}`
|
||||||
|
if (this.$isDev) {
|
||||||
|
src = `http://localhost:3333${src}`
|
||||||
|
}
|
||||||
|
console.log('src', src)
|
||||||
|
|
||||||
|
audioEl.src = src
|
||||||
|
audioEl.id = 'chapter-audio'
|
||||||
|
document.body.appendChild(audioEl)
|
||||||
|
|
||||||
|
audioEl.addEventListener('loadeddata', () => {
|
||||||
|
console.log('Audio loaded data', audioEl.duration)
|
||||||
|
audioEl.currentTime = trackOffset
|
||||||
|
audioEl.play()
|
||||||
|
console.log('Playing audio at current time', trackOffset)
|
||||||
|
})
|
||||||
|
audioEl.addEventListener('play', () => {
|
||||||
|
console.log('Audio playing')
|
||||||
|
this.isLoadingChapter = false
|
||||||
|
this.isPlayingChapter = true
|
||||||
|
})
|
||||||
|
audioEl.addEventListener('ended', () => {
|
||||||
|
console.log('Audio ended')
|
||||||
|
const nextTrack = this.tracks.find((t) => t.index === this.currentTrackIndex + 1)
|
||||||
|
if (nextTrack) {
|
||||||
|
console.log('Playing next track', nextTrack.index)
|
||||||
|
this.currentTrackIndex = nextTrack.index
|
||||||
|
this.playTrackAtTime(nextTrack, 0)
|
||||||
|
} else {
|
||||||
|
console.log('No next track')
|
||||||
|
this.destroyAudioEl()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.audioEl = audioEl
|
||||||
|
},
|
||||||
|
destroyAudioEl() {
|
||||||
|
if (!this.audioEl) return
|
||||||
|
this.audioEl.remove()
|
||||||
|
this.audioEl = null
|
||||||
|
this.selectedChapter = null
|
||||||
|
this.isPlayingChapter = false
|
||||||
|
this.isLoadingChapter = false
|
||||||
|
},
|
||||||
|
saveChapters() {
|
||||||
|
this.checkChapters()
|
||||||
|
|
||||||
|
for (let i = 0; i < this.newChapters.length; i++) {
|
||||||
|
if (this.newChapters[i].error) {
|
||||||
|
this.$toast.error('Chapters have errors')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.newChapters[i].title) {
|
||||||
|
this.$toast.error('Chapters must have titles')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextChapter = this.newChapters[i + 1]
|
||||||
|
if (nextChapter) {
|
||||||
|
this.newChapters[i].end = nextChapter.start
|
||||||
|
} else {
|
||||||
|
this.newChapters[i].end = this.mediaDuration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saving = true
|
||||||
|
|
||||||
|
console.log('udpated chapters', this.newChapters)
|
||||||
|
const payload = {
|
||||||
|
chapters: this.newChapters
|
||||||
|
}
|
||||||
|
this.$axios
|
||||||
|
.$post(`/api/items/${this.libraryItem.id}/chapters`, payload)
|
||||||
|
.then((data) => {
|
||||||
|
this.saving = false
|
||||||
|
if (data.updated) {
|
||||||
|
this.$toast.success('Chapters updated')
|
||||||
|
this.$router.push(`/item/${this.libraryItem.id}`)
|
||||||
|
} else {
|
||||||
|
this.$toast.info('No changes needed updating')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.saving = false
|
||||||
|
console.error('Failed to update chapters', error)
|
||||||
|
this.$toast.error('Failed to update chapters')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
applyChapterData() {
|
||||||
|
var index = 0
|
||||||
|
this.newChapters = this.chapterData.chapters
|
||||||
|
.filter((chap) => chap.startOffsetSec < this.mediaDuration)
|
||||||
|
.map((chap) => {
|
||||||
|
var chapEnd = Math.min(this.mediaDuration, (chap.startOffsetMs + chap.lengthMs) / 1000)
|
||||||
|
return {
|
||||||
|
id: index++,
|
||||||
|
start: chap.startOffsetMs / 1000,
|
||||||
|
end: chapEnd,
|
||||||
|
title: chap.title
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.showFindChaptersModal = false
|
||||||
|
this.chapterData = null
|
||||||
|
},
|
||||||
|
findChapters() {
|
||||||
|
if (!this.asinInput) {
|
||||||
|
this.$toast.error('Must input an ASIN')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.findingChapters = true
|
||||||
|
this.chapterData = null
|
||||||
|
this.$axios
|
||||||
|
.$get(`/api/search/chapters?asin=${this.asinInput}`)
|
||||||
|
.then((data) => {
|
||||||
|
this.findingChapters = false
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
this.$toast.error(data.error)
|
||||||
|
this.showFindChaptersModal = false
|
||||||
|
} else {
|
||||||
|
console.log('Chapter data', data)
|
||||||
|
this.chapterData = data
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
this.findingChapters = false
|
||||||
|
console.error('Failed to get chapter data', error)
|
||||||
|
this.$toast.error('Failed to find chapters')
|
||||||
|
this.showFindChaptersModal = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.asinInput = this.mediaMetadata.asin || null
|
||||||
|
this.newChapters = this.chapters.map((c) => ({ ...c }))
|
||||||
|
if (!this.newChapters.length) {
|
||||||
|
this.newChapters = [
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
start: 0,
|
||||||
|
end: this.mediaDuration,
|
||||||
|
title: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -89,9 +89,6 @@ export default {
|
|||||||
draggable
|
draggable
|
||||||
},
|
},
|
||||||
async asyncData({ store, params, app, redirect, route }) {
|
async asyncData({ store, params, app, redirect, route }) {
|
||||||
if (!store.state.user.user) {
|
|
||||||
return redirect(`/login?redirect=${route.path}`)
|
|
||||||
}
|
|
||||||
if (!store.getters['user/getUserCanUpdate']) {
|
if (!store.getters['user/getUserCanUpdate']) {
|
||||||
return redirect('/?error=unauthorized')
|
return redirect('/?error=unauthorized')
|
||||||
}
|
}
|
||||||
@@ -129,9 +126,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
media() {
|
media() {
|
||||||
return this.libraryItem.media || {}
|
return this.libraryItem.media || {}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<template>
|
||||||
|
<div id="page-wrapper" class="bg-bg page overflow-y-auto p-8" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<div class="flex mb-6">
|
||||||
|
<div class="w-48 min-w-48">
|
||||||
|
<div class="w-full h-52">
|
||||||
|
<covers-author-image :author="author" rounded="0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-8">
|
||||||
|
<div class="flex items-center mb-8">
|
||||||
|
<h1 class="text-2xl">{{ author.name }}</h1>
|
||||||
|
|
||||||
|
<button v-if="userCanUpdate" class="w-8 h-8 rounded-full flex items-center justify-center mx-4 cursor-pointer text-gray-300 hover:text-warning transform hover:scale-125 duration-100" @click="editAuthor">
|
||||||
|
<span class="material-icons text-base">edit</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="author.description" class="text-white text-opacity-60 uppercase text-xs mb-2">Description</p>
|
||||||
|
<p class="text-white max-w-3xl text-sm leading-5">{{ author.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="py-4">
|
||||||
|
<widgets-item-slider :items="libraryItems" :bookshelf-view="$constants.BookshelfView.AUTHOR">
|
||||||
|
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">
|
||||||
|
<h2 class="text-lg">{{ libraryItems.length }} Books</h2>
|
||||||
|
</nuxt-link>
|
||||||
|
</widgets-item-slider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="series in authorSeries" :key="series.id" class="py-4">
|
||||||
|
<widgets-item-slider :items="series.items" :bookshelf-view="$constants.BookshelfView.AUTHOR">
|
||||||
|
<nuxt-link :to="`/library/${currentLibraryId}/series/${series.id}`" class="hover:underline">
|
||||||
|
<h2 class="text-lg">{{ series.name }}</h2>
|
||||||
|
</nuxt-link>
|
||||||
|
<p class="text-white text-opacity-40 text-base px-2">Series</p>
|
||||||
|
</widgets-item-slider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
async asyncData({ store, app, params, redirect }) {
|
||||||
|
const author = await app.$axios.$get(`/api/authors/${params.id}?include=items,series`).catch((error) => {
|
||||||
|
console.error('Failed to get author', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!author) {
|
||||||
|
return redirect(`/library/${store.state.libraries.currentLibraryId}/authors`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
author
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
streamLibraryItem() {
|
||||||
|
return this.$store.state.streamLibraryItem
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
libraryItems() {
|
||||||
|
return this.author.libraryItems || []
|
||||||
|
},
|
||||||
|
authorSeries() {
|
||||||
|
return this.author.series || []
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
editAuthor() {
|
||||||
|
this.$store.commit('globals/showEditAuthorModal', this.author)
|
||||||
|
},
|
||||||
|
authorUpdated(author) {
|
||||||
|
if (author.id === this.author.id) {
|
||||||
|
console.log('Author was updated', author)
|
||||||
|
this.author = {
|
||||||
|
...author,
|
||||||
|
series: this.authorSeries,
|
||||||
|
libraryItems: this.libraryItems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
authorRemoved(author) {
|
||||||
|
if (author.id === this.author.id) {
|
||||||
|
console.warn('Author was removed')
|
||||||
|
this.$router.replace(`/library/${this.currentLibraryId}/authors`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (!this.author) this.$router.replace('/')
|
||||||
|
|
||||||
|
this.$root.socket.on('author_updated', this.authorUpdated)
|
||||||
|
this.$root.socket.on('author_removed', this.authorRemoved)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$root.socket.off('author_updated', this.authorUpdated)
|
||||||
|
this.$root.socket.off('author_removed', this.authorRemoved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="page-wrapper" class="page p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
<div id="page-wrapper" class="page p-2 md:p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<app-config-side-nav :is-open.sync="sideDrawerOpen" />
|
<app-config-side-nav :is-open.sync="sideDrawerOpen" />
|
||||||
<div class="configContent" :class="`page-${currentPage}`">
|
<div class="configContent" :class="`page-${currentPage}`">
|
||||||
<div v-show="isMobile" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2">
|
<div v-show="isMobile" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2">
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
|
<ui-toggle-switch v-model="useAlternativeBookshelfView" :disabled="updatingServerSettings" @input="updateAlternativeBookshelfView" />
|
||||||
<ui-tooltip :text="tooltips.bookshelfView">
|
<ui-tooltip :text="tooltips.bookshelfView">
|
||||||
<p class="pl-4 text-lg">
|
<p class="pl-4 text-lg">
|
||||||
Use alternative library bookshelf view
|
Use alternative bookshelf view
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<span class="material-icons icon-text">info_outlined</span>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
@@ -122,6 +122,20 @@
|
|||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center mb-2 mt-8">
|
||||||
|
<h1 class="text-xl">Experimental Feature Settings</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center py-2">
|
||||||
|
<ui-toggle-switch v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
|
||||||
|
<ui-tooltip :text="tooltips.enableEReader">
|
||||||
|
<p class="pl-4 text-lg">
|
||||||
|
Enable e-reader for all users
|
||||||
|
<span class="material-icons icon-text">info_outlined</span>
|
||||||
|
</p>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
<div class="h-0.5 bg-primary bg-opacity-30 w-full" />
|
||||||
@@ -169,10 +183,12 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
<ui-toggle-switch v-model="showExperimentalFeatures" />
|
||||||
<ui-tooltip :text="experimentalFeaturesTooltip">
|
<ui-tooltip :text="tooltips.experimentalFeatures">
|
||||||
<p class="pl-4 text-lg">
|
<p class="pl-4 text-lg">
|
||||||
Experimental Features
|
Experimental Features
|
||||||
<span class="material-icons icon-text">info_outlined</span>
|
<a href="https://github.com/advplyr/audiobookshelf/discussions/75" target="_blank">
|
||||||
|
<span class="material-icons icon-text">info_outlined</span>
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,16 +223,18 @@ export default {
|
|||||||
isPurgingCache: false,
|
isPurgingCache: false,
|
||||||
newServerSettings: {},
|
newServerSettings: {},
|
||||||
tooltips: {
|
tooltips: {
|
||||||
|
experimentalFeatures: 'Features in development that could use your feedback and help testing. Click to open github discussion.',
|
||||||
scannerDisableWatcher: 'Disables the automatic adding/updating of items when file changes are detected. *Requires server restart',
|
scannerDisableWatcher: 'Disables the automatic adding/updating of items when file changes are detected. *Requires server restart',
|
||||||
scannerPreferOpfMetadata: 'OPF file metadata will be used for book details over folder names',
|
scannerPreferOpfMetadata: 'OPF file metadata will be used for book details over folder names',
|
||||||
scannerPreferAudioMetadata: 'Audio file ID3 meta tags will be used for book details over folder names',
|
scannerPreferAudioMetadata: 'Audio file ID3 meta tags will be used for book details over folder names',
|
||||||
scannerParseSubtitle: 'Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"',
|
scannerParseSubtitle: 'Extract subtitles from audiobook folder names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"',
|
||||||
sortingIgnorePrefix: 'i.e. for prefix "the" book title "The Book Title" would sort as "Book Title, The"',
|
sortingIgnorePrefix: 'i.e. for prefix "the" book title "The Book Title" would sort as "Book Title, The"',
|
||||||
scannerFindCovers: 'If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time',
|
scannerFindCovers: 'If your audiobook does not have an embedded cover or a cover image inside the folder, the scanner will attempt to find a cover.<br>Note: This will extend scan time',
|
||||||
bookshelfView: 'Alternative bookshelf view that shows title & author under book covers',
|
bookshelfView: 'Alternative view without wooden bookshelf',
|
||||||
storeCoverWithItem: 'By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named "cover" will be kept',
|
storeCoverWithItem: 'By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named "cover" will be kept',
|
||||||
storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension',
|
storeMetadataWithItem: 'By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension',
|
||||||
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers'
|
coverAspectRatio: 'Prefer to use square covers over standard 1.6:1 book covers',
|
||||||
|
enableEReader: 'E-reader is still a work in progress, but use this setting to open it up to all your users (or use the "Experimental Features" toggle below just for you)'
|
||||||
},
|
},
|
||||||
showConfirmPurgeCache: false
|
showConfirmPurgeCache: false
|
||||||
}
|
}
|
||||||
@@ -229,9 +247,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
experimentalFeaturesTooltip() {
|
|
||||||
return 'Features in development that could use your feedback and help testing.'
|
|
||||||
},
|
|
||||||
serverSettings() {
|
serverSettings() {
|
||||||
return this.$store.state.serverSettings
|
return this.$store.state.serverSettings
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<tables-library-libraries-table />
|
<tables-library-libraries-table @showLibraryModal="setShowLibraryModal" />
|
||||||
|
|
||||||
|
<modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {
|
||||||
|
showLibraryModal: false,
|
||||||
|
selectedLibrary: null
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {},
|
computed: {},
|
||||||
methods: {},
|
methods: {
|
||||||
|
setShowLibraryModal(selectedLibrary) {
|
||||||
|
this.selectedLibrary = selectedLibrary
|
||||||
|
this.showLibraryModal = true
|
||||||
|
}
|
||||||
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -67,6 +67,12 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
|
asyncData({ redirect, store }) {
|
||||||
|
if (!store.state.libraries.currentLibraryId) {
|
||||||
|
return redirect('/config')
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
libraryStats: null
|
libraryStats: null
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<div class="flex p-2">
|
<div class="flex p-2">
|
||||||
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
|
||||||
<path
|
<path
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z"
|
d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z"
|
||||||
@@ -15,7 +15,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex p-2">
|
<div class="flex p-2">
|
||||||
<span class="material-icons-outlined" style="font-size: 4.1rem">event</span>
|
<div class="hidden sm:block">
|
||||||
|
<span class="hidden sm:block material-icons-outlined text-5xl lg:text-6xl">event</span>
|
||||||
|
</div>
|
||||||
<div class="px-1">
|
<div class="px-1">
|
||||||
<p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p>
|
<p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p>
|
||||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Days Listened</p>
|
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Days Listened</p>
|
||||||
@@ -23,15 +25,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex p-2">
|
<div class="flex p-2">
|
||||||
<span class="material-icons-outlined" style="font-size: 4.1rem">watch_later</span>
|
<div class="hidden sm:block">
|
||||||
|
<span class="material-icons-outlined text-5xl lg:text-6xl">watch_later</span>
|
||||||
|
</div>
|
||||||
<div class="px-1">
|
<div class="px-1">
|
||||||
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
|
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
|
||||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Minutes Listening</p>
|
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Minutes Listening</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col md:flex-row">
|
<div class="flex flex-col md:flex-row overflow-hidden max-w-full">
|
||||||
<stats-daily-listening-chart :listening-stats="listeningStats" />
|
<stats-daily-listening-chart :listening-stats="listeningStats" class="origin-top-left transform scale-75 lg:scale-100" />
|
||||||
<div class="w-80 my-6 mx-auto">
|
<div class="w-80 my-6 mx-auto">
|
||||||
<h1 class="text-2xl mb-4 font-book">Recent Listening Sessions</h1>
|
<h1 class="text-2xl mb-4 font-book">Recent Listening Sessions</h1>
|
||||||
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
|
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
|
||||||
@@ -52,6 +56,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -59,7 +65,8 @@
|
|||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
listeningStats: null
|
listeningStats: null,
|
||||||
|
windowWidth: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|||||||
@@ -104,9 +104,6 @@ export default {
|
|||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
username() {
|
username() {
|
||||||
return this.user.username
|
return this.user.username
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
asyncData({ redirect, store }) {
|
asyncData({ redirect, store }) {
|
||||||
|
if (!store.state.libraries.currentLibraryId) {
|
||||||
|
return redirect('/oops?message=No libraries')
|
||||||
|
}
|
||||||
redirect(`/library/${store.state.libraries.currentLibraryId}`)
|
redirect(`/library/${store.state.libraries.currentLibraryId}`)
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
|
<p v-if="isPodcast" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">by {{ podcastAuthor || 'Unknown' }}</p>
|
||||||
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
|
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
|
||||||
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||||
</p>
|
</p>
|
||||||
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
||||||
|
|
||||||
@@ -92,7 +92,8 @@
|
|||||||
<!-- Alerts -->
|
<!-- Alerts -->
|
||||||
<div v-show="showExperimentalReadAlert" class="bg-error p-4 rounded-xl flex items-center">
|
<div v-show="showExperimentalReadAlert" class="bg-error p-4 rounded-xl flex items-center">
|
||||||
<span class="material-icons text-2xl">warning_amber</span>
|
<span class="material-icons text-2xl">warning_amber</span>
|
||||||
<p class="ml-4">Book has no audio tracks but has valid ebook files. The e-reader is experimental and can be turned on in config.</p>
|
<p v-if="userIsAdminOrUp" class="ml-4">Book has no audio tracks but has an ebook. The experimental e-reader can be enabled in config.</p>
|
||||||
|
<p v-else class="ml-4">Book has no audio tracks but has an ebook. The experimental e-reader must be enabled by a server admin.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast episode downloads queue -->
|
<!-- Podcast episode downloads queue -->
|
||||||
@@ -135,7 +136,7 @@
|
|||||||
{{ isMissing ? 'Missing' : 'Incomplete' }}
|
{{ isMissing ? 'Missing' : 'Incomplete' }}
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
|
|
||||||
<ui-btn v-if="showExperimentalFeatures && ebookFile" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
|
<ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
|
||||||
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
|
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
|
||||||
Read
|
Read
|
||||||
</ui-btn>
|
</ui-btn>
|
||||||
@@ -177,6 +178,8 @@
|
|||||||
|
|
||||||
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" />
|
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" />
|
||||||
|
|
||||||
|
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
|
||||||
|
|
||||||
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" />
|
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,6 +224,12 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
showExperimentalFeatures() {
|
||||||
|
return this.$store.state.showExperimentalFeatures
|
||||||
|
},
|
||||||
|
enableEReader() {
|
||||||
|
return this.$store.getters['getServerSetting']('enableEReader')
|
||||||
|
},
|
||||||
userIsAdminOrUp() {
|
userIsAdminOrUp() {
|
||||||
return this.$store.getters['user/getIsAdminOrUp']
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
@@ -239,9 +248,6 @@ export default {
|
|||||||
isDeveloperMode() {
|
isDeveloperMode() {
|
||||||
return this.$store.state.developerMode
|
return this.$store.state.developerMode
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.$store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.libraryItem.mediaType === 'podcast'
|
return this.libraryItem.mediaType === 'podcast'
|
||||||
},
|
},
|
||||||
@@ -260,6 +266,9 @@ export default {
|
|||||||
if (this.isPodcast) return this.podcastEpisodes.length
|
if (this.isPodcast) return this.podcastEpisodes.length
|
||||||
return this.tracks.length
|
return this.tracks.length
|
||||||
},
|
},
|
||||||
|
showReadButton() {
|
||||||
|
return this.ebookFile && (this.showExperimentalFeatures || this.enableEReader)
|
||||||
|
},
|
||||||
libraryId() {
|
libraryId() {
|
||||||
return this.libraryItem.libraryId
|
return this.libraryItem.libraryId
|
||||||
},
|
},
|
||||||
@@ -275,6 +284,9 @@ export default {
|
|||||||
mediaMetadata() {
|
mediaMetadata() {
|
||||||
return this.media.metadata || {}
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
|
chapters() {
|
||||||
|
return this.media.chapters || []
|
||||||
|
},
|
||||||
tracks() {
|
tracks() {
|
||||||
return this.media.tracks || []
|
return this.media.tracks || []
|
||||||
},
|
},
|
||||||
@@ -337,7 +349,7 @@ export default {
|
|||||||
return this.media.ebookFile
|
return this.media.ebookFile
|
||||||
},
|
},
|
||||||
showExperimentalReadAlert() {
|
showExperimentalReadAlert() {
|
||||||
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures
|
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
|
||||||
},
|
},
|
||||||
description() {
|
description() {
|
||||||
return this.mediaMetadata.description || ''
|
return this.mediaMetadata.description || ''
|
||||||
@@ -378,9 +390,10 @@ export default {
|
|||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
showRssFeedBtn() {
|
showRssFeedBtn() {
|
||||||
if (!this.showExperimentalFeatures) return false
|
if (!this.rssFeedUrl && !this.podcastEpisodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks
|
||||||
|
|
||||||
// If rss feed is open then show feed url to users otherwise just show to admins
|
// If rss feed is open then show feed url to users otherwise just show to admins
|
||||||
return this.isPodcast && (this.userIsAdminOrUp || this.rssFeedUrl)
|
return this.userIsAdminOrUp || this.rssFeedUrl
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<div class="flex h-full">
|
<app-book-shelf-toolbar is-home />
|
||||||
<app-side-rail class="hidden md:block" />
|
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
|
||||||
<div class="flex-grow">
|
<div class="flex flex-wrap justify-center">
|
||||||
<app-book-shelf-toolbar is-home />
|
<template v-for="author in authors">
|
||||||
<div id="bookshelf" class="w-full h-full p-8 overflow-y-auto">
|
<cards-author-card :key="author.id" :author="author" :width="160" :height="200" class="p-3" @edit="editAuthor" />
|
||||||
<div class="flex flex-wrap justify-center">
|
</template>
|
||||||
<template v-for="author in authors">
|
|
||||||
<nuxt-link :key="author.id" :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`">
|
|
||||||
<cards-author-card :author="author" :width="160" :height="200" class="p-3" @edit="editAuthor" />
|
|
||||||
</nuxt-link>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<modals-authors-edit-modal v-model="showAuthorModal" :author="selectedAuthor" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -40,9 +32,7 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: true,
|
loading: true,
|
||||||
authors: [],
|
authors: []
|
||||||
showAuthorModal: false,
|
|
||||||
selectedAuthor: null
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -51,6 +41,9 @@ export default {
|
|||||||
},
|
},
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
selectedAuthor() {
|
||||||
|
return this.$store.state.globals.selectedAuthor
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -68,7 +61,7 @@ export default {
|
|||||||
},
|
},
|
||||||
authorUpdated(author) {
|
authorUpdated(author) {
|
||||||
if (this.selectedAuthor && this.selectedAuthor.id === author.id) {
|
if (this.selectedAuthor && this.selectedAuthor.id === author.id) {
|
||||||
this.selectedAuthor = author
|
this.$store.commit('globals/setSelectedAuthor', author)
|
||||||
}
|
}
|
||||||
this.authors = this.authors.map((au) => {
|
this.authors = this.authors.map((au) => {
|
||||||
if (au.id === author.id) {
|
if (au.id === author.id) {
|
||||||
@@ -81,8 +74,7 @@ export default {
|
|||||||
this.authors = this.authors.filter((au) => au.id !== author.id)
|
this.authors = this.authors.filter((au) => au.id !== author.id)
|
||||||
},
|
},
|
||||||
editAuthor(author) {
|
editAuthor(author) {
|
||||||
this.selectedAuthor = author
|
this.$store.commit('globals/showEditAuthorModal', author)
|
||||||
this.showAuthorModal = true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<div class="flex h-full">
|
<app-book-shelf-toolbar :page="id || ''" :view-mode.sync="viewMode" />
|
||||||
<app-side-rail class="hidden md:block" />
|
<app-lazy-bookshelf :page="id || ''" :view-mode="viewMode" />
|
||||||
<div class="flex-grow">
|
|
||||||
<app-book-shelf-toolbar :page="id || ''" :view-mode.sync="viewMode" />
|
|
||||||
<app-lazy-bookshelf :page="id || ''" :view-mode="viewMode" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<div class="flex h-full">
|
<app-book-shelf-toolbar is-home />
|
||||||
<app-side-rail class="hidden md:block" />
|
<app-book-shelf-categorized />
|
||||||
<div class="flex-grow">
|
|
||||||
<app-book-shelf-toolbar is-home />
|
|
||||||
<app-book-shelf-categorized />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<div class="flex h-full">
|
<app-book-shelf-toolbar page="podcast-search" />
|
||||||
<app-side-rail class="hidden md:block" />
|
<div class="w-full h-full overflow-y-auto p-12 relative">
|
||||||
<div class="flex-grow">
|
<div class="w-full max-w-3xl mx-auto">
|
||||||
<app-book-shelf-toolbar page="podcast-search" />
|
<form @submit.prevent="submit" class="flex">
|
||||||
<div class="w-full h-full overflow-y-auto p-12 relative">
|
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" />
|
||||||
<div class="w-full max-w-3xl mx-auto">
|
<ui-btn type="submit" :disabled="processing">Submit</ui-btn>
|
||||||
<form @submit.prevent="submit" class="flex">
|
</form>
|
||||||
<ui-text-input v-model="searchInput" :disabled="processing" placeholder="Enter search term or RSS feed URL" class="flex-grow mr-2" />
|
</div>
|
||||||
<ui-btn type="submit" :disabled="processing">Submit</ui-btn>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full max-w-3xl mx-auto py-4">
|
<div class="w-full max-w-3xl mx-auto py-4">
|
||||||
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p>
|
<p v-if="termSearched && !results.length && !processing" class="text-center text-xl">No podcasts found</p>
|
||||||
<template v-for="podcast in results">
|
<template v-for="podcast in results">
|
||||||
<div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)">
|
<div :key="podcast.id" class="flex p-1 hover:bg-primary hover:bg-opacity-25 cursor-pointer" @click="selectPodcast(podcast)">
|
||||||
<div class="w-24 min-w-24 h-24 bg-primary">
|
<div class="w-24 min-w-24 h-24 bg-primary">
|
||||||
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
|
<img v-if="podcast.cover" :src="podcast.cover" class="h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow pl-4 max-w-2xl">
|
<div class="flex-grow pl-4 max-w-2xl">
|
||||||
<a :href="podcast.pageUrl" class="text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
<a :href="podcast.pageUrl" class="text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
||||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
|
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
|
||||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
|
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
|
||||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
|
<p class="text-xs text-gray-400 leading-5">{{ podcast.trackCount }} Episodes</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-show="processing" class="absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black bg-opacity-25 z-40">
|
<div v-show="processing" class="absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black bg-opacity-25 z-40">
|
||||||
<ui-loading-indicator />
|
<ui-loading-indicator />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<div class="flex h-full">
|
<app-book-shelf-toolbar is-home page="search" :search-query="query" />
|
||||||
<app-side-rail class="hidden md:block" />
|
<app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
|
||||||
<div class="flex-grow">
|
<div v-else class="w-full py-16">
|
||||||
<app-book-shelf-toolbar is-home page="search" :search-query="query" />
|
<p class="text-xl text-center">No Search results for "{{ query }}"</p>
|
||||||
<app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
|
|
||||||
<div v-else class="w-full py-16">
|
|
||||||
<p class="text-xl text-center">No Search results for "{{ query }}"</p>
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<ui-btn class="w-52 my-4" @click="back">Back</ui-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -79,12 +71,6 @@ export default {
|
|||||||
this.$refs.bookshelf.setShelvesFromSearch()
|
this.$refs.bookshelf.setShelvesFromSearch()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
|
||||||
async back() {
|
|
||||||
var popped = await this.$store.dispatch('popRoute')
|
|
||||||
if (popped) this.$store.commit('setIsRoutingBack', true)
|
|
||||||
var backTo = popped || '/'
|
|
||||||
this.$router.push(backTo)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {},
|
mounted() {},
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
<div class="page" :class="streamLibraryItem ? 'streaming' : ''">
|
||||||
<div class="flex h-full">
|
<app-book-shelf-toolbar :selected-series="series" />
|
||||||
<app-side-rail class="hidden md:block" />
|
<app-lazy-bookshelf page="series-books" :series-id="seriesId" />
|
||||||
<div class="flex-grow">
|
|
||||||
<app-book-shelf-toolbar :selected-series="series" />
|
|
||||||
<app-lazy-bookshelf page="series-books" :series-id="seriesId" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
+128
-29
@@ -1,7 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-screen bg-bg">
|
<div class="w-full h-screen bg-bg">
|
||||||
<div class="w-full flex h-1/2 items-center justify-center">
|
<div class="w-full flex h-full items-center justify-center">
|
||||||
<div class="w-full max-w-md border border-opacity-0 rounded-xl px-8 pb-8 pt-4">
|
<div v-if="criticalError" class="w-full max-w-md rounded border border-error border-opacity-25 bg-error bg-opacity-10 p-4">
|
||||||
|
<p class="text-center text-lg font-semibold">Server could not be reached</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="showInitScreen" class="w-full max-w-lg px-4 md:px-8 pb-8 pt-4">
|
||||||
|
<p class="text-3xl text-white text-center mb-4">Initial Server Setup</p>
|
||||||
|
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||||
|
|
||||||
|
<form @submit.prevent="submitServerSetup">
|
||||||
|
<p class="text-lg font-semibold mb-2 pl-1 text-center">Create Root User</p>
|
||||||
|
<ui-text-input-with-label v-model="newRoot.username" label="Username" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||||
|
<ui-text-input-with-label v-model="newRoot.password" label="Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||||
|
<ui-text-input-with-label v-model="confirmPassword" label="Confirm Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" />
|
||||||
|
|
||||||
|
<p class="text-lg font-semibold mt-6 mb-2 pl-1 text-center">Directory Paths</p>
|
||||||
|
<ui-text-input-with-label v-model="ConfigPath" label="Config Path" disabled class="w-full mb-3 text-sm" />
|
||||||
|
<ui-text-input-with-label v-model="MetadataPath" label="Metadata Path" disabled class="w-full mb-3 text-sm" />
|
||||||
|
|
||||||
|
<div class="w-full flex justify-end py-3">
|
||||||
|
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Initializing...' : 'Submit' }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="isInit" class="w-full max-w-md px-8 pb-8 pt-4 -mt-40">
|
||||||
<p class="text-3xl text-white text-center mb-4">Login</p>
|
<p class="text-3xl text-white text-center mb-4">Login</p>
|
||||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||||
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
|
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
|
||||||
@@ -11,8 +33,8 @@
|
|||||||
|
|
||||||
<label class="text-xs text-gray-300 uppercase">Password</label>
|
<label class="text-xs text-gray-300 uppercase">Password</label>
|
||||||
<ui-text-input v-model="password" type="password" :disabled="processing" class="w-full mb-3" />
|
<ui-text-input v-model="password" type="password" :disabled="processing" class="w-full mb-3" />
|
||||||
<div class="w-full flex justify-end">
|
<div class="w-full flex justify-end py-3">
|
||||||
<button type="submit" :disabled="processing" class="bg-blue-600 hover:bg-blue-800 px-8 py-1 mt-3 rounded-md text-white text-center transition duration-300 ease-in-out focus:outline-none">{{ processing ? 'Checking...' : 'Submit' }}</button>
|
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : 'Submit' }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,15 +48,33 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
error: null,
|
error: null,
|
||||||
|
criticalError: null,
|
||||||
processing: false,
|
processing: false,
|
||||||
username: '',
|
username: '',
|
||||||
password: null
|
password: null,
|
||||||
|
showInitScreen: false,
|
||||||
|
isInit: false,
|
||||||
|
newRoot: {
|
||||||
|
username: 'root',
|
||||||
|
password: ''
|
||||||
|
},
|
||||||
|
confirmPassword: '',
|
||||||
|
ConfigPath: '',
|
||||||
|
MetadataPath: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
user(newVal) {
|
user(newVal) {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
if (this.$route.query.redirect) {
|
if (!this.$store.state.libraries.currentLibraryId) {
|
||||||
|
// No libraries available to this user
|
||||||
|
if (this.$store.getters['user/getIsRoot']) {
|
||||||
|
// If root user go to config/libraries
|
||||||
|
this.$router.replace('/config/libraries')
|
||||||
|
} else {
|
||||||
|
this.$router.replace('/oops?message=No libraries available')
|
||||||
|
}
|
||||||
|
} else if (this.$route.query.redirect) {
|
||||||
this.$router.replace(this.$route.query.redirect)
|
this.$router.replace(this.$route.query.redirect)
|
||||||
} else {
|
} else {
|
||||||
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
|
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
|
||||||
@@ -48,8 +88,45 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
setUser({ user, userDefaultLibraryId, serverSettings }) {
|
async submitServerSetup() {
|
||||||
|
if (!this.newRoot.username || !this.newRoot.username.trim()) {
|
||||||
|
this.$toast.error('Must enter a root username')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (this.newRoot.password !== this.confirmPassword) {
|
||||||
|
this.$toast.error('Password mismatch')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!this.newRoot.password) {
|
||||||
|
if (!confirm('Are you sure you want to create the root user with no password?')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.processing = true
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
newRoot: { ...this.newRoot }
|
||||||
|
}
|
||||||
|
var success = await this.$axios
|
||||||
|
.$post('/init', payload)
|
||||||
|
.then(() => true)
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error.response)
|
||||||
|
const errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||||
|
this.$toast.error(errorMsg)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
this.processing = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
location.reload()
|
||||||
|
},
|
||||||
|
setUser({ user, userDefaultLibraryId, serverSettings, Source }) {
|
||||||
this.$store.commit('setServerSettings', serverSettings)
|
this.$store.commit('setServerSettings', serverSettings)
|
||||||
|
this.$store.commit('setSource', Source)
|
||||||
|
|
||||||
if (serverSettings.chromecastEnabled) {
|
if (serverSettings.chromecastEnabled) {
|
||||||
console.log('Chromecast enabled import script')
|
console.log('Chromecast enabled import script')
|
||||||
@@ -81,32 +158,54 @@ export default {
|
|||||||
this.processing = false
|
this.processing = false
|
||||||
},
|
},
|
||||||
checkAuth() {
|
checkAuth() {
|
||||||
if (localStorage.getItem('token')) {
|
var token = localStorage.getItem('token')
|
||||||
var token = localStorage.getItem('token')
|
if (!token) return false
|
||||||
|
|
||||||
if (token) {
|
this.processing = true
|
||||||
this.processing = true
|
|
||||||
|
|
||||||
this.$axios
|
return this.$axios
|
||||||
.$post('/api/authorize', null, {
|
.$post('/api/authorize', null, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
this.setUser(res)
|
this.setUser(res)
|
||||||
this.processing = false
|
this.processing = false
|
||||||
})
|
return true
|
||||||
.catch((error) => {
|
})
|
||||||
console.error('Authorize error', error)
|
.catch((error) => {
|
||||||
this.processing = false
|
console.error('Authorize error', error)
|
||||||
})
|
this.processing = false
|
||||||
}
|
return false
|
||||||
}
|
})
|
||||||
|
},
|
||||||
|
checkStatus() {
|
||||||
|
this.processing = true
|
||||||
|
this.$axios
|
||||||
|
.$get('/status')
|
||||||
|
.then((res) => {
|
||||||
|
this.processing = false
|
||||||
|
this.isInit = res.isInit
|
||||||
|
this.showInitScreen = !res.isInit
|
||||||
|
if (this.showInitScreen) {
|
||||||
|
this.ConfigPath = res.ConfigPath || ''
|
||||||
|
this.MetadataPath = res.MetadataPath || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Status check failed', error)
|
||||||
|
this.processing = false
|
||||||
|
this.criticalError = 'Status check failed'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
async mounted() {
|
||||||
this.checkAuth()
|
if (localStorage.getItem('token')) {
|
||||||
|
var userfound = await this.checkAuth()
|
||||||
|
if (userfound) return // if valid user no need to check status
|
||||||
|
}
|
||||||
|
this.checkStatus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -86,6 +86,13 @@ export default {
|
|||||||
uploadFinished: false
|
uploadFinished: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
selectedLibrary(newVal) {
|
||||||
|
if (newVal && !this.selectedFolderId) {
|
||||||
|
this.setDefaultFolder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
inputAccept() {
|
inputAccept() {
|
||||||
var extensions = []
|
var extensions = []
|
||||||
|
|||||||
@@ -179,6 +179,9 @@ export default class PlayerHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
this.player.set(this.libraryItem, audioTracks, this.isHlsTranscode, this.startTime, this.playWhenReady)
|
||||||
|
|
||||||
|
// browser media session api
|
||||||
|
this.ctx.setMediaSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
closePlayer() {
|
closePlayer() {
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ const BookCoverAspectRatio = {
|
|||||||
|
|
||||||
const BookshelfView = {
|
const BookshelfView = {
|
||||||
STANDARD: 0,
|
STANDARD: 0,
|
||||||
TITLES: 1
|
TITLES: 1,
|
||||||
|
AUTHOR: 2 // Books shown on author page
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlayMethod = {
|
const PlayMethod = {
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ Vue.prototype.$addDaysToToday = (daysToAdd) => {
|
|||||||
if (!date || !isDate(date)) return null
|
if (!date || !isDate(date)) return null
|
||||||
return date
|
return date
|
||||||
}
|
}
|
||||||
|
Vue.prototype.$addDaysToDate = (jsdate, daysToAdd) => {
|
||||||
|
var date = addDays(jsdate, daysToAdd)
|
||||||
|
if (!date || !isDate(date)) return null
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
||||||
if (isNaN(bytes) || bytes == 0) {
|
if (isNaN(bytes) || bytes == 0) {
|
||||||
@@ -35,17 +40,20 @@ Vue.prototype.$bytesPretty = (bytes, decimals = 2) => {
|
|||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
Vue.prototype.$elapsedPretty = (seconds) => {
|
Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${Math.floor(seconds)} sec${useFullNames ? 'onds' : ''}`
|
||||||
|
}
|
||||||
var minutes = Math.floor(seconds / 60)
|
var minutes = Math.floor(seconds / 60)
|
||||||
if (minutes < 70) {
|
if (minutes < 70) {
|
||||||
return `${minutes} min`
|
return `${minutes} min${useFullNames ? `ute${minutes === 1 ? '' : 's'}` : ''}`
|
||||||
}
|
}
|
||||||
var hours = Math.floor(minutes / 60)
|
var hours = Math.floor(minutes / 60)
|
||||||
minutes -= hours * 60
|
minutes -= hours * 60
|
||||||
if (!minutes) {
|
if (!minutes) {
|
||||||
return `${hours} hr`
|
return `${hours} ${useFullNames ? 'hours' : 'hr'}`
|
||||||
}
|
}
|
||||||
return `${hours} hr ${minutes} min`
|
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
|
||||||
}
|
}
|
||||||
|
|
||||||
Vue.prototype.$secondsToTimestamp = (seconds) => {
|
Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||||
@@ -106,10 +114,11 @@ Vue.prototype.$calculateTextSize = (text, styles = {}) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
|
Vue.prototype.$sanitizeFilename = (input, colonReplacement = ' - ') => {
|
||||||
if (typeof input !== 'string') {
|
if (typeof input !== 'string') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
var replacement = ''
|
||||||
var illegalRe = /[\/\?<>\\:\*\|"]/g;
|
var illegalRe = /[\/\?<>\\:\*\|"]/g;
|
||||||
var controlRe = /[\x00-\x1f\x80-\x9f]/g;
|
var controlRe = /[\x00-\x1f\x80-\x9f]/g;
|
||||||
var reservedRe = /^\.+$/;
|
var reservedRe = /^\.+$/;
|
||||||
@@ -117,6 +126,7 @@ Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
|
|||||||
var windowsTrailingRe = /[\. ]+$/;
|
var windowsTrailingRe = /[\. ]+$/;
|
||||||
|
|
||||||
var sanitized = input
|
var sanitized = input
|
||||||
|
.replace(':', colonReplacement) // Replace first occurrence of a colon
|
||||||
.replace(illegalRe, replacement)
|
.replace(illegalRe, replacement)
|
||||||
.replace(controlRe, replacement)
|
.replace(controlRe, replacement)
|
||||||
.replace(reservedRe, replacement)
|
.replace(reservedRe, replacement)
|
||||||
@@ -153,17 +163,26 @@ Vue.prototype.$sanitizeSlug = (str) => {
|
|||||||
Vue.prototype.$copyToClipboard = (str, ctx) => {
|
Vue.prototype.$copyToClipboard = (str, ctx) => {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (!navigator.clipboard) {
|
if (!navigator.clipboard) {
|
||||||
console.warn('Clipboard not supported')
|
navigator.clipboard.writeText(str).then(() => {
|
||||||
return resolve(false)
|
if (ctx) ctx.$toast.success('Copied to clipboard')
|
||||||
|
resolve(true)
|
||||||
|
}, (err) => {
|
||||||
|
console.error('Clipboard copy failed', str, err)
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const el = document.createElement('textarea')
|
||||||
|
el.value = str
|
||||||
|
el.setAttribute('readonly', '')
|
||||||
|
el.style.position = 'absolute'
|
||||||
|
el.style.left = '-9999px'
|
||||||
|
document.body.appendChild(el)
|
||||||
|
el.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(el)
|
||||||
|
|
||||||
|
if (ctx) ctx.$toast.success('Copied to clipboard')
|
||||||
}
|
}
|
||||||
navigator.clipboard.writeText(str).then(() => {
|
|
||||||
console.log('Clipboard copy success', str)
|
|
||||||
ctx.$toast.success('Copied to clipboard')
|
|
||||||
resolve(true)
|
|
||||||
}, (err) => {
|
|
||||||
console.error('Clipboard copy failed', str, err)
|
|
||||||
resolve(false)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ export const state = () => ({
|
|||||||
showUserCollectionsModal: false,
|
showUserCollectionsModal: false,
|
||||||
showEditCollectionModal: false,
|
showEditCollectionModal: false,
|
||||||
showEditPodcastEpisode: false,
|
showEditPodcastEpisode: false,
|
||||||
|
showEditAuthorModal: false,
|
||||||
selectedEpisode: null,
|
selectedEpisode: null,
|
||||||
selectedCollection: null,
|
selectedCollection: null,
|
||||||
|
selectedAuthor: null,
|
||||||
showBookshelfTextureModal: false,
|
showBookshelfTextureModal: false,
|
||||||
isCasting: false, // Actively casting
|
isCasting: false, // Actively casting
|
||||||
isChromecastInitialized: false // Script loaded
|
isChromecastInitialized: false // Script loaded
|
||||||
@@ -61,6 +63,16 @@ export const mutations = {
|
|||||||
setShowBookshelfTextureModal(state, val) {
|
setShowBookshelfTextureModal(state, val) {
|
||||||
state.showBookshelfTextureModal = val
|
state.showBookshelfTextureModal = val
|
||||||
},
|
},
|
||||||
|
showEditAuthorModal(state, author) {
|
||||||
|
state.selectedAuthor = author
|
||||||
|
state.showEditAuthorModal = true
|
||||||
|
},
|
||||||
|
setShowEditAuthorModal(state, val) {
|
||||||
|
state.showEditAuthorModal = val
|
||||||
|
},
|
||||||
|
setSelectedAuthor(state, author) {
|
||||||
|
state.selectedAuthor = author
|
||||||
|
},
|
||||||
setChromecastInitialized(state, val) {
|
setChromecastInitialized(state, val) {
|
||||||
state.isChromecastInitialized = val
|
state.isChromecastInitialized = val
|
||||||
},
|
},
|
||||||
|
|||||||
+10
-19
@@ -2,6 +2,7 @@ import { checkForUpdate } from '@/plugins/version'
|
|||||||
import Vue from 'vue'
|
import Vue from 'vue'
|
||||||
|
|
||||||
export const state = () => ({
|
export const state = () => ({
|
||||||
|
Source: null,
|
||||||
versionData: null,
|
versionData: null,
|
||||||
serverSettings: null,
|
serverSettings: null,
|
||||||
streamLibraryItem: null,
|
streamLibraryItem: null,
|
||||||
@@ -15,13 +16,12 @@ export const state = () => ({
|
|||||||
selectedLibraryItems: [],
|
selectedLibraryItems: [],
|
||||||
processingBatch: false,
|
processingBatch: false,
|
||||||
previousPath: '/',
|
previousPath: '/',
|
||||||
routeHistory: [],
|
|
||||||
isRoutingBack: false,
|
|
||||||
showExperimentalFeatures: false,
|
showExperimentalFeatures: false,
|
||||||
backups: [],
|
backups: [],
|
||||||
bookshelfBookIds: [],
|
bookshelfBookIds: [],
|
||||||
openModal: null,
|
openModal: null,
|
||||||
selectedBookshelfTexture: '/textures/wood_default.jpg'
|
selectedBookshelfTexture: '/textures/wood_default.jpg',
|
||||||
|
lastBookshelfScrollData: {}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const getters = {
|
export const getters = {
|
||||||
@@ -33,7 +33,7 @@ export const getters = {
|
|||||||
return state.serverSettings[key]
|
return state.serverSettings[key]
|
||||||
},
|
},
|
||||||
getBookCoverAspectRatio: state => {
|
getBookCoverAspectRatio: state => {
|
||||||
if (!state.serverSettings || !state.serverSettings.coverAspectRatio) return 1
|
if (!state.serverSettings || isNaN(state.serverSettings.coverAspectRatio)) return 1
|
||||||
return state.serverSettings.coverAspectRatio === 0 ? 1.6 : 1
|
return state.serverSettings.coverAspectRatio === 0 ? 1.6 : 1
|
||||||
},
|
},
|
||||||
getNumLibraryItemsSelected: state => state.selectedLibraryItems.length,
|
getNumLibraryItemsSelected: state => state.selectedLibraryItems.length,
|
||||||
@@ -74,15 +74,6 @@ export const actions = {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
popRoute({ commit, state }) {
|
|
||||||
if (!state.routeHistory.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
var _history = [...state.routeHistory]
|
|
||||||
var last = _history.pop()
|
|
||||||
commit('setRouteHistory', _history)
|
|
||||||
return last
|
|
||||||
},
|
|
||||||
setBookshelfTexture({ commit, state }, img) {
|
setBookshelfTexture({ commit, state }, img) {
|
||||||
let root = document.documentElement;
|
let root = document.documentElement;
|
||||||
commit('setBookshelfTexture', img)
|
commit('setBookshelfTexture', img)
|
||||||
@@ -91,15 +82,15 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const mutations = {
|
export const mutations = {
|
||||||
|
setSource(state, source) {
|
||||||
|
state.Source = source
|
||||||
|
},
|
||||||
|
setLastBookshelfScrollData(state, { scrollTop, path, name }) {
|
||||||
|
state.lastBookshelfScrollData[name] = { scrollTop, path }
|
||||||
|
},
|
||||||
setBookshelfBookIds(state, val) {
|
setBookshelfBookIds(state, val) {
|
||||||
state.bookshelfBookIds = val || []
|
state.bookshelfBookIds = val || []
|
||||||
},
|
},
|
||||||
setRouteHistory(state, val) {
|
|
||||||
state.routeHistory = val
|
|
||||||
},
|
|
||||||
setIsRoutingBack(state, val) {
|
|
||||||
state.isRoutingBack = val
|
|
||||||
},
|
|
||||||
setPreviousPath(state, val) {
|
setPreviousPath(state, val) {
|
||||||
state.previousPath = val
|
state.previousPath = val
|
||||||
},
|
},
|
||||||
|
|||||||
+61
-32
@@ -2,7 +2,7 @@ export const state = () => ({
|
|||||||
libraries: [],
|
libraries: [],
|
||||||
lastLoad: 0,
|
lastLoad: 0,
|
||||||
listeners: [],
|
listeners: [],
|
||||||
currentLibraryId: 'main',
|
currentLibraryId: null,
|
||||||
folders: [],
|
folders: [],
|
||||||
issues: 0,
|
issues: 0,
|
||||||
folderLastUpdate: 0,
|
folderLastUpdate: 0,
|
||||||
@@ -47,12 +47,7 @@ export const getters = {
|
|||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
requestLibraryScan({ state, commit }, { libraryId, force }) {
|
requestLibraryScan({ state, commit }, { libraryId, force }) {
|
||||||
this.$axios.$get(`/api/libraries/${libraryId}/scan`, { params: { force } }).then(() => {
|
return this.$axios.$get(`/api/libraries/${libraryId}/scan`, { params: { force } })
|
||||||
this.$toast.success('Library scan started')
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error('Failed to start scan', error)
|
|
||||||
this.$toast.error('Failed to start scan')
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
loadFolders({ state, commit }) {
|
loadFolders({ state, commit }) {
|
||||||
if (state.folders.length) {
|
if (state.folders.length) {
|
||||||
@@ -206,11 +201,11 @@ export const mutations = {
|
|||||||
setLibraryFilterData(state, filterData) {
|
setLibraryFilterData(state, filterData) {
|
||||||
state.filterData = filterData
|
state.filterData = filterData
|
||||||
},
|
},
|
||||||
updateFilterDataWithAudiobook(state, audiobook) {
|
updateFilterDataWithItem(state, libraryItem) {
|
||||||
if (!audiobook || !audiobook.book || !state.filterData) return
|
if (!libraryItem || !state.filterData) return
|
||||||
if (state.currentLibraryId !== audiobook.libraryId) return
|
if (state.currentLibraryId !== libraryItem.libraryId) return
|
||||||
/*
|
/*
|
||||||
var filterdata = {
|
var data = {
|
||||||
authors: [],
|
authors: [],
|
||||||
genres: [],
|
genres: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -219,36 +214,70 @@ export const mutations = {
|
|||||||
languages: []
|
languages: []
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
var mediaMetadata = libraryItem.media.metadata
|
||||||
|
|
||||||
if (audiobook.book.authorFL) {
|
// Add/update book authors
|
||||||
audiobook.book.authorFL.split(', ').forEach((author) => {
|
if (mediaMetadata.authors && mediaMetadata.authors.length) {
|
||||||
if (author && !state.filterData.authors.includes(author)) {
|
mediaMetadata.authors.forEach((author) => {
|
||||||
|
var indexOf = state.filterData.authors.findIndex(au => au.id === author.id)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
state.filterData.authors.splice(indexOf, 1, author)
|
||||||
|
} else {
|
||||||
state.filterData.authors.push(author)
|
state.filterData.authors.push(author)
|
||||||
|
state.filterData.authors.sort((a, b) => (a.name || '').localeCompare((b.name || '')))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (audiobook.book.narratorFL) {
|
|
||||||
audiobook.book.narratorFL.split(', ').forEach((narrator) => {
|
// Add/update series
|
||||||
if (narrator && !state.filterData.narrators.includes(narrator)) {
|
if (mediaMetadata.series && mediaMetadata.series.length) {
|
||||||
|
mediaMetadata.series.forEach((series) => {
|
||||||
|
var indexOf = state.filterData.series.findIndex(se => se.id === series.id)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
state.filterData.series.splice(indexOf, 1, { id: series.id, name: series.name })
|
||||||
|
} else {
|
||||||
|
state.filterData.series.push({ id: series.id, name: series.name })
|
||||||
|
state.filterData.series.sort((a, b) => (a.name || '').localeCompare((b.name || '')))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add genres
|
||||||
|
if (mediaMetadata.genres && mediaMetadata.genres.length) {
|
||||||
|
mediaMetadata.genres.forEach((genre) => {
|
||||||
|
if (!state.filterData.genres.includes(genre)) {
|
||||||
|
state.filterData.genres.push(genre)
|
||||||
|
state.filterData.genres.sort((a, b) => a.localeCompare(b))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tags
|
||||||
|
if (libraryItem.media.tags && libraryItem.media.tags.length) {
|
||||||
|
libraryItem.media.tags.forEach((tag) => {
|
||||||
|
if (!state.filterData.tags.includes(tag)) {
|
||||||
|
state.filterData.tags.push(tag)
|
||||||
|
state.filterData.tags.sort((a, b) => a.localeCompare(b))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add narrators
|
||||||
|
if (mediaMetadata.narrators && mediaMetadata.narrators.length) {
|
||||||
|
mediaMetadata.narrators.forEach((narrator) => {
|
||||||
|
if (!state.filterData.narrators.includes(narrator)) {
|
||||||
state.filterData.narrators.push(narrator)
|
state.filterData.narrators.push(narrator)
|
||||||
|
state.filterData.narrators.sort((a, b) => a.localeCompare(b))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (audiobook.book.series && !state.filterData.series.includes(audiobook.book.series)) {
|
|
||||||
state.filterData.series.push(audiobook.book.series)
|
// Add language
|
||||||
}
|
if (mediaMetadata.language) {
|
||||||
if (audiobook.tags && audiobook.tags.length) {
|
if (!state.filterData.languages.includes(mediaMetadata.language)) {
|
||||||
audiobook.tags.forEach((tag) => {
|
state.filterData.languages.push(mediaMetadata.language)
|
||||||
if (tag && !state.filterData.tags.includes(tag)) state.filterData.tags.push(tag)
|
state.filterData.languages.sort((a, b) => a.localeCompare(b))
|
||||||
})
|
}
|
||||||
}
|
|
||||||
if (audiobook.book.genres && audiobook.book.genres.length) {
|
|
||||||
audiobook.book.genres.forEach((genre) => {
|
|
||||||
if (genre && !state.filterData.genres.includes(genre)) state.filterData.genres.push(genre)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (audiobook.book.language && !state.filterData.languages.includes(audiobook.book.language)) {
|
|
||||||
state.filterData.languages.push(audiobook.book.language)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
if(process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
|
if (process.env.TOKEN_SECRET == null) process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
|
||||||
const server = require('./server/Server')
|
const server = require('./server/Server')
|
||||||
global.appRoot = __dirname
|
global.appRoot = __dirname
|
||||||
|
|
||||||
@@ -9,20 +9,20 @@ if (isDev) {
|
|||||||
process.env.PORT = devEnv.Port
|
process.env.PORT = devEnv.Port
|
||||||
process.env.CONFIG_PATH = devEnv.ConfigPath
|
process.env.CONFIG_PATH = devEnv.ConfigPath
|
||||||
process.env.METADATA_PATH = devEnv.MetadataPath
|
process.env.METADATA_PATH = devEnv.MetadataPath
|
||||||
process.env.AUDIOBOOK_PATH = devEnv.AudiobookPath
|
|
||||||
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'
|
||||||
}
|
}
|
||||||
|
|
||||||
const PORT = process.env.PORT || 80
|
const PORT = process.env.PORT || 80
|
||||||
const HOST = process.env.HOST || '0.0.0.0'
|
const HOST = process.env.HOST || '0.0.0.0'
|
||||||
const CONFIG_PATH = process.env.CONFIG_PATH || '/config'
|
const CONFIG_PATH = process.env.CONFIG_PATH || '/config'
|
||||||
const AUDIOBOOK_PATH = process.env.AUDIOBOOK_PATH || '/audiobooks'
|
|
||||||
const METADATA_PATH = process.env.METADATA_PATH || '/metadata'
|
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'
|
||||||
|
|
||||||
console.log('Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
console.log('Config', CONFIG_PATH, METADATA_PATH)
|
||||||
|
|
||||||
const Server = new server(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
const Server = new server(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH)
|
||||||
Server.start()
|
Server.start()
|
||||||
|
|||||||
Generated
+366
-229
File diff suppressed because it is too large
Load Diff
+6
-7
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.0.11",
|
"version": "2.0.16",
|
||||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
"description": "Self-hosted audiobook and podcast server",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node index.js",
|
"dev": "node index.js",
|
||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
"client": "cd client && npm install && npm run generate",
|
"client": "cd client && npm ci && npm run generate",
|
||||||
"prod": "npm run client && npm install && node prod.js",
|
"prod": "npm run client && npm ci && node prod.js",
|
||||||
"build-win": "pkg -t node16-win-x64 -o ./dist/win/audiobookshelf .",
|
"build-win": "npm run client && pkg -t node16-win-x64 -o ./dist/win/audiobookshelf -C GZip .",
|
||||||
"build-linux": "build/linuxpackager",
|
"build-linux": "build/linuxpackager",
|
||||||
"docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push . -t advplyr/audiobookshelf",
|
"docker": "docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push . -t advplyr/audiobookshelf",
|
||||||
"deploy": "node dist/autodeploy"
|
"deploy": "node dist/autodeploy"
|
||||||
@@ -52,6 +52,5 @@
|
|||||||
"string-strip-html": "^8.3.0",
|
"string-strip-html": "^8.3.0",
|
||||||
"watcher": "^1.2.0",
|
"watcher": "^1.2.0",
|
||||||
"xml2js": "^0.4.23"
|
"xml2js": "^0.4.23"
|
||||||
},
|
}
|
||||||
"devDependencies": {}
|
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
const optionDefinitions = [
|
const optionDefinitions = [
|
||||||
{ name: 'config', alias: 'c', type: String },
|
{ name: 'config', alias: 'c', type: String },
|
||||||
{ name: 'audiobooks', alias: 'a', type: String },
|
|
||||||
{ name: 'metadata', alias: 'm', type: String },
|
{ name: 'metadata', alias: 'm', type: String },
|
||||||
{ name: 'port', alias: 'p', type: String },
|
{ name: 'port', alias: 'p', type: String },
|
||||||
{ name: 'host', alias: 'h', type: String }
|
{ name: 'host', alias: 'h', type: String },
|
||||||
|
{ name: 'source', alias: 's', type: String }
|
||||||
]
|
]
|
||||||
|
|
||||||
const commandLineArgs = require('command-line-args')
|
const commandLineArgs = require('command-line-args')
|
||||||
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'
|
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')
|
||||||
@@ -18,18 +18,17 @@ const server = require('./server/Server')
|
|||||||
global.appRoot = __dirname
|
global.appRoot = __dirname
|
||||||
|
|
||||||
var inputConfig = options.config ? Path.resolve(options.config) : null
|
var inputConfig = options.config ? Path.resolve(options.config) : null
|
||||||
var inputAudiobook = options.audiobooks ? Path.resolve(options.audiobooks) : null
|
|
||||||
var inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
|
var inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
|
||||||
|
|
||||||
const PORT = options.port || process.env.PORT || 3333
|
const PORT = options.port || process.env.PORT || 3333
|
||||||
const HOST = options.host || process.env.HOST || "0.0.0.0"
|
const HOST = options.host || process.env.HOST || "0.0.0.0"
|
||||||
const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')
|
const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')
|
||||||
const AUDIOBOOK_PATH = inputAudiobook || process.env.AUDIOBOOK_PATH || Path.resolve('audiobooks')
|
|
||||||
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
|
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
|
||||||
const UID = 99
|
const UID = 99
|
||||||
const GID = 100
|
const GID = 100
|
||||||
|
const SOURCE = options.source || 'debian'
|
||||||
|
|
||||||
console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH)
|
||||||
|
|
||||||
const Server = new server(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
const Server = new server(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH)
|
||||||
Server.start()
|
Server.start()
|
||||||
|
|||||||
@@ -58,13 +58,11 @@ Available using Test Flight: https://testflight.apple.com/join/wiic7QIW - [Join
|
|||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
** Default username is "root" with no password
|
|
||||||
|
|
||||||
### Docker Install
|
### Docker Install
|
||||||
Available in Unraid Community Apps
|
Available in Unraid Community Apps
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull advplyr/audiobookshelf
|
docker pull ghcr.io/advplyr/audiobookshelf:latest
|
||||||
|
|
||||||
docker run -d \
|
docker run -d \
|
||||||
-e AUDIOBOOKSHELF_UID=99 \
|
-e AUDIOBOOKSHELF_UID=99 \
|
||||||
@@ -75,14 +73,14 @@ docker run -d \
|
|||||||
-v </path/to/config>:/config \
|
-v </path/to/config>:/config \
|
||||||
-v </path/to/metadata>:/metadata \
|
-v </path/to/metadata>:/metadata \
|
||||||
--name audiobookshelf \
|
--name audiobookshelf \
|
||||||
ghcr.io/advplyr/audiobookshelf
|
ghcr.io/advplyr/audiobookshelf:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker Update
|
### Docker Update
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker stop audiobookshelf
|
docker stop audiobookshelf
|
||||||
docker pull ghcr.io/advplyr/audiobookshelf
|
docker pull ghcr.io/advplyr/audiobookshelf:latest
|
||||||
docker start audiobookshelf
|
docker start audiobookshelf
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -92,7 +90,7 @@ docker start audiobookshelf
|
|||||||
### docker-compose.yml ###
|
### docker-compose.yml ###
|
||||||
services:
|
services:
|
||||||
audiobookshelf:
|
audiobookshelf:
|
||||||
image: ghcr.io/advplyr/audiobookshelf
|
image: ghcr.io/advplyr/audiobookshelf:latest
|
||||||
environment:
|
environment:
|
||||||
- AUDIOBOOKSHELF_UID=99
|
- AUDIOBOOKSHELF_UID=99
|
||||||
- AUDIOBOOKSHELF_GID=100
|
- AUDIOBOOKSHELF_GID=100
|
||||||
|
|||||||
+2
-9
@@ -17,14 +17,6 @@ class Auth {
|
|||||||
return this.db.users
|
return this.db.users
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
|
||||||
var root = this.users.find(u => u.type === 'root')
|
|
||||||
if (!root) {
|
|
||||||
Logger.fatal('No Root User', this.users)
|
|
||||||
throw new Error('No Root User')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cors(req, res, next) {
|
cors(req, res, next) {
|
||||||
res.header('Access-Control-Allow-Origin', '*')
|
res.header('Access-Control-Allow-Origin', '*')
|
||||||
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
|
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
|
||||||
@@ -104,7 +96,8 @@ class Auth {
|
|||||||
return {
|
return {
|
||||||
user: user.toJSONForBrowser(),
|
user: user.toJSONForBrowser(),
|
||||||
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
|
userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries),
|
||||||
serverSettings: this.db.serverSettings.toJSON()
|
serverSettings: this.db.serverSettings.toJSON(),
|
||||||
|
Source: global.Source
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-39
@@ -46,6 +46,10 @@ class Db {
|
|||||||
this.previousVersion = null
|
this.previousVersion = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get hasRootUser() {
|
||||||
|
return this.users.some(u => u.id === 'root')
|
||||||
|
}
|
||||||
|
|
||||||
getEntityDb(entityName) {
|
getEntityDb(entityName) {
|
||||||
if (entityName === 'user') return this.usersDb
|
if (entityName === 'user') return this.usersDb
|
||||||
else if (entityName === 'session') return this.sessionsDb
|
else if (entityName === 'session') return this.sessionsDb
|
||||||
@@ -70,33 +74,6 @@ class Db {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
getDefaultUser(token) {
|
|
||||||
return new User({
|
|
||||||
id: 'root',
|
|
||||||
type: 'root',
|
|
||||||
username: 'root',
|
|
||||||
pash: '',
|
|
||||||
stream: null,
|
|
||||||
token,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: Date.now()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
getDefaultLibrary() {
|
|
||||||
var defaultLibrary = new Library()
|
|
||||||
defaultLibrary.setData({
|
|
||||||
id: 'main',
|
|
||||||
name: 'Main',
|
|
||||||
folder: { // Generates default folder
|
|
||||||
id: 'audiobooks',
|
|
||||||
fullPath: global.AudiobookPath,
|
|
||||||
libraryId: 'main'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return defaultLibrary
|
|
||||||
}
|
|
||||||
|
|
||||||
reinit() {
|
reinit() {
|
||||||
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath)
|
this.libraryItemsDb = new njodb.Database(this.LibraryItemsPath)
|
||||||
this.usersDb = new njodb.Database(this.UsersPath)
|
this.usersDb = new njodb.Database(this.UsersPath)
|
||||||
@@ -123,23 +100,36 @@ class Db {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createRootUser(username, pash, token) {
|
||||||
|
const newRoot = new User({
|
||||||
|
id: 'root',
|
||||||
|
type: 'root',
|
||||||
|
username,
|
||||||
|
pash,
|
||||||
|
token,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: Date.now()
|
||||||
|
})
|
||||||
|
return this.insertEntity('user', newRoot)
|
||||||
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
await this.load()
|
await this.load()
|
||||||
|
|
||||||
// Insert Defaults
|
// Insert Defaults
|
||||||
var rootUser = this.users.find(u => u.type === 'root')
|
// var rootUser = this.users.find(u => u.type === 'root')
|
||||||
if (!rootUser) {
|
// if (!rootUser) {
|
||||||
var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
|
// var token = await jwt.sign({ userId: 'root' }, process.env.TOKEN_SECRET)
|
||||||
Logger.debug('Generated default token', token)
|
// Logger.debug('Generated default token', token)
|
||||||
Logger.info('[Db] Root user created')
|
// Logger.info('[Db] Root user created')
|
||||||
await this.insertEntity('user', this.getDefaultUser(token))
|
// await this.insertEntity('user', this.getDefaultUser(token))
|
||||||
} else {
|
// } else {
|
||||||
Logger.info(`[Db] Root user exists, pw: ${rootUser.hasPw}`)
|
// Logger.info(`[Db] Root user exists, pw: ${rootUser.hasPw}`)
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (!this.libraries.length) {
|
// if (!this.libraries.length) {
|
||||||
await this.insertEntity('library', this.getDefaultLibrary())
|
// await this.insertEntity('library', this.getDefaultLibrary())
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (!this.serverSettings) {
|
if (!this.serverSettings) {
|
||||||
this.serverSettings = new ServerSettings()
|
this.serverSettings = new ServerSettings()
|
||||||
|
|||||||
+48
-19
@@ -34,18 +34,18 @@ const AudioMetadataMangaer = require('./managers/AudioMetadataManager')
|
|||||||
const RssFeedManager = require('./managers/RssFeedManager')
|
const RssFeedManager = require('./managers/RssFeedManager')
|
||||||
|
|
||||||
class Server {
|
class Server {
|
||||||
constructor(PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH) {
|
||||||
this.Port = PORT
|
this.Port = PORT
|
||||||
this.Host = HOST
|
this.Host = HOST
|
||||||
|
global.Source = SOURCE
|
||||||
global.Uid = isNaN(UID) ? 0 : Number(UID)
|
global.Uid = isNaN(UID) ? 0 : Number(UID)
|
||||||
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.AudiobookPath = Path.normalize(AUDIOBOOK_PATH)
|
|
||||||
global.MetadataPath = Path.normalize(METADATA_PATH)
|
global.MetadataPath = Path.normalize(METADATA_PATH)
|
||||||
|
|
||||||
// Fix backslash if not on Windows
|
// Fix backslash if not on Windows
|
||||||
if (process.platform !== 'win32') {
|
if (process.platform !== 'win32') {
|
||||||
global.ConfigPath = global.ConfigPath.replace(/\\/g, '/')
|
global.ConfigPath = global.ConfigPath.replace(/\\/g, '/')
|
||||||
global.AudiobookPath = global.AudiobookPath.replace(/\\/g, '/')
|
|
||||||
global.MetadataPath = global.MetadataPath.replace(/\\/g, '/')
|
global.MetadataPath = global.MetadataPath.replace(/\\/g, '/')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,10 +57,6 @@ class Server {
|
|||||||
fs.mkdirSync(global.MetadataPath)
|
fs.mkdirSync(global.MetadataPath)
|
||||||
filePerms.setDefaultDirSync(global.MetadataPath, false)
|
filePerms.setDefaultDirSync(global.MetadataPath, false)
|
||||||
}
|
}
|
||||||
if (!fs.pathExistsSync(global.AudiobookPath)) {
|
|
||||||
fs.mkdirSync(global.AudiobookPath)
|
|
||||||
filePerms.setDefaultDirSync(global.AudiobookPath, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.db = new Db()
|
this.db = new Db()
|
||||||
this.watcher = new Watcher()
|
this.watcher = new Watcher()
|
||||||
@@ -140,10 +136,9 @@ class Server {
|
|||||||
await this.db.init()
|
await this.db.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
this.auth.init()
|
|
||||||
|
|
||||||
await this.checkUserMediaProgress() // Remove invalid user item progress
|
await this.checkUserMediaProgress() // 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.cacheManager.ensureCachePaths()
|
||||||
|
|
||||||
await this.backupManager.init()
|
await this.backupManager.init()
|
||||||
await this.logManager.init()
|
await this.logManager.init()
|
||||||
@@ -214,18 +209,42 @@ class Server {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Client dynamic routes
|
// Client dynamic routes
|
||||||
app.get('/item/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
const dyanimicRoutes = [
|
||||||
app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
'/item/:id',
|
||||||
app.get('/library/:library', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
'/item/:id/manage',
|
||||||
app.get('/library/:library/search', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
'/item/:id/chapters',
|
||||||
app.get('/library/:library/bookshelf/:id?', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
'/audiobook/:id/edit',
|
||||||
app.get('/library/:library/authors', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
'/library/:library',
|
||||||
app.get('/library/:library/series/:id?', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
'/library/:library/search',
|
||||||
app.get('/config/users/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
'/library/:library/bookshelf/:id?',
|
||||||
app.get('/collection/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
'/library/:library/authors',
|
||||||
|
'/library/:library/series/:id?',
|
||||||
|
'/config/users/:id',
|
||||||
|
'/collection/:id'
|
||||||
|
]
|
||||||
|
dyanimicRoutes.forEach((route) => app.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
|
||||||
|
|
||||||
app.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
|
app.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
|
||||||
app.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
|
app.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
|
||||||
|
app.post('/init', (req, res) => {
|
||||||
|
if (this.db.hasRootUser) {
|
||||||
|
Logger.error(`[Server] attempt to init server when server already has a root user`)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
this.initializeServer(req, res)
|
||||||
|
})
|
||||||
|
app.get('/status', (req, res) => {
|
||||||
|
// status check for client to see if server has been initialized
|
||||||
|
// server has been initialized if a root user exists
|
||||||
|
const payload = {
|
||||||
|
isInit: this.db.hasRootUser
|
||||||
|
}
|
||||||
|
if (!payload.isInit) {
|
||||||
|
payload.ConfigPath = global.ConfigPath
|
||||||
|
payload.MetadataPath = global.MetadataPath
|
||||||
|
}
|
||||||
|
res.json(payload)
|
||||||
|
})
|
||||||
app.get('/ping', (req, res) => {
|
app.get('/ping', (req, res) => {
|
||||||
Logger.info('Recieved ping')
|
Logger.info('Recieved ping')
|
||||||
res.json({ success: true })
|
res.json({ success: true })
|
||||||
@@ -288,6 +307,17 @@ class Server {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async initializeServer(req, res) {
|
||||||
|
Logger.info(`[Server] Initializing new server`)
|
||||||
|
const newRoot = req.body.newRoot
|
||||||
|
let rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
|
||||||
|
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
|
||||||
|
let rootToken = await this.auth.generateAccessToken({ userId: 'root' })
|
||||||
|
await this.db.createRootUser(newRoot.username, rootPash, rootToken)
|
||||||
|
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
async filesChanged(fileUpdates) {
|
async filesChanged(fileUpdates) {
|
||||||
Logger.info('[Server]', fileUpdates.length, 'Files Changed')
|
Logger.info('[Server]', fileUpdates.length, 'Files Changed')
|
||||||
await this.scanner.scanFilesChanged(fileUpdates)
|
await this.scanner.scanFilesChanged(fileUpdates)
|
||||||
@@ -428,7 +458,6 @@ class Server {
|
|||||||
const initialPayload = {
|
const initialPayload = {
|
||||||
// TODO: this is sent with user auth now, update mobile app to use that then remove this
|
// TODO: this is sent with user auth now, update mobile app to use that then remove this
|
||||||
serverSettings: this.db.serverSettings.toJSON(),
|
serverSettings: this.db.serverSettings.toJSON(),
|
||||||
audiobookPath: global.AudiobookPath,
|
|
||||||
metadataPath: global.MetadataPath,
|
metadataPath: global.MetadataPath,
|
||||||
configPath: global.ConfigPath,
|
configPath: global.ConfigPath,
|
||||||
user: client.user.toJSONForBrowser(),
|
user: client.user.toJSONForBrowser(),
|
||||||
|
|||||||
@@ -140,7 +140,6 @@ class FolderWatcher extends EventEmitter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
Logger.debug(`[Watcher] Rename ${pathFrom} => ${pathTo}`)
|
Logger.debug(`[Watcher] Rename ${pathFrom} => ${pathTo}`)
|
||||||
this.addFileUpdate(libraryId, pathFrom, 'renamed')
|
|
||||||
this.addFileUpdate(libraryId, pathTo, 'renamed')
|
this.addFileUpdate(libraryId, pathTo, 'renamed')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,13 +162,6 @@ class FolderWatcher extends EventEmitter {
|
|||||||
}
|
}
|
||||||
var folderFullPath = folder.fullPath.replace(/\\/g, '/')
|
var folderFullPath = folder.fullPath.replace(/\\/g, '/')
|
||||||
|
|
||||||
// Check if file was added to root directory
|
|
||||||
var dir = Path.dirname(path)
|
|
||||||
if (dir === folderFullPath) {
|
|
||||||
Logger.warn(`[Watcher] New file "${Path.basename(path)}" added to folder root - ignoring it`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var relPath = path.replace(folderFullPath, '')
|
var relPath = path.replace(folderFullPath, '')
|
||||||
|
|
||||||
var hasDotPath = relPath.split('/').find(p => p.startsWith('.'))
|
var hasDotPath = relPath.split('/').find(p => p.startsWith('.'))
|
||||||
|
|||||||
@@ -1,11 +1,60 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { reqSupportsWebp } = require('../utils/index')
|
const { reqSupportsWebp } = require('../utils/index')
|
||||||
|
const { createNewSortInstance } = require('fast-sort')
|
||||||
|
|
||||||
|
const naturalSort = createNewSortInstance({
|
||||||
|
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
||||||
|
})
|
||||||
class AuthorController {
|
class AuthorController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
async findOne(req, res) {
|
async findOne(req, res) {
|
||||||
return res.json(req.author)
|
const include = (req.query.include || '').split(',')
|
||||||
|
|
||||||
|
const authorJson = req.author.toJSON()
|
||||||
|
|
||||||
|
// Used on author landing page to include library items and items grouped in series
|
||||||
|
if (include.includes('items')) {
|
||||||
|
authorJson.libraryItems = this.db.libraryItems.filter(li => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (include.includes('series')) {
|
||||||
|
const seriesMap = {}
|
||||||
|
// Group items into series
|
||||||
|
authorJson.libraryItems.forEach((li) => {
|
||||||
|
if (li.media.metadata.series) {
|
||||||
|
li.media.metadata.series.forEach((series) => {
|
||||||
|
|
||||||
|
const itemWithSeries = li.toJSONMinified()
|
||||||
|
itemWithSeries.media.metadata.series = series
|
||||||
|
|
||||||
|
if (seriesMap[series.id]) {
|
||||||
|
seriesMap[series.id].items.push(itemWithSeries)
|
||||||
|
} else {
|
||||||
|
seriesMap[series.id] = {
|
||||||
|
id: series.id,
|
||||||
|
name: series.name,
|
||||||
|
items: [itemWithSeries]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Sort series items
|
||||||
|
for (const key in seriesMap) {
|
||||||
|
seriesMap[key].items = naturalSort(seriesMap[key].items).asc(li => li.media.metadata.series.sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
authorJson.series = Object.values(seriesMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minify library items
|
||||||
|
authorJson.libraryItems = authorJson.libraryItems.map(li => li.toJSONMinified())
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(authorJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(req, res) {
|
async update(req, res) {
|
||||||
@@ -41,6 +90,7 @@ class AuthorController {
|
|||||||
}).length
|
}).length
|
||||||
this.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
this.emitter('author_updated', req.author.toJSONExpanded(numBooks))
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
author: req.author.toJSON(),
|
author: req.author.toJSON(),
|
||||||
updated: hasUpdated
|
updated: hasUpdated
|
||||||
@@ -57,11 +107,16 @@ class AuthorController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async match(req, res) {
|
async match(req, res) {
|
||||||
var authorData = await this.authorFinder.findAuthorByName(req.body.q)
|
var authorData = null
|
||||||
|
if (req.body.asin) {
|
||||||
|
authorData = await this.authorFinder.findAuthorByASIN(req.body.asin)
|
||||||
|
} else {
|
||||||
|
authorData = await this.authorFinder.findAuthorByName(req.body.q)
|
||||||
|
}
|
||||||
if (!authorData) {
|
if (!authorData) {
|
||||||
return res.status(404).send('Author not found')
|
return res.status(404).send('Author not found')
|
||||||
}
|
}
|
||||||
Logger.debug(`[AuthorController] match author with "${req.body.q}"`, authorData)
|
Logger.debug(`[AuthorController] match author with "${req.body.q || req.body.asin}"`, authorData)
|
||||||
|
|
||||||
var hasUpdates = false
|
var hasUpdates = false
|
||||||
if (authorData.asin && req.author.asin !== authorData.asin) {
|
if (authorData.asin && req.author.asin !== authorData.asin) {
|
||||||
@@ -71,6 +126,8 @@ class AuthorController {
|
|||||||
|
|
||||||
// Only updates image if there was no image before or the author ASIN was updated
|
// Only updates image if there was no image before or the author ASIN was updated
|
||||||
if (authorData.image && (!req.author.imagePath || hasUpdates)) {
|
if (authorData.image && (!req.author.imagePath || hasUpdates)) {
|
||||||
|
this.cacheManager.purgeImageCache(req.author.id)
|
||||||
|
|
||||||
var imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
|
var imageData = await this.authorFinder.saveAuthorImage(req.author.id, authorData.image)
|
||||||
if (imageData) {
|
if (imageData) {
|
||||||
req.author.imagePath = imageData.path
|
req.author.imagePath = imageData.path
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class LibraryController {
|
|||||||
newLibraryPayload.displayOrder = this.db.libraries.length + 1
|
newLibraryPayload.displayOrder = this.db.libraries.length + 1
|
||||||
library.setData(newLibraryPayload)
|
library.setData(newLibraryPayload)
|
||||||
await this.db.insertEntity('library', library)
|
await this.db.insertEntity('library', library)
|
||||||
|
// TODO: Only emit to users that have access
|
||||||
this.emitter('library_added', library.toJSON())
|
this.emitter('library_added', library.toJSON())
|
||||||
|
|
||||||
// Add library watcher
|
// Add library watcher
|
||||||
@@ -85,13 +86,17 @@ class LibraryController {
|
|||||||
return f
|
return f
|
||||||
})
|
})
|
||||||
for (var path of newFolderPaths) {
|
for (var path of newFolderPaths) {
|
||||||
var success = await fs.ensureDir(path).then(() => true).catch((error) => {
|
var pathExists = await fs.pathExists(path)
|
||||||
Logger.error(`[LibraryController] Failed to ensure folder dir "${path}"`, error)
|
if (!pathExists) {
|
||||||
return false
|
// Ensure dir will recursively create directories which might be preferred over mkdir
|
||||||
})
|
var success = await fs.ensureDir(path).then(() => true).catch((error) => {
|
||||||
if (!success) {
|
Logger.error(`[LibraryController] Failed to ensure folder dir "${path}"`, error)
|
||||||
return res.status(400).send(`Invalid folder directory "${path}"`)
|
return false
|
||||||
} else {
|
})
|
||||||
|
if (!success) {
|
||||||
|
return res.status(400).send(`Invalid folder directory "${path}"`)
|
||||||
|
}
|
||||||
|
// Set permissions on newly created path
|
||||||
await filePerms.setDefault(path)
|
await filePerms.setDefault(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,38 +224,6 @@ class LibraryItemController {
|
|||||||
res.json(libraryItem.toJSON())
|
res.json(libraryItem.toJSON())
|
||||||
}
|
}
|
||||||
|
|
||||||
// PATCH: api/items/:id/episodes
|
|
||||||
async updateEpisodes(req, res) { // For updating podcast episode order
|
|
||||||
var libraryItem = req.libraryItem
|
|
||||||
var orderedFileData = req.body.episodes
|
|
||||||
if (!libraryItem.media.setEpisodeOrder) {
|
|
||||||
Logger.error(`[LibraryItemController] updateEpisodes invalid media type ${libraryItem.id}`)
|
|
||||||
return res.sendStatus(500)
|
|
||||||
}
|
|
||||||
libraryItem.media.setEpisodeOrder(orderedFileData)
|
|
||||||
await this.db.updateLibraryItem(libraryItem)
|
|
||||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
|
||||||
res.json(libraryItem.toJSON())
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE: api/items/:id/episode/:episodeId
|
|
||||||
async removeEpisode(req, res) {
|
|
||||||
var episodeId = req.params.episodeId
|
|
||||||
var libraryItem = req.libraryItem
|
|
||||||
if (libraryItem.mediaType !== 'podcast') {
|
|
||||||
Logger.error(`[LibraryItemController] removeEpisode invalid media type ${libraryItem.id}`)
|
|
||||||
return res.sendStatus(500)
|
|
||||||
}
|
|
||||||
if (!libraryItem.media.episodes.find(ep => ep.id === episodeId)) {
|
|
||||||
Logger.error(`[LibraryItemController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
|
|
||||||
return res.sendStatus(404)
|
|
||||||
}
|
|
||||||
libraryItem.media.removeEpisode(episodeId)
|
|
||||||
await this.db.updateLibraryItem(libraryItem)
|
|
||||||
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
|
||||||
res.json(libraryItem.toJSON())
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST api/items/:id/match
|
// POST api/items/:id/match
|
||||||
async match(req, res) {
|
async match(req, res) {
|
||||||
var libraryItem = req.libraryItem
|
var libraryItem = req.libraryItem
|
||||||
@@ -359,7 +327,7 @@ class LibraryItemController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST: api/items/:id/audio-metadata
|
// GET: api/items/:id/audio-metadata
|
||||||
async updateAudioFileMetadata(req, res) {
|
async updateAudioFileMetadata(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
|
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
|
||||||
@@ -375,17 +343,74 @@ class LibraryItemController {
|
|||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST: api/items/:id/chapters
|
||||||
|
async updateMediaChapters(req, res) {
|
||||||
|
if (!req.user.canUpdate) {
|
||||||
|
Logger.error(`[LibraryItemController] User attempted to update chapters with invalid permissions`, req.user.username)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||||
|
Logger.error(`[LibraryItemController] Invalid library item`)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chapters = req.body.chapters || []
|
||||||
|
if (!chapters.length) {
|
||||||
|
Logger.error(`[LibraryItemController] Invalid payload`)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
|
||||||
|
if (wasUpdated) {
|
||||||
|
await this.db.updateLibraryItem(req.libraryItem)
|
||||||
|
this.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
updated: wasUpdated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: api/items/:id/open-feed
|
||||||
|
async openRSSFeed(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[LibraryItemController] Non-admin user attempted to open RSS feed`, req.user.username)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const feedData = this.rssFeedManager.openFeedForItem(req.user, req.libraryItem, req.body)
|
||||||
|
if (feedData.error) {
|
||||||
|
return res.json({
|
||||||
|
success: false,
|
||||||
|
error: feedData.error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
feedUrl: feedData.feedUrl
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeRSSFeed(req, res) {
|
||||||
|
if (!req.user.isAdminOrUp) {
|
||||||
|
Logger.error(`[LibraryItemController] Non-admin user attempted to close RSS feed`, req.user.username)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.rssFeedManager.closeFeedForItem(req.params.id)
|
||||||
|
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
middleware(req, res, next) {
|
||||||
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||||
if (!item || !item.media) return res.sendStatus(404)
|
if (!item || !item.media) return res.sendStatus(404)
|
||||||
|
|
||||||
// Check user can access this library
|
|
||||||
if (!req.user.checkCanAccessLibrary(item.libraryId)) {
|
|
||||||
return res.sendStatus(403)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check user can access this library item
|
// Check user can access this library item
|
||||||
if (!req.user.checkCanAccessLibraryItemWithTags(item.media.tags)) {
|
if (!req.user.checkCanAccessLibraryItem(item)) {
|
||||||
return res.sendStatus(403)
|
return res.sendStatus(403)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -225,6 +225,15 @@ class MiscController {
|
|||||||
res.json(author)
|
res.json(author)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findChapters(req, res) {
|
||||||
|
var asin = req.query.asin
|
||||||
|
var chapterData = await this.bookFinder.findChapters(asin)
|
||||||
|
if (!chapterData) {
|
||||||
|
return res.json({ error: 'Chapters not found' })
|
||||||
|
}
|
||||||
|
res.json(chapterData)
|
||||||
|
}
|
||||||
|
|
||||||
authorize(req, res) {
|
authorize(req, res) {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
Logger.error('Invalid user in authorize')
|
Logger.error('Invalid user in authorize')
|
||||||
@@ -233,7 +242,8 @@ class MiscController {
|
|||||||
const userResponse = {
|
const userResponse = {
|
||||||
user: req.user,
|
user: req.user,
|
||||||
userDefaultLibraryId: req.user.getDefaultLibraryId(this.db.libraries),
|
userDefaultLibraryId: req.user.getDefaultLibraryId(this.db.libraries),
|
||||||
serverSettings: this.db.serverSettings.toJSON()
|
serverSettings: this.db.serverSettings.toJSON(),
|
||||||
|
Source: global.Source
|
||||||
}
|
}
|
||||||
res.json(userResponse)
|
res.json(userResponse)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const fs = require('fs-extra')
|
const fs = require('fs-extra')
|
||||||
|
const Path = require('path')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
|
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
|
||||||
const LibraryItem = require('../objects/LibraryItem')
|
const LibraryItem = require('../objects/LibraryItem')
|
||||||
const { getFileTimestampsWithIno } = require('../utils/fileUtils')
|
const { getFileTimestampsWithIno, sanitizeFilename } = require('../utils/fileUtils')
|
||||||
const filePerms = require('../utils/filePerms')
|
const filePerms = require('../utils/filePerms')
|
||||||
|
|
||||||
class PodcastController {
|
class PodcastController {
|
||||||
@@ -107,6 +108,12 @@ class PodcastController {
|
|||||||
if (!payload) {
|
if (!payload) {
|
||||||
return res.status(500).send('Invalid podcast RSS feed')
|
return res.status(500).send('Invalid podcast RSS feed')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!payload.podcast.metadata.feedUrl) {
|
||||||
|
// Not every RSS feed will put the feed url in their metadata
|
||||||
|
payload.podcast.metadata.feedUrl = url
|
||||||
|
}
|
||||||
|
|
||||||
res.json(payload)
|
res.json(payload)
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
@@ -166,37 +173,6 @@ class PodcastController {
|
|||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
async openPodcastFeed(req, res) {
|
|
||||||
if (!req.user.isAdminOrUp) {
|
|
||||||
Logger.error(`[PodcastController] Non-admin user attempted to open podcast feed`, req.user.username)
|
|
||||||
return res.sendStatus(500)
|
|
||||||
}
|
|
||||||
|
|
||||||
const feedData = this.rssFeedManager.openPodcastFeed(req.user, req.libraryItem, req.body)
|
|
||||||
if (feedData.error) {
|
|
||||||
return res.json({
|
|
||||||
success: false,
|
|
||||||
error: feedData.error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
feedUrl: feedData.feedUrl
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async closePodcastFeed(req, res) {
|
|
||||||
if (!req.user.isAdminOrUp) {
|
|
||||||
Logger.error(`[PodcastController] Non-admin user attempted to close podcast feed`, req.user.username)
|
|
||||||
return res.sendStatus(500)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.rssFeedManager.closePodcastFeedForItem(req.params.id)
|
|
||||||
|
|
||||||
res.sendStatus(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateEpisode(req, res) {
|
async updateEpisode(req, res) {
|
||||||
var libraryItem = req.libraryItem
|
var libraryItem = req.libraryItem
|
||||||
|
|
||||||
@@ -214,6 +190,35 @@ class PodcastController {
|
|||||||
res.json(libraryItem.toJSONExpanded())
|
res.json(libraryItem.toJSONExpanded())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DELETE: api/podcasts/:id/episode/:episodeId
|
||||||
|
async removeEpisode(req, res) {
|
||||||
|
var episodeId = req.params.episodeId
|
||||||
|
var libraryItem = req.libraryItem
|
||||||
|
var hardDelete = req.query.hard === '1'
|
||||||
|
|
||||||
|
var episode = libraryItem.media.episodes.find(ep => ep.id === episodeId)
|
||||||
|
if (!episode) {
|
||||||
|
Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
|
||||||
|
return res.sendStatus(404)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hardDelete) {
|
||||||
|
var audioFile = episode.audioFile
|
||||||
|
// TODO: this will trigger the watcher. should maybe handle this gracefully
|
||||||
|
await fs.remove(audioFile.metadata.path).then(() => {
|
||||||
|
Logger.info(`[PodcastController] Hard deleted episode file at "${audioFile.metadata.path}"`)
|
||||||
|
}).catch((error) => {
|
||||||
|
Logger.error(`[PodcastController] Failed to hard delete episode file at "${audioFile.metadata.path}"`, error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryItem.media.removeEpisode(episodeId)
|
||||||
|
|
||||||
|
await this.db.updateLibraryItem(libraryItem)
|
||||||
|
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
||||||
|
res.json(libraryItem.toJSON())
|
||||||
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
middleware(req, res, next) {
|
||||||
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||||
if (!item || !item.media) return res.sendStatus(404)
|
if (!item || !item.media) return res.sendStatus(404)
|
||||||
|
|||||||
@@ -19,9 +19,14 @@ class AuthorFinder {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findAuthorByASIN(asin) {
|
||||||
|
if (!asin) return null
|
||||||
|
return this.audnexus.findAuthorByASIN(asin)
|
||||||
|
}
|
||||||
|
|
||||||
async findAuthorByName(name, options = {}) {
|
async findAuthorByName(name, options = {}) {
|
||||||
if (!name) return null
|
if (!name) return null
|
||||||
const maxLevenshtein = !isNaN(options.maxLevenshtein) ? Number(options.maxLevenshtein) : 2
|
const maxLevenshtein = !isNaN(options.maxLevenshtein) ? Number(options.maxLevenshtein) : 3
|
||||||
|
|
||||||
var author = await this.audnexus.findAuthorByName(name, maxLevenshtein)
|
var author = await this.audnexus.findAuthorByName(name, maxLevenshtein)
|
||||||
if (!author || !author.name) {
|
if (!author || !author.name) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const LibGen = require('../providers/LibGen')
|
|||||||
const GoogleBooks = require('../providers/GoogleBooks')
|
const GoogleBooks = require('../providers/GoogleBooks')
|
||||||
const Audible = require('../providers/Audible')
|
const Audible = require('../providers/Audible')
|
||||||
const iTunes = require('../providers/iTunes')
|
const iTunes = require('../providers/iTunes')
|
||||||
|
const Audnexus = require('../providers/Audnexus')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { levenshteinDistance } = require('../utils/index')
|
const { levenshteinDistance } = require('../utils/index')
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ class BookFinder {
|
|||||||
this.googleBooks = new GoogleBooks()
|
this.googleBooks = new GoogleBooks()
|
||||||
this.audible = new Audible()
|
this.audible = new Audible()
|
||||||
this.iTunesApi = new iTunes()
|
this.iTunesApi = new iTunes()
|
||||||
|
this.audnexus = new Audnexus()
|
||||||
|
|
||||||
this.verbose = false
|
this.verbose = false
|
||||||
}
|
}
|
||||||
@@ -226,5 +228,9 @@ class BookFinder {
|
|||||||
})
|
})
|
||||||
return covers
|
return covers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findChapters(asin) {
|
||||||
|
return this.audnexus.getChaptersByASIN(asin)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = BookFinder
|
module.exports = BookFinder
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const fs = require('fs-extra')
|
const fs = require('fs-extra')
|
||||||
const stream = require('stream')
|
const stream = require('stream')
|
||||||
|
const filePerms = require('../utils/filePerms')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { resizeImage } = require('../utils/ffmpegHelpers')
|
const { resizeImage } = require('../utils/ffmpegHelpers')
|
||||||
|
|
||||||
@@ -9,6 +10,34 @@ class CacheManager {
|
|||||||
this.CachePath = Path.join(global.MetadataPath, 'cache')
|
this.CachePath = Path.join(global.MetadataPath, 'cache')
|
||||||
this.CoverCachePath = Path.join(this.CachePath, 'covers')
|
this.CoverCachePath = Path.join(this.CachePath, 'covers')
|
||||||
this.ImageCachePath = Path.join(this.CachePath, 'images')
|
this.ImageCachePath = Path.join(this.CachePath, 'images')
|
||||||
|
|
||||||
|
this.cachePathsExist = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureCachePaths() { // Creates cache paths if necessary and sets owner and permissions
|
||||||
|
if (this.cachePathsExist) return
|
||||||
|
|
||||||
|
var pathsCreated = false
|
||||||
|
if (!(await fs.pathExists(this.CachePath))) {
|
||||||
|
await fs.mkdir(this.CachePath)
|
||||||
|
pathsCreated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(this.CoverCachePath))) {
|
||||||
|
await fs.mkdir(this.CoverCachePath)
|
||||||
|
pathsCreated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await fs.pathExists(this.ImageCachePath))) {
|
||||||
|
await fs.mkdir(this.ImageCachePath)
|
||||||
|
pathsCreated = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathsCreated) {
|
||||||
|
await filePerms.setDefault(this.CachePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cachePathsExist = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleCoverCache(res, libraryItem, options = {}) {
|
async handleCoverCache(res, libraryItem, options = {}) {
|
||||||
@@ -33,9 +62,6 @@ class CacheManager {
|
|||||||
return ps.pipe(res)
|
return ps.pipe(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write cache
|
|
||||||
await fs.ensureDir(this.CoverCachePath)
|
|
||||||
|
|
||||||
if (!libraryItem.media.coverPath || !await fs.pathExists(libraryItem.media.coverPath)) {
|
if (!libraryItem.media.coverPath || !await fs.pathExists(libraryItem.media.coverPath)) {
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
@@ -43,6 +69,9 @@ class CacheManager {
|
|||||||
let writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height)
|
let writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height)
|
||||||
if (!writtenFile) return res.sendStatus(400)
|
if (!writtenFile) return res.sendStatus(400)
|
||||||
|
|
||||||
|
// Set owner and permissions of cache image
|
||||||
|
await filePerms.setDefault(path)
|
||||||
|
|
||||||
var readStream = fs.createReadStream(writtenFile)
|
var readStream = fs.createReadStream(writtenFile)
|
||||||
readStream.pipe(res)
|
readStream.pipe(res)
|
||||||
}
|
}
|
||||||
@@ -56,8 +85,6 @@ class CacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async purgeEntityCache(entityId, cachePath) {
|
async purgeEntityCache(entityId, cachePath) {
|
||||||
// If purgeAll has been called... The cover cache directory no longer exists
|
|
||||||
await fs.ensureDir(cachePath)
|
|
||||||
return Promise.all((await fs.readdir(cachePath)).reduce((promises, file) => {
|
return Promise.all((await fs.readdir(cachePath)).reduce((promises, file) => {
|
||||||
if (file.startsWith(entityId)) {
|
if (file.startsWith(entityId)) {
|
||||||
Logger.debug(`[CacheManager] Going to purge ${file}`);
|
Logger.debug(`[CacheManager] Going to purge ${file}`);
|
||||||
@@ -84,6 +111,7 @@ class CacheManager {
|
|||||||
Logger.error(`[CacheManager] Failed to remove cache dir "${this.CachePath}"`, error)
|
Logger.error(`[CacheManager] Failed to remove cache dir "${this.CachePath}"`, error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
await this.ensureCachePaths()
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleAuthorCache(res, author, options = {}) {
|
async handleAuthorCache(res, author, options = {}) {
|
||||||
@@ -108,12 +136,12 @@ class CacheManager {
|
|||||||
return ps.pipe(res)
|
return ps.pipe(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write cache
|
|
||||||
await fs.ensureDir(this.ImageCachePath)
|
|
||||||
|
|
||||||
let writtenFile = await resizeImage(author.imagePath, path, width, height)
|
let writtenFile = await resizeImage(author.imagePath, path, width, height)
|
||||||
if (!writtenFile) return res.sendStatus(400)
|
if (!writtenFile) return res.sendStatus(400)
|
||||||
|
|
||||||
|
// Set owner and permissions of cache image
|
||||||
|
await filePerms.setDefault(path)
|
||||||
|
|
||||||
var readStream = fs.createReadStream(writtenFile)
|
var readStream = fs.createReadStream(writtenFile)
|
||||||
readStream.pipe(res)
|
readStream.pipe(res)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user