mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-01-04 13:39:38 -05:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c07f9ec25 | ||
|
|
0ba38d45bc | ||
|
|
0da327222e | ||
|
|
868e1af28a | ||
|
|
a343a1038c | ||
|
|
3e5338ec8e | ||
|
|
01fdca4bf9 | ||
|
|
ed96dd7c81 | ||
|
|
1ead5de9f5 | ||
|
|
06554811e2 | ||
|
|
9935bd2ffa | ||
|
|
4260903bbe | ||
|
|
64ae8ef849 | ||
|
|
be15f2f5a0 | ||
|
|
341de6a196 | ||
|
|
740f6966ba | ||
|
|
2e889ff9fe | ||
|
|
d123abd4cd | ||
|
|
cc84349d6d | ||
|
|
b5e83d8866 | ||
|
|
ca9521ac9a | ||
|
|
078f404fe4 | ||
|
|
bc47dfa343 | ||
|
|
03f39d71e3 | ||
|
|
69cd6aa4d0 | ||
|
|
e161f70710 | ||
|
|
3ef0173226 | ||
|
|
87a308f749 | ||
|
|
f45a1c4161 | ||
|
|
5e15695f64 | ||
|
|
b35997e8be | ||
|
|
fcd664c16e | ||
|
|
94741598af | ||
|
|
6cb418a871 | ||
|
|
d234fdea11 | ||
|
|
13ac5f1d2a | ||
|
|
f4d6e65380 | ||
|
|
baccbaf82a | ||
|
|
d6969e0b85 | ||
|
|
b3ad9c95ce | ||
|
|
2f6417dec2 | ||
|
|
f54d48270e | ||
|
|
57321084af | ||
|
|
1d97422011 | ||
|
|
2f2a64b89e | ||
|
|
b2e129eec7 | ||
|
|
cb79e48685 | ||
|
|
e735ef7869 | ||
|
|
8f1152762a | ||
|
|
587adb3773 | ||
|
|
db01db3a2b | ||
|
|
0851a1e71e | ||
|
|
0addfc8269 | ||
|
|
b2ab5730f5 | ||
|
|
7859d7a502 |
@@ -10,4 +10,5 @@ npm-debug.log
|
||||
dev.js
|
||||
test/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
/client/dist/
|
||||
/dist/
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -7,4 +7,5 @@ node_modules/
|
||||
/metadata/
|
||||
test/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
/client/dist/
|
||||
/dist/
|
||||
8
build/debian/DEBIAN/control
Normal file
8
build/debian/DEBIAN/control
Normal file
@@ -0,0 +1,8 @@
|
||||
Package: audiobookshelf
|
||||
Version: 1.2.1
|
||||
Section: base
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
Depends:
|
||||
Maintainer: advplyr
|
||||
Description: Self-hosted audiobook server for managing and playing audiobooks
|
||||
85
build/debian/DEBIAN/postinst
Normal file
85
build/debian/DEBIAN/postinst
Normal file
@@ -0,0 +1,85 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
declare -r init_type='auto'
|
||||
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 () {
|
||||
: "${1:?'Service name was not defined'}"
|
||||
declare -r service_name="$1"
|
||||
|
||||
if hash systemctl 2> /dev/null; then
|
||||
if [[ "$init_type" == 'auto' || "$init_type" == 'systemd' ]]; then
|
||||
{
|
||||
systemctl enable "$service_name.service" && \
|
||||
systemctl start "$service_name.service"
|
||||
} || echo "$service_name could not be registered or started"
|
||||
fi
|
||||
elif hash service 2> /dev/null; then
|
||||
if [[ "$init_type" == 'auto' || "$init_type" == 'upstart' || "$init_type" == 'sysv' ]]; then
|
||||
service "$service_name" start || echo "$service_name could not be registered or started"
|
||||
fi
|
||||
elif hash start 2> /dev/null; then
|
||||
if [[ "$init_type" == 'auto' || "$init_type" == 'upstart' ]]; then
|
||||
start "$service_name" || echo "$service_name could not be registered or started"
|
||||
fi
|
||||
elif hash update-rc.d 2> /dev/null; then
|
||||
if [[ "$init_type" == 'auto' || "$init_type" == 'sysv' ]]; then
|
||||
{
|
||||
update-rc.d "$service_name" defaults && \
|
||||
"/etc/init.d/$service_name" start
|
||||
} || echo "$service_name could not be registered or started"
|
||||
fi
|
||||
else
|
||||
echo 'Your system does not appear to use systemd, Upstart, or System V, so the service could not be started'
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
add_group 'audiobookshelf' ''
|
||||
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
||||
|
||||
mkdir -p '/var/log/audiobookshelf'
|
||||
chown -R 'audiobookshelf:audiobookshelf' '/var/log/audiobookshelf'
|
||||
chown -R 'audiobookshelf:audiobookshelf' '/usr/share/audiobookshelf'
|
||||
|
||||
start_service 'audiobookshelf'
|
||||
99
build/debian/DEBIAN/preinst
Normal file
99
build/debian/DEBIAN/preinst
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
|
||||
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
|
||||
DEFAULT_PORT=7331
|
||||
|
||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||
|
||||
install_ffmpeg() {
|
||||
echo "Starting FFMPEG Install"
|
||||
|
||||
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz"
|
||||
TARGET_DIR="/usr/lib/audiobookshelf-ffmpeg/"
|
||||
|
||||
if ! cd "$TARGET_DIR"; then
|
||||
echo "WARNING: can't access working directory ($TARGET_DIR) creating it" >&2
|
||||
mkdir "$TARGET_DIR"
|
||||
cd "$TARGET_DIR"
|
||||
fi
|
||||
|
||||
$WGET
|
||||
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1
|
||||
rm ffmpeg-git-amd64-static.tar.xz
|
||||
|
||||
chown -R 'audiobookshelf:audiobookshelf' "$TARGET_DIR"
|
||||
|
||||
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() {
|
||||
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
|
||||
PORT=$PORT"
|
||||
|
||||
echo "$config_text"
|
||||
|
||||
echo "$config_text" > /etc/default/audiobookshelf;
|
||||
|
||||
echo "Config created"
|
||||
|
||||
fi
|
||||
}
|
||||
|
||||
setup_config
|
||||
|
||||
install_ffmpeg
|
||||
26
build/debian/DEBIAN/prerm
Normal file
26
build/debian/DEBIAN/prerm
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
declare -r init_type='auto'
|
||||
declare -r service_name='audiobookshelf'
|
||||
|
||||
if [[ "$init_type" == 'auto' || "$init_type" == 'systemd' || "$init_type" == 'upstart' || "$init_type" == 'sysv' ]]; then
|
||||
if hash systemctl 2> /dev/null; then
|
||||
systemctl disable "$service_name.service" && \
|
||||
systemctl stop "$service_name.service" || \
|
||||
echo "$service_name wasn't even running!"
|
||||
elif hash service 2> /dev/null; then
|
||||
service "$service_name" stop || echo "$service_name wasn't even running!"
|
||||
elif hash stop 2> /dev/null; then
|
||||
stop "$service_name" || echo "$service_name wasn't even running!"
|
||||
elif hash update-rc.d 2> /dev/null; then
|
||||
{
|
||||
update-rc.d "$service_name" remove && \
|
||||
"/etc/init.d/$service_name" stop
|
||||
} || "$service_name wasn't even running!"
|
||||
else
|
||||
echo "Your system does not appear to use upstart, systemd or sysv, so $service_name could not be stopped"
|
||||
echo 'Unless these systems were removed since install, no processes have been left running'
|
||||
fi
|
||||
fi
|
||||
0
build/debian/etc/default/.gitkeep
Normal file
0
build/debian/etc/default/.gitkeep
Normal file
16
build/debian/lib/systemd/system/audiobookshelf.service
Normal file
16
build/debian/lib/systemd/system/audiobookshelf.service
Normal file
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Self-hosted audiobook server for managing and playing audiobooks
|
||||
Requires=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/default/audiobookshelf
|
||||
WorkingDirectory=/usr/share/audiobookshelf
|
||||
ExecStart=/usr/share/audiobookshelf/audiobookshelf
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
User=audiobookshelf
|
||||
PermissionsStartOnly=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
0
build/debian/usr/lib/audiobookshelf-ffmpeg/.gitkeep
Normal file
0
build/debian/usr/lib/audiobookshelf-ffmpeg/.gitkeep
Normal file
0
build/debian/usr/share/audiobookshelf/.gitkeep
Normal file
0
build/debian/usr/share/audiobookshelf/.gitkeep
Normal file
57
build/linuxpackager
Executable file
57
build/linuxpackager
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
|
||||
cd "$SCRIPT_DIR/.."
|
||||
|
||||
# Get package version without double quotes
|
||||
VERSION="$( eval echo $( jq '.version' package.json) )"
|
||||
DESCRIPTION="$( eval echo $( jq '.description' package.json) )"
|
||||
OUTPUT_FILE="audiobookshelf_${VERSION}_amd64.deb"
|
||||
|
||||
echo ">>> Building Client"
|
||||
echo "--------------------"
|
||||
|
||||
cd client
|
||||
rm -rf node_modules
|
||||
npm ci --unsafe-perm=true --allow-root
|
||||
npm run generate
|
||||
cd ..
|
||||
|
||||
echo ">>> Building Server"
|
||||
echo "--------------------"
|
||||
|
||||
rm -rf node_modules
|
||||
npm ci --unsafe-perm=true --allow-root
|
||||
|
||||
echo ">>> Packaging"
|
||||
echo "--------------------"
|
||||
|
||||
# Create debian control file
|
||||
|
||||
mkdir -p dist
|
||||
cp -R build/debian dist/debian
|
||||
chmod -R 775 dist/debian
|
||||
|
||||
controlfile="Package: audiobookshelf
|
||||
Version: $VERSION
|
||||
Section: base
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
Depends:
|
||||
Maintainer: advplyr
|
||||
Description: $DESCRIPTION"
|
||||
|
||||
echo "$controlfile" > dist/debian/DEBIAN/control;
|
||||
|
||||
# Package debian
|
||||
pkg -t node12-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||
|
||||
fakeroot dpkg-deb --build dist/debian
|
||||
|
||||
mv dist/debian.deb "dist/$OUTPUT_FILE"
|
||||
chmod +x "dist/$OUTPUT_FILE"
|
||||
|
||||
echo "Finished! Filename: $OUTPUT_FILE"
|
||||
@@ -9,20 +9,38 @@
|
||||
height: calc(100% - 64px - 165px);
|
||||
max-height: calc(100% - 64px - 165px);
|
||||
}
|
||||
#bookshelf {
|
||||
height: calc(100% - 40px);
|
||||
}
|
||||
|
||||
/* width */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
/* ::-webkit-scrollbar:horizontal { */
|
||||
/* height: 16px; */
|
||||
/* height: 24px;
|
||||
} */
|
||||
/* Track */
|
||||
::-webkit-scrollbar-track {
|
||||
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 */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #855620;
|
||||
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 */
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #704922;
|
||||
@@ -102,3 +120,11 @@
|
||||
.box-shadow-book {
|
||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||
}
|
||||
|
||||
.box-shadow-book3d {
|
||||
box-shadow: 4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||
}
|
||||
|
||||
.box-shadow-side {
|
||||
box-shadow: 5px 0px 5px #11111166;
|
||||
}
|
||||
|
||||
@@ -12,9 +12,6 @@
|
||||
<div v-if="chapters.length" class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
|
||||
<span class="material-icons text-3xl">format_list_bulleted</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center text-gray-500">
|
||||
<span class="material-icons text-3xl">format_list_bulleted</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute right-32 top-0 bottom-0">
|
||||
@@ -56,10 +53,17 @@
|
||||
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
|
||||
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
|
||||
</div>
|
||||
<div ref="track" class="w-full h-2 relative overflow-hidden">
|
||||
<template v-for="(tick, index) in chapterTicks">
|
||||
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-50 h-1 pointer-events-none" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Hover timestamp -->
|
||||
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5">00:00</p>
|
||||
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
|
||||
</div>
|
||||
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
|
||||
<div class="arrow-down" />
|
||||
</div>
|
||||
@@ -68,7 +72,7 @@
|
||||
|
||||
<audio ref="audio" @pause="paused" @playing="playing" @progress="progress" @timeupdate="timeupdate" @loadeddata="audioLoadedData" />
|
||||
|
||||
<modals-chapters-modal v-model="showChaptersModal" :chapters="chapters" @select="selectChapter" />
|
||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -100,7 +104,9 @@ export default {
|
||||
totalDuration: 0,
|
||||
seekedTime: 0,
|
||||
seekLoading: false,
|
||||
showChaptersModal: false
|
||||
showChaptersModal: false,
|
||||
currentTime: 0,
|
||||
trackOffsetLeft: 16 // Track is 16px from edge
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -109,6 +115,18 @@ export default {
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
},
|
||||
chapterTicks() {
|
||||
return this.chapters.map((chap) => {
|
||||
var perc = chap.start / this.totalDuration
|
||||
return {
|
||||
title: chap.title,
|
||||
left: perc * this.trackWidth
|
||||
}
|
||||
})
|
||||
},
|
||||
currentChapter() {
|
||||
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -172,10 +190,29 @@ export default {
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
var width = this.$refs.hoverTimestamp.clientWidth
|
||||
this.$refs.hoverTimestamp.style.opacity = 1
|
||||
this.$refs.hoverTimestamp.style.left = offsetX - width / 2 + 'px'
|
||||
var posLeft = offsetX - width / 2
|
||||
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
|
||||
posLeft = window.innerWidth - width - this.trackOffsetLeft
|
||||
} else if (posLeft < -this.trackOffsetLeft) {
|
||||
posLeft = -this.trackOffsetLeft
|
||||
}
|
||||
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
|
||||
}
|
||||
|
||||
if (this.$refs.hoverTimestampArrow) {
|
||||
var width = this.$refs.hoverTimestampArrow.clientWidth
|
||||
var posLeft = offsetX - width / 2
|
||||
this.$refs.hoverTimestampArrow.style.opacity = 1
|
||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||
}
|
||||
if (this.$refs.hoverTimestampText) {
|
||||
this.$refs.hoverTimestampText.innerText = this.$secondsToTimestamp(time)
|
||||
var hoverText = this.$secondsToTimestamp(time)
|
||||
|
||||
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
|
||||
if (chapter && chapter.title) {
|
||||
hoverText += ` - ${chapter.title}`
|
||||
}
|
||||
this.$refs.hoverTimestampText.innerText = hoverText
|
||||
}
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.opacity = 1
|
||||
@@ -186,6 +223,9 @@ export default {
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
this.$refs.hoverTimestamp.style.opacity = 0
|
||||
}
|
||||
if (this.$refs.hoverTimestampArrow) {
|
||||
this.$refs.hoverTimestampArrow.style.opacity = 0
|
||||
}
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.opacity = 0
|
||||
}
|
||||
@@ -289,7 +329,6 @@ export default {
|
||||
end: end + offset
|
||||
})
|
||||
}
|
||||
|
||||
return ranges
|
||||
},
|
||||
getLastBufferedTime() {
|
||||
@@ -315,7 +354,6 @@ export default {
|
||||
this.bufferTrackWidth = bufferlen
|
||||
},
|
||||
timeupdate() {
|
||||
// console.log('Time update', this.audioEl.currentTime)
|
||||
if (!this.$refs.playedTrack) {
|
||||
console.error('Invalid no played track ref')
|
||||
return
|
||||
@@ -335,6 +373,8 @@ export default {
|
||||
|
||||
this.updateTimestamp()
|
||||
|
||||
this.currentTime = this.audioEl.currentTime
|
||||
|
||||
var perc = this.audioEl.currentTime / this.audioEl.duration
|
||||
var ptWidth = Math.round(perc * this.trackWidth)
|
||||
if (this.playedTrackWidth === ptWidth) {
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
<template>
|
||||
<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-6 py-1 z-30">
|
||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-40">
|
||||
<div class="flex h-full items-center">
|
||||
<img v-if="!showBack" src="/Logo48.png" class="w-12 h-12 mr-4" />
|
||||
<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">
|
||||
<span class="material-icons text-4xl text-white">arrow_back</span>
|
||||
</a>
|
||||
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
|
||||
|
||||
<!-- <div class="-mb-2">
|
||||
<h1 class="text-lg font-book leading-3 mr-6 px-1">AudioBookshelf</h1>
|
||||
<div class="bg-black bg-opacity-20 rounded-sm py-1.5 px-2 mt-1.5 flex items-center justify-between border border-bg">
|
||||
<p class="text-sm text-gray-400 leading-3">My Library</p>
|
||||
<span class="material-icons text-sm leading-3 text-gray-400">expand_more</span>
|
||||
</div>
|
||||
</div> -->
|
||||
<controls-global-search />
|
||||
<div class="flex-grow" />
|
||||
|
||||
<nuxt-link v-if="isRootUser" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mr-4">
|
||||
<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">
|
||||
<span class="material-icons">upload</span>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center">
|
||||
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center ml-4">
|
||||
<span class="material-icons">settings</span>
|
||||
</nuxt-link>
|
||||
|
||||
@@ -31,13 +37,19 @@
|
||||
|
||||
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
|
||||
<ui-btn small class="text-sm mx-2" @click="toggleSelectAll">{{ isAllSelected ? 'Select None' : 'Select All' }}</ui-btn>
|
||||
<ui-btn v-show="!isHome" small class="text-sm mx-2" @click="toggleSelectAll"
|
||||
>{{ isAllSelected ? 'Select None' : 'Select All' }}<span class="pl-2">({{ audiobooksShowing.length }})</span></ui-btn
|
||||
>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
|
||||
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
<template v-if="userCanUpdate">
|
||||
<ui-btn v-show="!processingBatchDelete" color="warning" small class="mx-2" @click="batchEditClick"><span class="material-icons text-gray-200 pt-1">edit</span></ui-btn>
|
||||
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
||||
</template>
|
||||
<ui-btn v-if="userCanDelete" color="error" small class="mx-2" :loading="processingBatchDelete" @click="batchDeleteClick"><span class="material-icons text-gray-200 pt-1">delete</span></ui-btn>
|
||||
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,8 +64,11 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isHome() {
|
||||
return this.$route.name === 'index'
|
||||
},
|
||||
showBack() {
|
||||
return this.$route.name !== 'index'
|
||||
return this.$route.name !== 'library-id' && !this.isHome
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
@@ -61,6 +76,7 @@ export default {
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
|
||||
username() {
|
||||
return this.user ? this.user.username : 'err'
|
||||
},
|
||||
@@ -73,23 +89,41 @@ export default {
|
||||
isAllSelected() {
|
||||
return this.audiobooksShowing.length === this.selectedAudiobooks.length
|
||||
},
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user.user.audiobooks || {}
|
||||
},
|
||||
audiobooksShowing() {
|
||||
return this.$store.getters['audiobooks/getFiltered']()
|
||||
// return this.$store.getters['audiobooks/getFiltered']()
|
||||
return this.$store.getters['audiobooks/getEntitiesShowing']()
|
||||
},
|
||||
selectedSeries() {
|
||||
return this.$store.state.audiobooks.selectedSeries
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userCanUpload() {
|
||||
return this.$store.getters['user/getUserCanUpload']
|
||||
},
|
||||
selectedIsRead() {
|
||||
// Find an audiobook that is not read, if none then all audiobooks read
|
||||
return !this.selectedAudiobooks.find((ab) => {
|
||||
var userAb = this.userAudiobooks[ab]
|
||||
return !userAb || !userAb.isRead
|
||||
})
|
||||
},
|
||||
processingBatch() {
|
||||
return this.$store.state.processingBatch
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
back() {
|
||||
if (this.$route.name === 'audiobook-id-edit') {
|
||||
this.$router.push(`/audiobook/${this.$route.params.id}`)
|
||||
} else {
|
||||
this.$router.push('/')
|
||||
}
|
||||
async back() {
|
||||
var popped = await this.$store.dispatch('popRoute')
|
||||
var backTo = popped || '/'
|
||||
this.$router.push(backTo)
|
||||
},
|
||||
cancelSelectionMode() {
|
||||
if (this.processingBatchDelete) return
|
||||
@@ -103,8 +137,32 @@ export default {
|
||||
this.$store.commit('setSelectedAudiobooks', audiobookIds)
|
||||
}
|
||||
},
|
||||
toggleBatchRead() {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
var newIsRead = !this.selectedIsRead
|
||||
var updateProgressPayloads = this.selectedAudiobooks.map((ab) => {
|
||||
return {
|
||||
audiobookId: ab,
|
||||
isRead: newIsRead
|
||||
}
|
||||
})
|
||||
this.$axios
|
||||
.patch(`/api/user/audiobooks`, updateProgressPayloads)
|
||||
.then(() => {
|
||||
this.$toast.success('Batch update success!')
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error('Batch update failed')
|
||||
console.error('Failed to batch update read/not read', error)
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
})
|
||||
},
|
||||
batchDeleteClick() {
|
||||
if (confirm(`Are you sure you want to delete these ${this.numAudiobooksSelected} audiobook(s)?`)) {
|
||||
var audiobookText = this.numAudiobooksSelected > 1 ? `these ${this.numAudiobooksSelected} audiobooks` : 'this audiobook'
|
||||
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the audiobooks from AudioBookshelf`
|
||||
if (confirm(confirmMsg)) {
|
||||
this.processingBatchDelete = true
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
this.$axios
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-auto relative">
|
||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
|
||||
<!-- Cover size widget -->
|
||||
<div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-20">
|
||||
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
|
||||
@@ -10,23 +10,30 @@
|
||||
</div>
|
||||
|
||||
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4">Your Audiobookshelf is empty!</p>
|
||||
<ui-btn color="success" @click="scan">Scan your Audiobooks</ui-btn>
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
|
||||
<div class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full flex flex-col items-center">
|
||||
<template v-for="(shelf, index) in groupedBooks">
|
||||
<div v-else id="bookshelf" class="w-full flex flex-col items-center">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<div :key="index" class="w-full bookshelfRow relative">
|
||||
<div class="flex justify-center items-center">
|
||||
<template v-for="audiobook in shelf">
|
||||
<cards-book-card :ref="`audiobookCard-${audiobook.id}`" :key="audiobook.id" :width="bookCoverWidth" :user-progress="userAudiobooks[audiobook.id]" :audiobook="audiobook" />
|
||||
<template v-for="entity in shelf">
|
||||
<cards-group-card v-if="showGroups" :key="entity.id" :width="bookCoverWidth" :group="entity" @click="clickGroup" />
|
||||
<!-- <cards-book-3d :key="entity.id" v-else :width="100" :src="$store.getters['audiobooks/getBookCoverSrc'](entity.book)" /> -->
|
||||
<cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-show="!groupedBooks.length" class="w-full py-16 text-center text-xl">
|
||||
<div class="py-4">No Audiobooks</div>
|
||||
<ui-btn v-if="filterBy !== 'all' || keywordFilter" @click="clearFilter">Clear Filter</ui-btn>
|
||||
<div v-show="!shelves.length" class="w-full py-16 text-center text-xl">
|
||||
<div v-if="page === 'search'" class="py-4 mb-6"><p class="text-2xl">No Results</p></div>
|
||||
<div v-else class="py-4">No {{ showGroups ? 'Series' : 'Audiobooks' }}</div>
|
||||
<ui-btn v-if="!showGroups && (filterBy !== 'all' || keywordFilter)" @click="clearFilter">Clear Filter</ui-btn>
|
||||
<ui-btn v-else-if="page === 'search'" to="/library">Back to Library</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,21 +41,42 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
page: String,
|
||||
selectedSeries: String,
|
||||
searchResults: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
searchQuery: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
width: 0,
|
||||
booksPerRow: 0,
|
||||
groupedBooks: [],
|
||||
shelves: [],
|
||||
currFilterOrderKey: null,
|
||||
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
|
||||
selectedSizeIndex: 3,
|
||||
rowPaddingX: 40,
|
||||
keywordFilterTimeout: null
|
||||
keywordFilterTimeout: null,
|
||||
scannerParseSubtitle: false,
|
||||
wrapperClientWidth: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
keywordFilter() {
|
||||
this.checkKeywordFilter()
|
||||
},
|
||||
selectedSeries() {
|
||||
this.$nextTick(() => {
|
||||
this.$store.commit('audiobooks/setSelectedSeries', this.selectedSeries)
|
||||
this.setBookshelfEntities()
|
||||
})
|
||||
},
|
||||
searchResults() {
|
||||
this.$nextTick(() => {
|
||||
this.$store.commit('audiobooks/setSearchResults', this.searchResults)
|
||||
this.setBookshelfEntities()
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -81,9 +109,29 @@ export default {
|
||||
},
|
||||
filterBy() {
|
||||
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||
},
|
||||
showGroups() {
|
||||
return this.page !== '' && this.page !== 'search' && !this.selectedSeries
|
||||
},
|
||||
entities() {
|
||||
if (this.page === '') {
|
||||
return this.$store.getters['audiobooks/getFilteredAndSorted']()
|
||||
} else if (this.page === 'search') {
|
||||
return this.searchResults || []
|
||||
} else {
|
||||
var seriesGroups = this.$store.getters['audiobooks/getSeriesGroups']()
|
||||
if (this.selectedSeries) {
|
||||
var group = seriesGroups.find((group) => group.name === this.selectedSeries)
|
||||
return group.books
|
||||
}
|
||||
return seriesGroups
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickGroup(group) {
|
||||
this.$emit('update:selectedSeries', group.name)
|
||||
},
|
||||
clearFilter() {
|
||||
this.$store.commit('audiobooks/setKeywordFilter', null)
|
||||
if (this.filterBy !== 'all') {
|
||||
@@ -91,13 +139,13 @@ export default {
|
||||
filterBy: 'all'
|
||||
})
|
||||
} else {
|
||||
this.setGroupedBooks()
|
||||
this.setBookshelfEntities()
|
||||
}
|
||||
},
|
||||
checkKeywordFilter() {
|
||||
clearTimeout(this.keywordFilterTimeout)
|
||||
this.keywordFilterTimeout = setTimeout(() => {
|
||||
this.setGroupedBooks()
|
||||
this.setBookshelfEntities()
|
||||
}, 500)
|
||||
},
|
||||
increaseSize() {
|
||||
@@ -110,59 +158,52 @@ export default {
|
||||
this.resize()
|
||||
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
|
||||
},
|
||||
setGroupedBooks() {
|
||||
setBookshelfEntities() {
|
||||
this.wrapperClientWidth = this.$refs.wrapper.clientWidth
|
||||
var width = Math.max(0, this.wrapperClientWidth - this.rowPaddingX * 2)
|
||||
var booksPerRow = Math.floor(width / this.bookWidth)
|
||||
|
||||
var entities = this.entities
|
||||
var groups = []
|
||||
var currentRow = 0
|
||||
var currentGroup = []
|
||||
|
||||
var audiobooksSorted = this.$store.getters['audiobooks/getFilteredAndSorted']()
|
||||
this.currFilterOrderKey = this.filterOrderKey
|
||||
|
||||
for (let i = 0; i < audiobooksSorted.length; i++) {
|
||||
var row = Math.floor(i / this.booksPerRow)
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
var row = Math.floor(i / booksPerRow)
|
||||
if (row > currentRow) {
|
||||
groups.push([...currentGroup])
|
||||
currentRow = row
|
||||
currentGroup = []
|
||||
}
|
||||
currentGroup.push(audiobooksSorted[i])
|
||||
currentGroup.push(entities[i])
|
||||
}
|
||||
if (currentGroup.length) {
|
||||
groups.push([...currentGroup])
|
||||
}
|
||||
this.groupedBooks = groups
|
||||
this.shelves = groups
|
||||
},
|
||||
calculateBookshelf() {
|
||||
this.width = this.$refs.wrapper.clientWidth
|
||||
this.width = Math.max(0, this.width - this.rowPaddingX * 2)
|
||||
var booksPerRow = Math.floor(this.width / this.bookWidth)
|
||||
this.booksPerRow = booksPerRow
|
||||
},
|
||||
getAudiobookCard(id) {
|
||||
if (this.$refs[`audiobookCard-${id}`] && this.$refs[`audiobookCard-${id}`].length) {
|
||||
return this.$refs[`audiobookCard-${id}`][0]
|
||||
}
|
||||
return null
|
||||
},
|
||||
init() {
|
||||
async init() {
|
||||
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
||||
|
||||
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
var sizeIndex = this.availableSizes.findIndex((s) => s === bookshelfCoverSize)
|
||||
if (!isNaN(sizeIndex)) this.selectedSizeIndex = sizeIndex
|
||||
this.calculateBookshelf()
|
||||
|
||||
var isLoading = await this.$store.dispatch('audiobooks/load')
|
||||
if (!isLoading) {
|
||||
this.setBookshelfEntities()
|
||||
}
|
||||
},
|
||||
resize() {
|
||||
this.$nextTick(() => {
|
||||
this.calculateBookshelf()
|
||||
this.setGroupedBooks()
|
||||
})
|
||||
this.$nextTick(this.setBookshelfEntities)
|
||||
},
|
||||
audiobooksUpdated() {
|
||||
console.log('[AudioBookshelf] Audiobooks Updated')
|
||||
this.setGroupedBooks()
|
||||
this.setBookshelfEntities()
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
if (this.currFilterOrderKey !== this.filterOrderKey) {
|
||||
this.setGroupedBooks()
|
||||
this.setBookshelfEntities()
|
||||
}
|
||||
if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) {
|
||||
var index = this.availableSizes.indexOf(settings.bookshelfCoverSize)
|
||||
@@ -176,18 +217,24 @@ export default {
|
||||
this.$root.socket.emit('scan')
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
if (this.$refs.wrapper) {
|
||||
if (this.wrapperClientWidth !== this.$refs.wrapper.clientWidth) {
|
||||
this.$nextTick(this.setBookshelfEntities)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.resize)
|
||||
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
|
||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
||||
|
||||
this.$store.dispatch('audiobooks/load')
|
||||
this.init()
|
||||
window.addEventListener('resize', this.resize)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.resize)
|
||||
this.$store.commit('audiobooks/removeListener', 'bookshelf')
|
||||
this.$store.commit('user/removeSettingsListener', 'bookshelf')
|
||||
window.removeEventListener('resize', this.resize)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
158
client/components/app/BookShelfCategorized.vue
Normal file
158
client/components/app/BookShelfCategorized.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
|
||||
<!-- Cover size widget -->
|
||||
<div class="fixed bottom-2 right-4 z-40">
|
||||
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
|
||||
<span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize">remove</span>
|
||||
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
|
||||
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
|
||||
<div class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else id="bookshelf" class="w-full flex flex-col items-center">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
|
||||
selectedSizeIndex: 3,
|
||||
rowPaddingX: 40,
|
||||
keywordFilterTimeout: null,
|
||||
scannerParseSubtitle: false,
|
||||
wrapperClientWidth: 0,
|
||||
overflowingShelvesRight: {},
|
||||
overflowingShelvesLeft: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
||||
},
|
||||
audiobooks() {
|
||||
return this.$store.state.audiobooks.audiobooks
|
||||
},
|
||||
bookCoverWidth() {
|
||||
return this.availableSizes[this.selectedSizeIndex]
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.bookCoverWidth / 120
|
||||
},
|
||||
signSizeMultiplier() {
|
||||
return (1 - this.sizeMultiplier) / 2 + this.sizeMultiplier
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
},
|
||||
bookWidth() {
|
||||
return this.bookCoverWidth + this.paddingX * 2
|
||||
},
|
||||
mostRecentPlayed() {
|
||||
var audiobooks = this.audiobooks.filter((ab) => this.userAudiobooks[ab.id] && this.userAudiobooks[ab.id].lastUpdate > 0 && this.userAudiobooks[ab.id].progress > 0 && !this.userAudiobooks[ab.id].isRead).map((ab) => ({ ...ab }))
|
||||
audiobooks.sort((a, b) => {
|
||||
return this.userAudiobooks[b.id].lastUpdate - this.userAudiobooks[a.id].lastUpdate
|
||||
})
|
||||
return audiobooks.slice(0, 10)
|
||||
},
|
||||
mostRecentAdded() {
|
||||
var audiobooks = this.audiobooks.map((ab) => ({ ...ab })).sort((a, b) => b.addedAt - a.addedAt)
|
||||
return audiobooks.slice(0, 10)
|
||||
},
|
||||
seriesGroups() {
|
||||
return this.$store.getters['audiobooks/getSeriesGroups']()
|
||||
},
|
||||
recentlyUpdatedSeries() {
|
||||
var mostRecentTime = 0
|
||||
var mostRecentSeries = null
|
||||
this.seriesGroups.forEach((series) => {
|
||||
if ((series.books.length && mostRecentSeries === null) || series.lastUpdate > mostRecentTime) {
|
||||
mostRecentTime = series.lastUpdate
|
||||
mostRecentSeries = series
|
||||
}
|
||||
})
|
||||
if (!mostRecentSeries) return null
|
||||
return mostRecentSeries.books
|
||||
},
|
||||
booksRecentlyRead() {
|
||||
var audiobooks = this.audiobooks.filter((ab) => this.userAudiobooks[ab.id] && this.userAudiobooks[ab.id].isRead).map((ab) => ({ ...ab }))
|
||||
audiobooks.sort((a, b) => {
|
||||
return this.userAudiobooks[b.id].finishedAt - this.userAudiobooks[a.id].finishedAt
|
||||
})
|
||||
return audiobooks.slice(0, 10)
|
||||
},
|
||||
shelves() {
|
||||
var shelves = [
|
||||
{ books: this.mostRecentPlayed, label: 'Continue Reading' },
|
||||
{ books: this.mostRecentAdded, label: 'Recently Added' }
|
||||
]
|
||||
if (this.recentlyUpdatedSeries) {
|
||||
shelves.push({ books: this.recentlyUpdatedSeries, label: 'Newest Series' })
|
||||
}
|
||||
if (this.booksRecentlyRead.length) {
|
||||
shelves.push({ books: this.booksRecentlyRead, label: 'Read Again' })
|
||||
}
|
||||
return shelves
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
increaseSize() {
|
||||
this.selectedSizeIndex = Math.min(this.availableSizes.length - 1, this.selectedSizeIndex + 1)
|
||||
this.resize()
|
||||
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
|
||||
},
|
||||
decreaseSize() {
|
||||
this.selectedSizeIndex = Math.max(0, this.selectedSizeIndex - 1)
|
||||
this.resize()
|
||||
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
|
||||
},
|
||||
async init() {
|
||||
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
|
||||
|
||||
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
var sizeIndex = this.availableSizes.findIndex((s) => s === bookshelfCoverSize)
|
||||
if (!isNaN(sizeIndex)) this.selectedSizeIndex = sizeIndex
|
||||
|
||||
await this.$store.dispatch('audiobooks/load')
|
||||
},
|
||||
resize() {},
|
||||
audiobooksUpdated() {},
|
||||
settingsUpdated(settings) {
|
||||
if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) {
|
||||
var index = this.availableSizes.indexOf(settings.bookshelfCoverSize)
|
||||
if (index >= 0) {
|
||||
this.selectedSizeIndex = index
|
||||
this.resize()
|
||||
}
|
||||
}
|
||||
},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan')
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.resize)
|
||||
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
|
||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
||||
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.resize)
|
||||
this.$store.commit('audiobooks/removeListener', 'bookshelf')
|
||||
this.$store.commit('user/removeSettingsListener', 'bookshelf')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
139
client/components/app/BookShelfRow.vue
Normal file
139
client/components/app/BookShelfRow.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div ref="shelf" class="w-full max-w-full bookshelfRowCategorized relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: 2.5 * sizeMultiplier + 'rem' }" @scroll="scrolled">
|
||||
<div class="w-full h-full" :style="{ marginTop: sizeMultiplier + 'rem' }">
|
||||
<div class="flex items-center -mb-2">
|
||||
<template v-for="entity in shelf.books">
|
||||
<cards-book-card :key="entity.id" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" @hook:updated="updatedBookCard" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-8 w-36 rounded-md" style="height: 22px">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
|
||||
<p class="transform text-sm">{{ shelf.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bookshelfDividerCategorized h-6 w-full absolute bottom-0 left-0 right-0 z-20"></div>
|
||||
|
||||
<div v-show="canScrollLeft && !isScrolling" class="absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left flex items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollLeft">
|
||||
<span class="material-icons text-8xl text-white">chevron_left</span>
|
||||
</div>
|
||||
<div v-show="canScrollRight && !isScrolling" class="absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right flex items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight">
|
||||
<span class="material-icons text-8xl text-white">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
index: Number,
|
||||
shelf: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
sizeMultiplier: Number,
|
||||
bookCoverWidth: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canScrollRight: false,
|
||||
canScrollLeft: false,
|
||||
isScrolling: false,
|
||||
scrollTimer: null,
|
||||
updateTimer: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
scrolled() {
|
||||
clearTimeout(this.scrollTimer)
|
||||
this.scrollTimer = setTimeout(() => {
|
||||
this.isScrolling = false
|
||||
this.$nextTick(this.checkCanScroll)
|
||||
}, 50)
|
||||
},
|
||||
scrollLeft() {
|
||||
if (!this.$refs.shelf) {
|
||||
console.error('No Shelf', this.index)
|
||||
return
|
||||
}
|
||||
this.isScrolling = true
|
||||
this.$refs.shelf.scrollLeft = 0
|
||||
},
|
||||
scrollRight() {
|
||||
if (!this.$refs.shelf) {
|
||||
console.error('No Shelf', this.index)
|
||||
return
|
||||
}
|
||||
this.isScrolling = true
|
||||
this.$refs.shelf.scrollLeft = 999
|
||||
},
|
||||
updatedBookCard() {
|
||||
clearTimeout(this.updateTimer)
|
||||
this.updateTimer = setTimeout(() => {
|
||||
this.$nextTick(this.checkCanScroll)
|
||||
}, 100)
|
||||
},
|
||||
checkCanScroll() {
|
||||
if (!this.$refs.shelf) {
|
||||
console.error('No Shelf', this.index)
|
||||
return
|
||||
}
|
||||
var clientWidth = this.$refs.shelf.clientWidth
|
||||
var scrollWidth = this.$refs.shelf.scrollWidth
|
||||
var scrollLeft = this.$refs.shelf.scrollLeft
|
||||
if (scrollWidth > clientWidth) {
|
||||
this.canScrollRight = scrollLeft === 0
|
||||
this.canScrollLeft = scrollLeft > 0
|
||||
} else {
|
||||
this.canScrollRight = false
|
||||
this.canScrollLeft = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bookshelfRowCategorized {
|
||||
scroll-behavior: smooth;
|
||||
width: calc(100vw - 80px);
|
||||
background-image: url(/wood_panels.jpg);
|
||||
}
|
||||
.bookshelfDividerCategorized {
|
||||
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(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;
|
||||
}
|
||||
.categoryPlacard {
|
||||
background-image: url(https://image.freepik.com/free-photo/brown-wooden-textured-flooring-background_53876-128537.jpg);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.shinyBlack {
|
||||
background-color: #2d3436;
|
||||
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
|
||||
border-color: rgba(255, 244, 182, 0.6);
|
||||
border-style: solid;
|
||||
color: #fce3a6;
|
||||
}
|
||||
.book-shelf-arrow-right {
|
||||
height: calc(100% - 24px);
|
||||
background: rgb(48, 48, 48);
|
||||
background: linear-gradient(90deg, rgba(48, 48, 48, 0) 0%, rgba(25, 25, 25, 0.25) 8%, rgba(17, 17, 17, 0.4) 28%, rgba(17, 17, 17, 0.6) 71%, rgba(10, 10, 10, 0.6) 86%, rgba(0, 0, 0, 0.7) 100%);
|
||||
}
|
||||
.book-shelf-arrow-left {
|
||||
height: calc(100% - 24px);
|
||||
background: rgb(48, 48, 48);
|
||||
background: linear-gradient(-90deg, rgba(48, 48, 48, 0) 0%, rgba(25, 25, 25, 0.25) 8%, rgba(17, 17, 17, 0.4) 28%, rgba(17, 17, 17, 0.6) 71%, rgba(10, 10, 10, 0.6) 86%, rgba(0, 0, 0, 0.7) 100%);
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +1,48 @@
|
||||
<template>
|
||||
<div class="w-full h-10 relative">
|
||||
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8">
|
||||
<p class="font-book">{{ numShowing }} Audiobooks</p>
|
||||
<div class="flex-grow" />
|
||||
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-40 flex items-center px-8">
|
||||
<template v-if="page !== 'search' && !isHome">
|
||||
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
|
||||
<div v-else class="flex items-center">
|
||||
<div @click="seriesBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-white">west</span>
|
||||
</div>
|
||||
<!-- <span class="material-icons text-2xl cursor-pointer" @click="seriesBackArrow">west</span> -->
|
||||
<p class="pl-4 font-book text-lg">
|
||||
{{ selectedSeries }} <span class="ml-3 font-mono text-lg bg-black bg-opacity-30 rounded-lg px-1 py-0.5">{{ numShowing }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-text-input v-model="_keywordFilter" placeholder="Keyword Filter" :padding-y="1.5" class="text-xs w-40" />
|
||||
|
||||
<controls-filter-select v-model="settings.filterBy" class="w-48 h-7.5 ml-4" @change="updateFilter" />
|
||||
|
||||
<controls-order-select v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5 ml-4" @change="updateOrder" />
|
||||
<ui-text-input v-show="!selectedSeries" v-model="_keywordFilter" placeholder="Keyword Filter" :padding-y="1.5" class="text-xs w-40" />
|
||||
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-48 h-7.5 ml-4" @change="updateFilter" />
|
||||
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5 ml-4" @change="updateOrder" />
|
||||
</template>
|
||||
<template v-else-if="!isHome">
|
||||
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-white">west</span>
|
||||
</div>
|
||||
<!-- <p class="font-book pl-4">{{ numShowing }} showing</p> -->
|
||||
<div class="flex-grow" />
|
||||
<p>Search results for "{{ searchQuery }}"</p>
|
||||
<div class="flex-grow" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
page: String,
|
||||
isHome: Boolean,
|
||||
selectedSeries: String,
|
||||
searchResults: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
searchQuery: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
settings: {},
|
||||
@@ -22,8 +50,29 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showSortFilters() {
|
||||
return this.page === ''
|
||||
},
|
||||
numShowing() {
|
||||
return this.$store.getters['audiobooks/getFiltered']().length
|
||||
if (this.page === '') {
|
||||
return this.$store.getters['audiobooks/getFiltered']().length
|
||||
} else if (this.page === 'search') {
|
||||
return (this.searchResults || []).length
|
||||
} else {
|
||||
var groups = this.$store.getters['audiobooks/getSeriesGroups']()
|
||||
if (this.selectedSeries) {
|
||||
var group = groups.find((g) => g.name === this.selectedSeries)
|
||||
if (group) return group.books.length
|
||||
return 0
|
||||
}
|
||||
return groups.length
|
||||
}
|
||||
},
|
||||
entityName() {
|
||||
if (!this.page) return 'Audiobooks'
|
||||
if (this.page === 'series') return 'Series'
|
||||
if (this.page === 'collections') return 'Collections'
|
||||
return ''
|
||||
},
|
||||
_keywordFilter: {
|
||||
get() {
|
||||
@@ -35,6 +84,13 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
searchBackArrow() {
|
||||
this.$router.replace('/library')
|
||||
},
|
||||
seriesBackArrow() {
|
||||
this.$router.replace('/library/series')
|
||||
this.$emit('update:selectedSeries', null)
|
||||
},
|
||||
updateOrder() {
|
||||
this.saveSettings()
|
||||
},
|
||||
@@ -42,7 +98,6 @@ export default {
|
||||
this.saveSettings()
|
||||
},
|
||||
saveSettings() {
|
||||
this.$store.commit('user/setSettings', this.settings) // Immediate update
|
||||
this.$store.dispatch('user/updateUserSettings', this.settings)
|
||||
},
|
||||
init() {
|
||||
@@ -67,6 +122,6 @@ export default {
|
||||
|
||||
<style>
|
||||
#toolbar {
|
||||
box-shadow: 0px 8px 8px #111111aa;
|
||||
box-shadow: 0px 8px 6px #111111aa;
|
||||
}
|
||||
</style>
|
||||
85
client/components/app/SideRail.vue
Normal file
85
client/components/app/SideRail.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="w-20 bg-bg h-full relative box-shadow-side z-30" style="min-width: 80px">
|
||||
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||
<nuxt-link to="/" 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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 1rem">Home</p>
|
||||
|
||||
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/library" 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="paramId === '' && !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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 1rem">Library</p>
|
||||
|
||||
<div v-show="paramId === '' && !homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/library/series" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'series' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 1rem">Series</p>
|
||||
|
||||
<div v-show="paramId === 'series'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<!-- <nuxt-link to="/library/collections" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 0.8rem">Collections</p>
|
||||
|
||||
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link> -->
|
||||
|
||||
<!-- <nuxt-link to="/library/tags" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'tags' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 0.8rem">Tags</p>
|
||||
|
||||
<div v-show="paramId === 'tags'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link> -->
|
||||
|
||||
<!-- <nuxt-link to="/library/authors" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'authors' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5" style="font-size: 0.8rem">Authors</p>
|
||||
|
||||
<div v-show="paramId === 'authors'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
paramId() {
|
||||
return this.$route.params ? this.$route.params.id || '' : ''
|
||||
},
|
||||
selectedClassName() {
|
||||
return ''
|
||||
},
|
||||
homePage() {
|
||||
return this.$route.name === 'index'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary p-4">
|
||||
<div class="absolute -top-16 left-4">
|
||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute -top-16 left-4 cursor-pointer">
|
||||
<cards-book-cover :audiobook="streamAudiobook" :width="88" />
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<div class="flex items-center pl-24">
|
||||
<div>
|
||||
<h1>
|
||||
{{ title }} <span v-if="stream" class="text-xs text-gray-400">({{ stream.id }})</span>
|
||||
</h1>
|
||||
<p class="text-gray-400 text-sm">by {{ author }}</p>
|
||||
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer">
|
||||
{{ title }} <span v-if="stream && $isDev" class="text-xs text-gray-400">({{ stream.id }})</span>
|
||||
</nuxt-link>
|
||||
<p class="text-gray-400 text-sm hover:underline cursor-pointer" @click="filterByAuthor">by {{ author }}</p>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
|
||||
@@ -66,6 +66,15 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
filterByAuthor() {
|
||||
if (this.$route.name !== 'index') {
|
||||
this.$router.push('/library')
|
||||
}
|
||||
var settingsUpdate = {
|
||||
filterBy: `authors.${this.$encode(this.author)}`
|
||||
}
|
||||
this.$store.dispatch('user/updateUserSettings', settingsUpdate)
|
||||
},
|
||||
audioPlayerMounted() {
|
||||
this.audioPlayerReady = true
|
||||
if (this.stream) {
|
||||
|
||||
254
client/components/cards/Book3d.vue
Normal file
254
client/components/cards/Book3d.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative pointer-events-none" :style="{ width: standardWidth * 0.8 * 1.1 * scale + 'px', height: standardHeight * 1.1 * scale + 'px', marginBottom: 20 + 'px', marginTop: 15 + 'px' }">
|
||||
<div ref="card" class="wrap absolute origin-center transform duration-200" :style="{ transform: `scale(${scale * scaleMultiplier}) translateY(${hover2 ? '-40%' : '-50%'})` }">
|
||||
<div class="perspective">
|
||||
<div class="book-wrap transform duration-100 pointer-events-auto" :class="hover2 ? 'z-80' : 'rotate'" @mouseover="hover = true" @mouseout="hover = false">
|
||||
<div class="book book-1 box-shadow-book3d" ref="front"></div>
|
||||
<div class="title book-1 pointer-events-none" ref="left"></div>
|
||||
<div class="bottom book-1 pointer-events-none" ref="bottom"></div>
|
||||
<div class="book-back book-1 pointer-events-none">
|
||||
<div class="text pointer-events-none">
|
||||
<h3 class="mb-4">Book Back</h3>
|
||||
<p>
|
||||
<span>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Sunt earum doloremque aliquam culpa dolor nostrum consequatur quas dicta? Molestias repellendus minima pariatur libero vel, reiciendis optio magnam rerum, labore corporis.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
src: String,
|
||||
width: {
|
||||
type: Number,
|
||||
default: 200
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hover: false,
|
||||
hover2: false,
|
||||
standardWidth: 200,
|
||||
standardHeight: 320,
|
||||
isAttached: true,
|
||||
pageX: 0,
|
||||
pageY: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
src(newVal) {
|
||||
this.setCover()
|
||||
},
|
||||
width(newVal) {
|
||||
this.init()
|
||||
},
|
||||
hover(newVal) {
|
||||
if (newVal) {
|
||||
this.unattach()
|
||||
} else {
|
||||
this.attach()
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.hover2 = newVal
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
scaleMultiplier() {
|
||||
return this.hover2 ? 1.25 : 1
|
||||
},
|
||||
scale() {
|
||||
var scale = this.width / this.standardWidth
|
||||
return scale
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
unattach() {
|
||||
if (this.$refs.card && this.isAttached) {
|
||||
var bookshelf = document.getElementById('bookshelf')
|
||||
if (bookshelf) {
|
||||
var pos = this.$refs.wrapper.getBoundingClientRect()
|
||||
|
||||
this.pageX = pos.x
|
||||
this.pageY = pos.y
|
||||
document.body.appendChild(this.$refs.card)
|
||||
this.$refs.card.style.left = this.pageX + 'px'
|
||||
this.$refs.card.style.top = this.pageY + 'px'
|
||||
this.$refs.card.style.zIndex = 50
|
||||
this.isAttached = false
|
||||
} else if (bookshelf) {
|
||||
console.log(this.pageX, this.pageY)
|
||||
this.isAttached = false
|
||||
}
|
||||
}
|
||||
},
|
||||
attach() {
|
||||
if (this.$refs.card && !this.isAttached) {
|
||||
if (this.$refs.wrapper) {
|
||||
this.isAttached = true
|
||||
|
||||
this.$refs.wrapper.appendChild(this.$refs.card)
|
||||
this.$refs.card.style.left = '0px'
|
||||
this.$refs.card.style.top = '0px'
|
||||
}
|
||||
} else {
|
||||
console.log('Is attached already', this.isAttached)
|
||||
}
|
||||
},
|
||||
init() {
|
||||
var standardWidth = this.standardWidth
|
||||
document.documentElement.style.setProperty('--book-w', standardWidth + 'px')
|
||||
document.documentElement.style.setProperty('--book-wx', standardWidth + 1 + 'px')
|
||||
document.documentElement.style.setProperty('--book-h', standardWidth * 1.6 + 'px')
|
||||
document.documentElement.style.setProperty('--book-d', 40 + 'px')
|
||||
},
|
||||
setElBg(el) {
|
||||
el.style.backgroundImage = `url("${this.src}")`
|
||||
el.style.backgroundSize = 'cover'
|
||||
el.style.backgroundPosition = 'center center'
|
||||
el.style.backgroundRepeat = 'no-repeat'
|
||||
},
|
||||
setCover() {
|
||||
if (this.$refs.front) {
|
||||
this.setElBg(this.$refs.front)
|
||||
}
|
||||
if (this.$refs.bottom) {
|
||||
this.setElBg(this.$refs.bottom)
|
||||
this.$refs.bottom.style.backgroundSize = '2000%'
|
||||
this.$refs.bottom.style.filter = 'blur(1px)'
|
||||
}
|
||||
if (this.$refs.left) {
|
||||
this.setElBg(this.$refs.left)
|
||||
this.$refs.left.style.backgroundSize = '2000%'
|
||||
this.$refs.left.style.filter = 'blur(1px)'
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setCover()
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* :root {
|
||||
--book-w: 200px;
|
||||
--book-h: 320px;
|
||||
--book-d: 30px;
|
||||
--book-wx: 201px;
|
||||
} */
|
||||
/*
|
||||
.wrap {
|
||||
width: calc(1.1 * var(--book-w));
|
||||
height: calc(1.1 * var(--book-h));
|
||||
margin: 0 auto;
|
||||
}
|
||||
.perspective {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
perspective: 600px;
|
||||
transform-style: preserve-3d;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.book-wrap {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transform-style: preserve-3d;
|
||||
transition: 'all ease-out 0.6s';
|
||||
}
|
||||
|
||||
.book {
|
||||
width: var(--book-w);
|
||||
height: var(--book-h);
|
||||
background: url(https://covers.openlibrary.org/b/id/8303020-L.jpg) no-repeat center center;
|
||||
background-size: cover;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.title {
|
||||
content: '';
|
||||
height: var(--book-h);
|
||||
width: var(--book-d);
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: calc(var(--book-wx) * -1);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
background: #444;
|
||||
transform: rotateY(-80deg) translateX(-14px);
|
||||
|
||||
background: url(https://covers.openlibrary.org/b/id/8303020-L.jpg) no-repeat center center;
|
||||
background-size: 5000%;
|
||||
filter: blur(1px);
|
||||
}
|
||||
|
||||
.bottom {
|
||||
content: '';
|
||||
height: var(--book-d);
|
||||
width: var(--book-w);
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: var(--book-h);
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
background: #444;
|
||||
transform: rotateY(0deg) rotateX(90deg) translateY(-15px) translateX(-2.5px) skewX(10deg);
|
||||
|
||||
background: url(https://covers.openlibrary.org/b/id/8303020-L.jpg) no-repeat center center;
|
||||
background-size: 5000%;
|
||||
filter: blur(1px);
|
||||
}
|
||||
|
||||
.book-back {
|
||||
width: var(--book-w);
|
||||
height: var(--book-h);
|
||||
background-color: #444;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
transform: rotate(180deg) translateZ(-30px) translateX(5px);
|
||||
}
|
||||
.book-back .text {
|
||||
transform: rotateX(180deg);
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
padding: 20px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
.book-back .text h3 {
|
||||
color: #fff;
|
||||
}
|
||||
.book-back .text span {
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.book-wrap.rotate {
|
||||
transform: rotateY(30deg) rotateX(0deg);
|
||||
}
|
||||
.book-wrap.flip {
|
||||
transform: rotateY(180deg);
|
||||
} */
|
||||
</style>
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- New Book Flag -->
|
||||
<div v-show="isNew" class="absolute top-4 left-0 w-4 h-10 pr-2 bg-darkgreen box-shadow-xl">
|
||||
<div v-show="isNew" class="absolute top-4 left-0 w-4 h-10 pr-2 bg-darkgreen box-shadow-xl z-20">
|
||||
<div class="absolute top-0 left-0 w-full h-full transform -rotate-90 flex items-center justify-center">
|
||||
<p class="text-center text-sm">New</p>
|
||||
</div>
|
||||
<div class="absolute -bottom-4 left-0 triangle-right" />
|
||||
</div>
|
||||
|
||||
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard">
|
||||
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @click.stop>
|
||||
<nuxt-link :to="isSelectionMode ? '' : `/audiobook/${audiobookId}`" class="cursor-pointer">
|
||||
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }">
|
||||
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }" @click="clickCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" />
|
||||
|
||||
<div v-show="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black rounded" :class="overlayWrapperClasslist">
|
||||
<div v-show="!isSelectionMode" class="h-full flex items-center justify-center">
|
||||
<div v-show="!isSelectionMode && !isMissing" class="h-full flex items-center justify-center">
|
||||
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
|
||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||
</div>
|
||||
@@ -24,12 +24,20 @@
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||
</div>
|
||||
|
||||
<div v-if="userCanUpdate || userCanDelete" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick">
|
||||
<div class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick">
|
||||
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
|
||||
</div>
|
||||
|
||||
<!-- <div v-if="true && hasEbook" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">EBook</p>
|
||||
</div> -->
|
||||
|
||||
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0">
|
||||
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||
@@ -56,7 +64,8 @@ export default {
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
}
|
||||
},
|
||||
showVolumeNumber: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -65,7 +74,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
isNew() {
|
||||
return this.tags.includes('new')
|
||||
return this.tags.includes('New')
|
||||
},
|
||||
tags() {
|
||||
return this.audiobook.tags || []
|
||||
@@ -73,8 +82,12 @@ export default {
|
||||
audiobookId() {
|
||||
return this.audiobook.id
|
||||
},
|
||||
hasEbook() {
|
||||
return this.audiobook.numEbooks
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumAudiobooksSelected']
|
||||
// return this.$store.getters['getNumAudiobooksSelected']
|
||||
return !!this.selectedAudiobooks.length
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
return this.$store.state.selectedAudiobooks
|
||||
@@ -132,7 +145,10 @@ export default {
|
||||
return this.userProgress ? !!this.userProgress.isRead : false
|
||||
},
|
||||
showError() {
|
||||
return this.hasMissingParts || this.hasInvalidParts
|
||||
return this.hasMissingParts || this.hasInvalidParts || this.isMissing
|
||||
},
|
||||
isMissing() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
hasMissingParts() {
|
||||
return this.audiobook.hasMissingParts
|
||||
@@ -141,6 +157,7 @@ export default {
|
||||
return this.audiobook.hasInvalidParts
|
||||
},
|
||||
errorText() {
|
||||
if (this.isMissing) return 'Audiobook directory is missing!'
|
||||
var txt = ''
|
||||
if (this.hasMissingParts) {
|
||||
txt = `${this.hasMissingParts} missing parts.`
|
||||
@@ -190,7 +207,6 @@ export default {
|
||||
this.selectBtnClick()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
|
||||
<div class="w-full h-full z-0" ref="coverBg" />
|
||||
</div>
|
||||
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
|
||||
<img ref="cover" :src="fullCoverUrl" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
|
||||
</div>
|
||||
|
||||
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
@@ -53,6 +53,9 @@ export default {
|
||||
book() {
|
||||
return this.audiobook.book || {}
|
||||
},
|
||||
bookLastUpdate() {
|
||||
return this.book.lastUpdate || Date.now()
|
||||
},
|
||||
title() {
|
||||
return this.book.title || 'No Title'
|
||||
},
|
||||
@@ -76,15 +79,7 @@ export default {
|
||||
return '/book_placeholder.jpg'
|
||||
},
|
||||
fullCoverUrl() {
|
||||
if (!this.cover || this.cover === this.placeholderUrl) return ''
|
||||
if (this.cover.startsWith('http:') || this.cover.startsWith('https:')) return this.cover
|
||||
try {
|
||||
var url = new URL(this.cover, document.baseURI)
|
||||
return url.href
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return ''
|
||||
}
|
||||
return this.$store.getters['audiobooks/getBookCoverSrc'](this.book, this.placeholderUrl)
|
||||
},
|
||||
cover() {
|
||||
return this.book.cover || this.placeholderUrl
|
||||
@@ -106,6 +101,9 @@ export default {
|
||||
},
|
||||
authorBottom() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
98
client/components/cards/GroupCard.vue
Normal file
98
client/components/cards/GroupCard.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard">
|
||||
<nuxt-link :to="`/library/series?${groupType}=${groupEncode}`" class="cursor-pointer">
|
||||
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: height + 'px', width: height + 'px' }">
|
||||
<cards-group-cover ref="groupcover" :name="groupName" :book-items="bookItems" :width="height" :height="height" />
|
||||
|
||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<p class="font-book" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ groupName }}</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-2 right-2 w-7 h-7 rounded-lg bg-black bg-opacity-90 text-gray-300 box-shadow-book flex items-center justify-center border border-white border-opacity-25 pointer-events-none">
|
||||
<p class="font-book text-xl">{{ bookItems.length }}</p>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full h-1 flex flex-nowrap">
|
||||
<div v-for="userProgress in userProgressItems" :key="userProgress.audiobookId" class="h-full w-full" :class="userProgress.isRead ? 'bg-success' : userProgress.progress > 0 ? 'bg-yellow-400' : ''" />
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
group: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
width(newVal) {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.groupcover) {
|
||||
this.$refs.groupcover.init()
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
_group() {
|
||||
return this.group || {}
|
||||
},
|
||||
height() {
|
||||
return this.width * 1.6
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.width / 120
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
},
|
||||
bookItems() {
|
||||
return this._group.books || []
|
||||
},
|
||||
userAudiobooks() {
|
||||
return Object.values(this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {})
|
||||
},
|
||||
userProgressItems() {
|
||||
return this.bookItems.map((item) => {
|
||||
var userAudiobook = this.userAudiobooks.find((ab) => ab.audiobookId === item.id)
|
||||
return userAudiobook || {}
|
||||
})
|
||||
},
|
||||
groupName() {
|
||||
return this._group.name || 'No Name'
|
||||
},
|
||||
groupType() {
|
||||
return this._group.type
|
||||
},
|
||||
groupEncode() {
|
||||
return this.$encode(this.groupName)
|
||||
},
|
||||
filter() {
|
||||
return `${this.groupType}.${this.$encode(this.groupName)}`
|
||||
},
|
||||
hasValidCovers() {
|
||||
var validCovers = this.bookItems.map((bookItem) => bookItem.book.cover)
|
||||
return !!validCovers.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickCard() {
|
||||
this.$emit('click', this.group)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
139
client/components/cards/GroupCover.vue
Normal file
139
client/components/cards/GroupCover.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div ref="wrapper" :style="{ height: height + 'px', width: width + 'px' }" class="relative">
|
||||
<div v-if="noValidCovers" class="absolute top-0 left-0 w-full h-full flex items-center justify-center box-shadow-book" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
name: String,
|
||||
bookItems: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
width: Number,
|
||||
height: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
noValidCovers: false,
|
||||
coverDiv: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
bookItems: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
// ensure wrapper is initialized
|
||||
this.$nextTick(this.init)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sizeMultiplier() {
|
||||
return this.width / 192
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getCoverUrl(book) {
|
||||
return this.$store.getters['audiobooks/getBookCoverSrc'](book, '')
|
||||
},
|
||||
async buildCoverImg(src, bgCoverWidth, offsetLeft, forceCoverBg = false) {
|
||||
var showCoverBg =
|
||||
forceCoverBg ||
|
||||
(await new Promise((resolve) => {
|
||||
var image = new Image()
|
||||
|
||||
image.onload = () => {
|
||||
var { naturalWidth, naturalHeight } = image
|
||||
var aspectRatio = naturalHeight / naturalWidth
|
||||
var arDiff = Math.abs(aspectRatio - 1.6)
|
||||
|
||||
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
|
||||
if (arDiff > 0.15) {
|
||||
resolve(true)
|
||||
} else {
|
||||
resolve(false)
|
||||
}
|
||||
}
|
||||
image.onerror = (err) => {
|
||||
console.error(err)
|
||||
resolve(false)
|
||||
}
|
||||
image.src = src
|
||||
}))
|
||||
|
||||
var imgdiv = document.createElement('div')
|
||||
imgdiv.style.height = this.height + 'px'
|
||||
imgdiv.style.width = bgCoverWidth + 'px'
|
||||
imgdiv.style.left = offsetLeft + 'px'
|
||||
imgdiv.className = 'absolute top-0 box-shadow-book'
|
||||
imgdiv.style.boxShadow = '-4px 0px 4px #11111166'
|
||||
// imgdiv.style.transform = 'skew(0deg, 15deg)'
|
||||
|
||||
if (showCoverBg) {
|
||||
var coverbgwrapper = document.createElement('div')
|
||||
coverbgwrapper.className = 'absolute top-0 left-0 w-full h-full bg-primary'
|
||||
|
||||
var coverbg = document.createElement('div')
|
||||
coverbg.className = 'w-full h-full'
|
||||
coverbg.style.backgroundImage = `url("${src}")`
|
||||
coverbg.style.backgroundSize = 'cover'
|
||||
coverbg.style.backgroundPosition = 'center'
|
||||
coverbg.style.opacity = 0.25
|
||||
coverbg.style.filter = 'blur(1px)'
|
||||
|
||||
coverbgwrapper.appendChild(coverbg)
|
||||
imgdiv.appendChild(coverbgwrapper)
|
||||
}
|
||||
|
||||
var img = document.createElement('img')
|
||||
img.src = src
|
||||
img.className = 'absolute top-0 left-0 w-full h-full'
|
||||
img.style.objectFit = showCoverBg ? 'contain' : 'cover'
|
||||
|
||||
imgdiv.appendChild(img)
|
||||
return imgdiv
|
||||
},
|
||||
async init() {
|
||||
if (this.coverDiv) {
|
||||
this.coverDiv.remove()
|
||||
this.coverDiv = null
|
||||
}
|
||||
var validCovers = this.bookItems.map((bookItem) => this.getCoverUrl(bookItem.book)).filter((b) => b !== '')
|
||||
if (!validCovers.length) {
|
||||
this.noValidCovers = true
|
||||
return
|
||||
}
|
||||
this.noValidCovers = false
|
||||
|
||||
var coverWidth = this.width
|
||||
var widthPer = this.width
|
||||
if (validCovers.length > 1) {
|
||||
coverWidth = this.height / 1.6
|
||||
widthPer = (this.width - coverWidth) / (validCovers.length - 1)
|
||||
}
|
||||
|
||||
var outerdiv = document.createElement('div')
|
||||
outerdiv.className = 'w-full h-full relative'
|
||||
|
||||
for (let i = 0; i < validCovers.length; i++) {
|
||||
var offsetLeft = widthPer * i
|
||||
var img = await this.buildCoverImg(validCovers[i], coverWidth, offsetLeft, validCovers.length === 1)
|
||||
outerdiv.appendChild(img)
|
||||
}
|
||||
|
||||
if (this.$refs.wrapper) {
|
||||
this.coverDiv = outerdiv
|
||||
this.$refs.wrapper.appendChild(outerdiv)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
82
client/components/cards/PreviewCover.vue
Normal file
82
client/components/cards/PreviewCover.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
|
||||
<div class="w-full h-full relative">
|
||||
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
|
||||
<div class="w-full h-full z-0" ref="coverBg" />
|
||||
</div>
|
||||
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
|
||||
</div>
|
||||
|
||||
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
|
||||
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||
<p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
src: String,
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
imageFailed: false,
|
||||
showCoverBg: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
cover() {
|
||||
this.imageFailed = false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
cover() {
|
||||
return this.src
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.width / 120
|
||||
},
|
||||
placeholderCoverPadding() {
|
||||
return 0.8 * this.sizeMultiplier
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setCoverBg() {
|
||||
if (this.$refs.coverBg) {
|
||||
this.$refs.coverBg.style.backgroundImage = `url("${this.src}")`
|
||||
this.$refs.coverBg.style.backgroundSize = 'cover'
|
||||
this.$refs.coverBg.style.backgroundPosition = 'center'
|
||||
this.$refs.coverBg.style.opacity = 0.25
|
||||
this.$refs.coverBg.style.filter = 'blur(1px)'
|
||||
}
|
||||
},
|
||||
imageLoaded() {
|
||||
if (this.$refs.cover) {
|
||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||
var aspectRatio = naturalHeight / naturalWidth
|
||||
var arDiff = Math.abs(aspectRatio - 1.6)
|
||||
|
||||
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
|
||||
if (arDiff > 0.15) {
|
||||
this.showCoverBg = true
|
||||
this.$nextTick(this.setCoverBg)
|
||||
} else {
|
||||
this.showCoverBg = false
|
||||
}
|
||||
}
|
||||
},
|
||||
imageError(err) {
|
||||
console.error('ImgError', err)
|
||||
this.imageFailed = true
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -86,6 +86,11 @@ export default {
|
||||
text: 'Authors',
|
||||
value: 'authors',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Progress',
|
||||
value: 'progress',
|
||||
sublist: true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -132,6 +137,9 @@ export default {
|
||||
authors() {
|
||||
return this.$store.getters['audiobooks/getUniqueAuthors']
|
||||
},
|
||||
progress() {
|
||||
return ['Read', 'Unread', 'In Progress']
|
||||
},
|
||||
sublistItems() {
|
||||
return (this[this.sublist] || []).map((item) => {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<template>
|
||||
<div class="w-64 ml-8 relative">
|
||||
<ui-text-input v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||
<form @submit.prevent="submitSearch">
|
||||
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||
</form>
|
||||
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
||||
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||
@@ -18,7 +20,7 @@
|
||||
</li>
|
||||
<template v-else>
|
||||
<template v-for="item in items">
|
||||
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(item)">
|
||||
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickedOption(item)">
|
||||
<template v-if="item.type === 'audiobook'">
|
||||
<cards-audiobook-search-card :audiobook="item.data" />
|
||||
</template>
|
||||
@@ -51,6 +53,19 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitSearch() {
|
||||
if (!this.search) return
|
||||
this.$router.push(`/library/search?query=${this.search}`)
|
||||
|
||||
this.search = null
|
||||
this.items = []
|
||||
this.showMenu = false
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.input) {
|
||||
this.$refs.input.blur()
|
||||
}
|
||||
})
|
||||
},
|
||||
focussed() {
|
||||
this.isFocused = true
|
||||
this.showMenu = true
|
||||
@@ -73,6 +88,10 @@ export default {
|
||||
return []
|
||||
})
|
||||
this.isFetching = false
|
||||
if (!this.showMenu) {
|
||||
return
|
||||
}
|
||||
|
||||
this.items = results.map((res) => {
|
||||
return {
|
||||
id: res.id,
|
||||
|
||||
@@ -74,10 +74,10 @@ export default {
|
||||
this.showMenu = false
|
||||
},
|
||||
leftArrowClick() {
|
||||
this.rateIndex = Math.max(0, this.rateIndex - 4)
|
||||
this.rateIndex = Math.max(0, this.rateIndex - 1)
|
||||
},
|
||||
rightArrowClick() {
|
||||
this.rateIndex = Math.min(this.rates.length - this.numVisible, this.rateIndex + 4)
|
||||
this.rateIndex = Math.min(this.rates.length - this.numVisible, this.rateIndex + 1)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isEditingRoot && newUser.permissions" class="w-full border-t border-b border-black-200 py-2 mt-4">
|
||||
<p class="text-lg mb-2">Permissions</p>
|
||||
<div class="flex items-center my-2 max-w-lg">
|
||||
<div v-if="!isEditingRoot && newUser.permissions" class="w-full border-t border-b border-black-200 py-2 px-3 mt-4">
|
||||
<p class="text-lg mb-2 font-semibold">Permissions</p>
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>Can Download</p>
|
||||
</div>
|
||||
@@ -38,7 +38,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-lg">
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>Can Update</p>
|
||||
</div>
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-lg">
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>Can Delete</p>
|
||||
</div>
|
||||
@@ -55,6 +55,15 @@
|
||||
<ui-toggle-switch v-model="newUser.permissions.delete" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>Can Upload</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.upload" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex pt-4">
|
||||
@@ -179,7 +188,8 @@ export default {
|
||||
this.newUser.permissions = {
|
||||
download: type !== 'guest',
|
||||
update: type === 'admin',
|
||||
delete: type === 'admin'
|
||||
delete: type === 'admin',
|
||||
upload: type === 'admin'
|
||||
}
|
||||
},
|
||||
init() {
|
||||
@@ -201,7 +211,8 @@ export default {
|
||||
permissions: {
|
||||
download: true,
|
||||
update: false,
|
||||
delete: false
|
||||
delete: false,
|
||||
upload: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="500" :height="'unset'">
|
||||
<div class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 500px">
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<template v-for="chap in chapters">
|
||||
<div :key="chap.id" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg bg-opacity-20 rounded-lg relative" @click="clickChapter(chap)">
|
||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-bg bg-opacity-80' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
||||
{{ chap.title }}
|
||||
<span class="flex-grow" />
|
||||
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
|
||||
|
||||
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -19,11 +21,20 @@ export default {
|
||||
chapters: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentChapter: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
watch: {
|
||||
value(newVal) {
|
||||
this.$nextTick(this.scrollToChapter)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
@@ -32,13 +43,28 @@ export default {
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
currentChapterId() {
|
||||
return this.currentChapter ? this.currentChapter.id : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickChapter(chap) {
|
||||
this.$emit('select', chap)
|
||||
},
|
||||
scrollToChapter() {
|
||||
if (!this.currentChapterId) return
|
||||
|
||||
var container = this.$refs.container
|
||||
if (container) {
|
||||
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||
if (currChapterEl) {
|
||||
var offsetTop = currChapterEl.offsetTop
|
||||
var containerHeight = container.clientHeight
|
||||
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -109,6 +109,7 @@ export default {
|
||||
availableTabs() {
|
||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||
return this.tabs.filter((tab) => {
|
||||
if (tab.id === 'download' && this.isMissing) return false
|
||||
if ((tab.id === 'download' || tab.id === 'tracks') && this.userCanDownload) return true
|
||||
if (tab.id !== 'download' && tab.id !== 'tracks' && this.userCanUpdate) return true
|
||||
return false
|
||||
@@ -122,6 +123,9 @@ export default {
|
||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||
return _tab ? _tab.component : ''
|
||||
},
|
||||
isMissing() {
|
||||
return this.selectedAudiobook.isMissing
|
||||
},
|
||||
selectedAudiobook() {
|
||||
return this.$store.state.selectedAudiobook || {}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
|
||||
<div class="flex">
|
||||
<div class="relative">
|
||||
<cards-book-cover :audiobook="audiobook" />
|
||||
@@ -12,12 +12,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow pl-6 pr-2">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center">
|
||||
<div v-if="userCanUpload" class="w-40 pr-2" style="min-width: 160px">
|
||||
<ui-file-input ref="fileInput" @change="fileUploadSelected" />
|
||||
</div>
|
||||
<form @submit.prevent="submitForm" class="flex flex-grow">
|
||||
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
|
||||
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-3 w-24">Update</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
|
||||
<div class="flex items-center justify-center py-2">
|
||||
@@ -63,6 +66,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="previewUpload" class="absolute top-0 left-0 w-full h-full z-10 bg-bg p-8">
|
||||
<p class="text-lg">Preview Cover</p>
|
||||
<span class="absolute top-4 right-4 material-icons text-2xl cursor-pointer" @click="resetCoverPreview">close</span>
|
||||
<div class="flex justify-center py-4">
|
||||
<cards-preview-cover :src="previewUpload" :width="240" />
|
||||
</div>
|
||||
<div class="absolute bottom-0 right-0 flex py-4 px-5">
|
||||
<ui-btn :disabled="processingUpload" class="mx-2" @click="resetCoverPreview">Clear</ui-btn>
|
||||
<ui-btn :loading="processingUpload" color="success" @click="submitCoverUpload">Upload</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -79,12 +94,15 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processingUpload: false,
|
||||
searchTitle: null,
|
||||
searchAuthor: null,
|
||||
imageUrl: null,
|
||||
coversFound: [],
|
||||
hasSearched: false,
|
||||
showLocalCovers: false
|
||||
showLocalCovers: false,
|
||||
previewUpload: null,
|
||||
selectedFile: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -112,6 +130,9 @@ export default {
|
||||
otherFiles() {
|
||||
return this.audiobook ? this.audiobook.otherFiles || [] : []
|
||||
},
|
||||
userCanUpload() {
|
||||
return this.$store.getters['user/getUserCanUpload']
|
||||
},
|
||||
localCovers() {
|
||||
return this.otherFiles
|
||||
.filter((f) => f.filetype === 'image')
|
||||
@@ -123,6 +144,39 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitCoverUpload() {
|
||||
this.processingUpload = true
|
||||
var form = new FormData()
|
||||
form.set('cover', this.selectedFile)
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/audiobook/${this.audiobook.id}/cover`, form)
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
} else {
|
||||
this.$toast.success('Cover Uploaded')
|
||||
this.resetCoverPreview()
|
||||
}
|
||||
this.processingUpload = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Oops, something went wrong...')
|
||||
this.processingUpload = false
|
||||
})
|
||||
},
|
||||
resetCoverPreview() {
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.reset()
|
||||
}
|
||||
this.previewUpload = null
|
||||
this.selectedFile = null
|
||||
},
|
||||
fileUploadSelected(file) {
|
||||
this.previewUpload = URL.createObjectURL(file)
|
||||
this.selectedFile = file
|
||||
},
|
||||
init() {
|
||||
this.showLocalCovers = false
|
||||
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.author)) {
|
||||
|
||||
@@ -55,6 +55,11 @@
|
||||
<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="flex px-4">
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
||||
|
||||
<ui-tooltip text="(Root User Only) Save a NFO metadata file in your audiobooks directory" class="mx-4">
|
||||
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<ui-btn type="submit">Submit</ui-btn>
|
||||
</div>
|
||||
@@ -87,7 +92,8 @@ export default {
|
||||
},
|
||||
newTags: [],
|
||||
resettingProgress: false,
|
||||
isScrollable: false
|
||||
isScrollable: false,
|
||||
savingMetadata: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -107,6 +113,9 @@ export default {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
audiobookId() {
|
||||
return this.audiobook ? this.audiobook.id : null
|
||||
},
|
||||
@@ -127,6 +136,24 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
saveMetadataComplete(result) {
|
||||
this.savingMetadata = false
|
||||
if (result.error) {
|
||||
this.$toast.error(result.error)
|
||||
} else if (result.audiobookId) {
|
||||
var { savedPath } = result
|
||||
if (!savedPath) {
|
||||
this.$toast.error(`Failed to save metadata file (${result.audiobookId})`)
|
||||
} else {
|
||||
this.$toast.success(`Metadata file saved "${result.audiobookTitle}"`)
|
||||
}
|
||||
}
|
||||
},
|
||||
saveMetadata() {
|
||||
this.savingMetadata = true
|
||||
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
|
||||
this.$root.socket.emit('save_metadata', this.audiobookId)
|
||||
},
|
||||
async submitForm() {
|
||||
if (this.isProcessing) {
|
||||
return
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-left">Size</th>
|
||||
<th class="text-left">Duration</th>
|
||||
<th v-if="userCanDownload" class="text-center">Download</th>
|
||||
<th v-if="showDownload" class="text-center">Download</th>
|
||||
</tr>
|
||||
<template v-for="track in tracks">
|
||||
<tr :key="track.index">
|
||||
@@ -27,7 +27,7 @@
|
||||
<td class="font-mono">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="userCanDownload" class="font-mono text-center">
|
||||
<td v-if="showDownload" class="font-mono text-center">
|
||||
<a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -64,6 +64,12 @@ export default {
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
isMissing() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
showDownload() {
|
||||
return this.userCanDownload && !this.isMissing
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
<template>
|
||||
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @click="click">
|
||||
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :class="classList">
|
||||
<slot />
|
||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||
<!-- <span class="material-icons animate-spin">refresh</span> -->
|
||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @click="click">
|
||||
<slot />
|
||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||
<!-- <span class="material-icons animate-spin">refresh</span> -->
|
||||
@@ -13,6 +22,7 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
to: String,
|
||||
color: {
|
||||
type: String,
|
||||
default: 'primary'
|
||||
|
||||
39
client/components/ui/FileInput.vue
Normal file
39
client/components/ui/FileInput.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div>
|
||||
<input ref="fileInput" id="hidden-input" type="file" :accept="inputAccept" class="hidden" @change="inputChanged" />
|
||||
<ui-btn @click="clickUpload" color="primary" type="text">Upload Cover</ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
inputAccept: 'image/*'
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
reset() {
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.value = ''
|
||||
}
|
||||
},
|
||||
clickUpload() {
|
||||
if (this.$refs.fileInput) {
|
||||
this.$refs.fileInput.click()
|
||||
}
|
||||
},
|
||||
inputChanged(e) {
|
||||
if (!e.target || !e.target.files) return
|
||||
var _files = Array.from(e.target.files)
|
||||
if (_files && _files.length) {
|
||||
var file = _files[0]
|
||||
console.log('File', file)
|
||||
this.$emit('change', file)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<button class="icon-btn rounded-md bg-primary border border-gray-600 flex items-center justify-center h-9 w-9 relative" @click="clickBtn">
|
||||
<button class="icon-btn rounded-md border border-gray-600 flex items-center justify-center h-9 w-9 relative" :disabled="disabled" :class="className" @click="clickBtn">
|
||||
<span class="material-icons icon-text">{{ icon }}</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -8,12 +8,22 @@
|
||||
export default {
|
||||
props: {
|
||||
icon: String,
|
||||
disabled: Boolean
|
||||
disabled: Boolean,
|
||||
bgColor: {
|
||||
type: String,
|
||||
default: 'primary'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
className() {
|
||||
var classes = []
|
||||
classes.push(`bg-${this.bgColor}`)
|
||||
return classes.join(' ')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBtn(e) {
|
||||
if (this.disabled) {
|
||||
@@ -29,6 +39,9 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
button.icon-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.icon-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
||||
@@ -96,7 +96,7 @@ export default {
|
||||
}
|
||||
this.isFocused = false
|
||||
if (this.input !== this.textInput) {
|
||||
var val = this.$cleanString(this.textInput) || null
|
||||
var val = this.textInput ? this.textInput.trim() : null
|
||||
this.input = val
|
||||
if (val && !this.items.includes(val)) {
|
||||
this.$emit('newItem', val)
|
||||
@@ -105,7 +105,7 @@ export default {
|
||||
}, 50)
|
||||
},
|
||||
submitForm() {
|
||||
var val = this.$cleanString(this.textInput) || null
|
||||
var val = this.textInput ? this.textInput.trim() : null
|
||||
this.input = val
|
||||
if (val && !this.items.includes(val)) {
|
||||
this.$emit('newItem', val)
|
||||
@@ -113,10 +113,12 @@ export default {
|
||||
this.currentSearch = null
|
||||
},
|
||||
clickedOption(e, item) {
|
||||
var newValue = this.input === item ? null : item
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.input = this.$cleanString(newValue) || null
|
||||
this.input = item
|
||||
|
||||
// this.input = this.textInput ? this.textInput.trim() : null
|
||||
console.log('Clicked option', item)
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -180,7 +180,7 @@ export default {
|
||||
submitForm() {
|
||||
if (!this.textInput) return
|
||||
|
||||
var cleaned = this.textInput.toLowerCase().trim()
|
||||
var cleaned = this.textInput.trim()
|
||||
var matchesItem = this.items.find((i) => {
|
||||
return i === cleaned
|
||||
})
|
||||
|
||||
@@ -7,32 +7,6 @@
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
|
||||
</svg>
|
||||
<!-- <svg v-if="!isRead" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 482.204 482.204" xml:space="preserve" fill="currentColor">
|
||||
<path
|
||||
d="M83.127,344.477c54.602,1.063,101.919,9.228,136.837,23.613c0.596,0.244,1.227,0.366,1.852,0.366
|
||||
c0.95,0,1.895-0.279,2.706-0.822c1.349-0.902,2.158-2.418,2.158-4.041l0.019-261.017c0-1.992-1.215-3.783-3.066-4.519
|
||||
L85.019,42.899c-1.496-0.596-3.193-0.411-4.527,0.494c-1.334,0.906-2.133,2.413-2.133,4.025v292.197
|
||||
C78.359,342.264,80.479,344.425,83.127,344.477z"
|
||||
/>
|
||||
<path
|
||||
d="M480.244,89.256c-1.231-0.917-2.824-1.198-4.297-0.759l-49.025,14.657
|
||||
c-2.06,0.616-3.471,2.51-3.471,4.659v252.151c0,0,0.218,3.978-3.97,3.978c-4.796,0-7.946,0-7.946,0
|
||||
c-39.549,0-113.045,4.105-160.93,31.6l-9.504,5.442l-9.503-5.442c-47.886-27.494-121.381-31.6-160.93-31.6c0,0-8.099,0-10.142,0
|
||||
c-1.891,0-1.775-2.272-1.775-2.271V107.813c0-2.149-1.411-4.043-3.47-4.659L6.256,88.497c-1.473-0.439-3.066-0.158-4.298,0.759
|
||||
S0,91.619,0,93.155v305.069c0,1.372,0.581,2.681,1.597,3.604c1.017,0.921,2.375,1.372,3.741,1.236
|
||||
c14.571-1.429,37.351-3.131,63.124-3.131c56.606,0,102.097,8.266,131.576,23.913c4.331,2.272,29.441,15.803,41.065,15.803
|
||||
c11.624,0,36.733-13.53,41.063-15.803c29.48-15.647,74.971-23.913,131.577-23.913c25.771,0,48.553,1.702,63.123,3.131
|
||||
c1.367,0.136,2.725-0.315,3.742-1.236c1.016-0.923,1.596-2.231,1.596-3.604V93.155C482.203,91.619,481.476,90.173,480.244,89.256z
|
||||
"
|
||||
/>
|
||||
<path
|
||||
d="M257.679,367.634c0.812,0.543,1.757,0.822,2.706,0.822c0.626,0,1.256-0.122,1.853-0.366
|
||||
c34.917-14.386,82.235-22.551,136.837-23.613c2.648-0.052,4.769-2.213,4.769-4.861V47.418c0-1.613-0.799-3.12-2.133-4.025
|
||||
c-1.334-0.904-3.031-1.09-4.528-0.494L258.569,98.057c-1.851,0.736-3.065,2.527-3.065,4.519l0.019,261.017
|
||||
C255.521,365.216,256.331,366.732,257.679,367.634z"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M19 2H6c-1.206 0-3 .799-3 3v14c0 2.201 1.794 3 3 3h15v-2H6.012C5.55 19.988 5 19.806 5 19c0-.101.009-.191.024-.273.112-.576.584-.717.988-.727H21V4a2 2 0 0 0-2-2zm0 9-2-1-2 1V4h4v7z" /></svg> -->
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<input v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none border border-gray-600" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none border border-gray-600" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -53,6 +53,9 @@ export default {
|
||||
},
|
||||
keyup(e) {
|
||||
this.$emit('keyup', e)
|
||||
},
|
||||
blur() {
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="box" class="tooltip-box" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div ref="box" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -14,7 +14,8 @@ export default {
|
||||
direction: {
|
||||
type: String,
|
||||
default: 'right'
|
||||
}
|
||||
},
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -25,38 +26,69 @@ export default {
|
||||
watch: {
|
||||
text() {
|
||||
this.updateText()
|
||||
},
|
||||
disabled(newVal) {
|
||||
if (newVal && this.isShowing) {
|
||||
this.hideTooltip()
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateText() {
|
||||
if (this.tooltip) {
|
||||
this.tooltip.innerHTML = this.text
|
||||
this.setTooltipPosition(this.tooltip)
|
||||
}
|
||||
},
|
||||
getTextWidth() {
|
||||
var styles = {
|
||||
'font-size': '0.75rem'
|
||||
}
|
||||
var size = this.$calculateTextSize(this.text, styles)
|
||||
console.log('Text Size', size.width, size.height)
|
||||
return size.width
|
||||
},
|
||||
createTooltip() {
|
||||
if (!this.$refs.box) return
|
||||
var tooltip = document.createElement('div')
|
||||
tooltip.className = 'absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs'
|
||||
tooltip.style.zIndex = 100
|
||||
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
|
||||
tooltip.innerHTML = this.text
|
||||
|
||||
this.setTooltipPosition(tooltip)
|
||||
|
||||
this.tooltip = tooltip
|
||||
},
|
||||
setTooltipPosition(tooltip) {
|
||||
var boxChow = this.$refs.box.getBoundingClientRect()
|
||||
|
||||
var shouldMount = !tooltip.isConnected
|
||||
// Calculate size of tooltip
|
||||
if (shouldMount) document.body.appendChild(tooltip)
|
||||
var { width, height } = tooltip.getBoundingClientRect()
|
||||
if (shouldMount) tooltip.remove()
|
||||
|
||||
var top = 0
|
||||
var left = 0
|
||||
if (this.direction === 'right') {
|
||||
top = boxChow.top
|
||||
top = boxChow.top - height / 2 + boxChow.height / 2
|
||||
left = boxChow.left + boxChow.width + 4
|
||||
} else if (this.direction === 'bottom') {
|
||||
top = boxChow.top + boxChow.height + 4
|
||||
left = boxChow.left
|
||||
left = boxChow.left - width / 2 + boxChow.width / 2
|
||||
} else if (this.direction === 'top') {
|
||||
top = boxChow.top - 24
|
||||
left = boxChow.left
|
||||
top = boxChow.top - height - 4
|
||||
left = boxChow.left - width / 2 + boxChow.width / 2
|
||||
} else if (this.direction === 'left') {
|
||||
top = boxChow.top - height / 2 + boxChow.height / 2
|
||||
left = boxChow.left - width - 4
|
||||
}
|
||||
var tooltip = document.createElement('div')
|
||||
tooltip.className = 'absolute px-2 bg-black bg-opacity-90 py-1 text-white pointer-events-none text-xs rounded shadow-lg'
|
||||
tooltip.style.top = top + 'px'
|
||||
tooltip.style.left = left + 'px'
|
||||
tooltip.style.zIndex = 100
|
||||
tooltip.innerHTML = this.text
|
||||
this.tooltip = tooltip
|
||||
},
|
||||
showTooltip() {
|
||||
if (this.disabled) return
|
||||
if (!this.tooltip) {
|
||||
this.createTooltip()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
|
||||
<app-appbar />
|
||||
|
||||
<Nuxt />
|
||||
|
||||
<app-stream-container ref="streamContainer" />
|
||||
<modals-edit-modal />
|
||||
<widgets-scan-alert />
|
||||
@@ -24,6 +26,9 @@ export default {
|
||||
if (this.$store.state.selectedAudiobooks) {
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
}
|
||||
if (this.$store.state.audiobooks.keywordFilter) {
|
||||
this.$store.commit('audiobooks/setKeywordFilter', '')
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -84,7 +89,7 @@ export default {
|
||||
audiobookRemoved(audiobook) {
|
||||
if (this.$route.name.startsWith('audiobook')) {
|
||||
if (this.$route.params.id === audiobook.id) {
|
||||
this.$router.replace('/')
|
||||
this.$router.replace('/library')
|
||||
}
|
||||
}
|
||||
this.$store.commit('audiobooks/remove', audiobook)
|
||||
@@ -102,6 +107,7 @@ export default {
|
||||
if (results.added) scanResultMsgs.push(`${results.added} added`)
|
||||
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
|
||||
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
|
||||
if (results.missing) scanResultMsgs.push(`${results.missing} missing`)
|
||||
if (!scanResultMsgs.length) this.$toast.success('Scan Finished\nEverything was up to date')
|
||||
else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n'))
|
||||
}
|
||||
@@ -121,6 +127,21 @@ export default {
|
||||
this.$store.commit('setScanProgress', progress)
|
||||
}
|
||||
},
|
||||
saveMetadataComplete(result) {
|
||||
if (result.error) {
|
||||
this.$toast.error(result.error)
|
||||
} else if (result.audiobookId) {
|
||||
var { savedPath } = result
|
||||
if (!savedPath) {
|
||||
this.$toast.error(`Failed to save metadata file (${result.audiobookId})`)
|
||||
} else {
|
||||
this.$toast.success(`Metadata file saved (${result.audiobookId})`)
|
||||
}
|
||||
} else {
|
||||
var { success, failed } = result
|
||||
this.$toast.success(`Metadata save complete\n${success} Succeeded\n${failed} Failed`)
|
||||
}
|
||||
},
|
||||
userUpdated(user) {
|
||||
if (this.$store.state.user.user.id === user.id) {
|
||||
this.$store.commit('user/setUser', user)
|
||||
@@ -128,19 +149,18 @@ export default {
|
||||
}
|
||||
},
|
||||
downloadToastClick(download) {
|
||||
console.log('Downlaod ready toast click', download)
|
||||
// if (!download || !download.audiobookId) {
|
||||
// return console.error('Invalid download object', download)
|
||||
// }
|
||||
// var audiobook = this.$store.getters['audiobooks/getAudiobook'](download.audiobookId)
|
||||
// if (!audiobook) {
|
||||
// return console.error('Audiobook not found for download', download)
|
||||
// }
|
||||
// this.$store.commit('showEditModalOnTab', { audiobook, tab: 'download' })
|
||||
if (!download || !download.audiobookId) {
|
||||
return console.error('Invalid download object', download)
|
||||
}
|
||||
var audiobook = this.$store.getters['audiobooks/getAudiobook'](download.audiobookId)
|
||||
if (!audiobook) {
|
||||
return console.error('Audiobook not found for download', download)
|
||||
}
|
||||
this.$store.commit('showEditModalOnTab', { audiobook, tab: 'download' })
|
||||
},
|
||||
downloadStarted(download) {
|
||||
download.status = this.$constants.DownloadStatus.PENDING
|
||||
download.toastId = this.$toast(`Preparing download "${download.filename}"`, { timeout: false, draggable: false, closeOnClick: false, onClick: this.downloadToastClick })
|
||||
download.toastId = this.$toast(`Preparing download "${download.filename}"`, { timeout: false, draggable: false, closeOnClick: false, onClick: () => this.downloadToastClick(download) })
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
downloadReady(download) {
|
||||
@@ -149,7 +169,7 @@ export default {
|
||||
|
||||
if (existingDownload && existingDownload.toastId !== undefined) {
|
||||
download.toastId = existingDownload.toastId
|
||||
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" is ready!`, options: { timeout: 5000, type: 'success', onClick: this.downloadToastClick } }, true)
|
||||
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" is ready!`, options: { timeout: 5000, type: 'success', onClick: () => this.downloadToastClick(download) } }, true)
|
||||
} else {
|
||||
this.$toast.success(`Download "${download.filename}" is ready!`)
|
||||
}
|
||||
@@ -163,7 +183,7 @@ export default {
|
||||
|
||||
if (existingDownload && existingDownload.toastId !== undefined) {
|
||||
download.toastId = existingDownload.toastId
|
||||
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" ${failedMsg}`, options: { timeout: 5000, type: 'error', onClick: this.downloadToastClick } }, true)
|
||||
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" ${failedMsg}`, options: { timeout: 5000, type: 'error', onClick: () => this.downloadToastClick(download) } }, true)
|
||||
} else {
|
||||
console.warn('Download failed no existing download', existingDownload)
|
||||
this.$toast.error(`Download "${download.filename}" ${failedMsg}`)
|
||||
@@ -174,7 +194,7 @@ export default {
|
||||
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
|
||||
if (existingDownload && existingDownload.toastId !== undefined) {
|
||||
download.toastId = existingDownload.toastId
|
||||
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" was terminated`, options: { timeout: 5000, type: 'error', onClick: this.downloadToastClick } }, true)
|
||||
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" was terminated`, options: { timeout: 5000, type: 'error', onClick: () => this.downloadToastClick(download) } }, true)
|
||||
} else {
|
||||
console.warn('Download killed no existing download found', existingDownload)
|
||||
this.$toast.error(`Download "${download.filename}" was terminated`)
|
||||
@@ -225,6 +245,7 @@ export default {
|
||||
this.socket.on('scan_start', this.scanStart)
|
||||
this.socket.on('scan_complete', this.scanComplete)
|
||||
this.socket.on('scan_progress', this.scanProgress)
|
||||
// this.socket.on('save_metadata_complete', this.saveMetadataComplete)
|
||||
|
||||
// Download Listeners
|
||||
this.socket.on('download_started', this.downloadStarted)
|
||||
@@ -232,10 +253,40 @@ export default {
|
||||
this.socket.on('download_failed', this.downloadFailed)
|
||||
this.socket.on('download_killed', this.downloadKilled)
|
||||
this.socket.on('download_expired', this.downloadExpired)
|
||||
},
|
||||
showUpdateToast(versionData) {
|
||||
var ignoreVersion = localStorage.getItem('ignoreVersion')
|
||||
var latestVersion = versionData.latestVersion
|
||||
|
||||
if (!ignoreVersion || ignoreVersion !== latestVersion) {
|
||||
this.$toast.info(`Update is available!\nCheck release notes for v${versionData.latestVersion}`, {
|
||||
position: 'top-center',
|
||||
toastClassName: 'cursor-pointer',
|
||||
bodyClassName: 'custom-class-1',
|
||||
timeout: 20000,
|
||||
closeOnClick: false,
|
||||
draggable: false,
|
||||
hideProgressBar: false,
|
||||
onClick: () => {
|
||||
window.open(versionData.githubTagUrl, '_blank')
|
||||
},
|
||||
onClose: () => {
|
||||
localStorage.setItem('ignoreVersion', versionData.latestVersion)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.warn(`Update is available but user chose to dismiss it! v${versionData.latestVersion}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initializeSocket()
|
||||
this.$store
|
||||
.dispatch('checkForUpdate')
|
||||
.then((res) => {
|
||||
if (res && res.hasUpdate) this.showUpdateToast(res)
|
||||
})
|
||||
.catch((err) => console.error(err))
|
||||
|
||||
if (this.$route.query.error) {
|
||||
this.$toast.error(this.$route.query.error)
|
||||
@@ -243,4 +294,10 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.Vue-Toastification__toast-body.custom-class-1 {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
export default function ({ store, redirect, route }) {
|
||||
export default function ({ store, redirect, route, app }) {
|
||||
// If the user is not authenticated
|
||||
if (!store.state.user.user) {
|
||||
if (route.name === 'batch') return redirect('/login')
|
||||
return redirect(`/login?redirect=${route.path}`)
|
||||
if (route.name === 'batch' || route.name === 'index') return redirect('/login')
|
||||
return redirect(`/login?redirect=${route.fullPath}`)
|
||||
}
|
||||
}
|
||||
19
client/middleware/routed.js
Normal file
19
client/middleware/routed.js
Normal file
@@ -0,0 +1,19 @@
|
||||
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 === 'config' || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id')) {
|
||||
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && from.name !== '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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,10 @@ module.exports = {
|
||||
]
|
||||
},
|
||||
|
||||
router: {
|
||||
middleware: ['routed']
|
||||
},
|
||||
|
||||
// Global CSS: https://go.nuxtjs.dev/config-css
|
||||
css: [
|
||||
'@/assets/app.css'
|
||||
@@ -71,7 +75,8 @@ module.exports = {
|
||||
|
||||
proxy: {
|
||||
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
|
||||
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/', pathRewrite: { '^/local/': '' } }
|
||||
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/', pathRewrite: { '^/local/': '' } },
|
||||
'/metadata/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
|
||||
},
|
||||
|
||||
io: {
|
||||
|
||||
2
client/package-lock.json
generated
2
client/package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.4",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "1.1.8",
|
||||
"version": "1.2.8",
|
||||
"description": "Audiobook manager and player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -9,7 +9,7 @@
|
||||
"start": "nuxt start",
|
||||
"generate": "nuxt generate"
|
||||
},
|
||||
"author": "",
|
||||
"author": "advplyr",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
||||
@@ -13,9 +13,11 @@
|
||||
<div class="mb-2">
|
||||
<h1 class="text-2xl font-book leading-7">{{ title }}</h1>
|
||||
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
|
||||
<ui-tooltip :text="authorTooltipText" direction="bottom">
|
||||
<p class="text-sm text-gray-100 leading-7">by {{ author }}</p>
|
||||
</ui-tooltip>
|
||||
<div class="w-min">
|
||||
<ui-tooltip :text="authorTooltipText" direction="bottom">
|
||||
<span class="text-sm text-gray-100 leading-7 whitespace-nowrap">by {{ author }}</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
@@ -31,17 +33,26 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-4">
|
||||
<ui-btn :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
|
||||
<ui-btn v-if="!isMissing" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
|
||||
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
||||
{{ streaming ? 'Streaming' : 'Play' }}
|
||||
</ui-btn>
|
||||
<ui-btn v-else color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
|
||||
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">error</span>
|
||||
Missing
|
||||
</ui-btn>
|
||||
|
||||
<!-- <ui-btn v-if="ebooks.length" 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>
|
||||
Read
|
||||
</ui-btn> -->
|
||||
|
||||
<ui-tooltip v-if="userCanUpdate" text="Edit" direction="top">
|
||||
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip v-if="userCanDownload" text="Download" direction="top">
|
||||
<ui-icon-btn icon="download" class="mx-0.5" @click="downloadClick" />
|
||||
<ui-tooltip v-if="userCanDownload" :disabled="isMissing" text="Download" direction="top">
|
||||
<ui-icon-btn icon="download" :disabled="isMissing" class="mx-0.5" @click="downloadClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-tooltip :text="isRead ? 'Mark as Not Read' : 'Mark as Read'" direction="top">
|
||||
@@ -69,7 +80,7 @@
|
||||
Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span>
|
||||
</p>
|
||||
<div>
|
||||
<p v-for="part in invalidParts" :key="part" class="text-sm font-mono">{{ part.filename }}: {{ part.error }}</p>
|
||||
<p v-for="part in invalidParts" :key="part.filename" class="text-sm font-mono">{{ part.filename }}: {{ part.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,6 +91,8 @@
|
||||
<tables-other-files-table v-if="otherFiles.length" :audiobook-id="audiobook.id" :files="otherFiles" class="mt-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="area"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -152,6 +165,9 @@ export default {
|
||||
})
|
||||
return chunks
|
||||
},
|
||||
isMissing() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
missingParts() {
|
||||
return this.audiobook.missingParts || []
|
||||
},
|
||||
@@ -214,6 +230,9 @@ export default {
|
||||
audioFiles() {
|
||||
return this.audiobook.audioFiles || []
|
||||
},
|
||||
ebooks() {
|
||||
return this.audiobook.ebooks
|
||||
},
|
||||
description() {
|
||||
return this.book.description || ''
|
||||
},
|
||||
@@ -252,6 +271,18 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openEbook() {
|
||||
var ebook = this.ebooks[0]
|
||||
console.log('Ebook', ebook)
|
||||
this.$axios
|
||||
.$get(`/ebook/open/${this.audiobookId}/${ebook.ino}`)
|
||||
.then(() => {
|
||||
console.log('opened')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('failed', error)
|
||||
})
|
||||
},
|
||||
toggleRead() {
|
||||
var updatePayload = {
|
||||
isRead: !this.isRead
|
||||
|
||||
@@ -130,7 +130,7 @@ export default {
|
||||
this.isProcessing = false
|
||||
if (data.updates) {
|
||||
this.$toast.success(`Successfully updated ${data.updates} audiobooks`)
|
||||
this.$router.replace('/')
|
||||
this.$router.replace('/library')
|
||||
} else {
|
||||
this.$toast.warning('No updates were necessary')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div id="page-wrapper" class="page p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<div class="flex items-center mb-2">
|
||||
<h1 class="text-2xl">Users</h1>
|
||||
@@ -49,7 +49,14 @@
|
||||
<div class="flex-grow" />
|
||||
<div class="w-40 flex flex-col">
|
||||
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
|
||||
<ui-btn color="primary" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
|
||||
|
||||
<div class="w-full mb-4">
|
||||
<ui-tooltip direction="bottom" text="Only scans audiobooks without a cover. Covers will be applied if a close match is found." class="w-full">
|
||||
<ui-btn color="primary" class="w-full" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- <ui-btn color="primary" small @click="saveMetadataFiles">Save Metadata</ui-btn> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,6 +154,9 @@ export default {
|
||||
scanCovers() {
|
||||
this.$root.socket.emit('scan_covers')
|
||||
},
|
||||
saveMetadataFiles() {
|
||||
this.$root.socket.emit('save_metadata')
|
||||
},
|
||||
loadUsers() {
|
||||
this.$axios
|
||||
.$get('/api/users')
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
<template>
|
||||
<div class="page" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<app-book-shelf-toolbar />
|
||||
<app-book-shelf />
|
||||
<!-- <app-book-shelf-toolbar /> -->
|
||||
<!-- <div class="flex h-full">
|
||||
<app-side-rail />
|
||||
<div class="flex-grow"> -->
|
||||
<!-- <app-book-shelf /> -->
|
||||
<!-- </div> -->
|
||||
<!-- </div> -->
|
||||
|
||||
<div class="flex h-full">
|
||||
<app-side-rail />
|
||||
<div class="flex-grow">
|
||||
<app-book-shelf-toolbar is-home />
|
||||
<app-book-shelf-categorized />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
// asyncData({ redirect }) {
|
||||
// redirect('/library')
|
||||
// },
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
|
||||
70
client/pages/library/_id.vue
Normal file
70
client/pages/library/_id.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="page" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="flex h-full">
|
||||
<app-side-rail />
|
||||
<div class="flex-grow">
|
||||
<app-book-shelf-toolbar :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" />
|
||||
<app-book-shelf :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ params, query, store, app }) {
|
||||
if (query.filter) {
|
||||
store.dispatch('user/updateUserSettings', { filterBy: query.filter })
|
||||
}
|
||||
var searchResults = []
|
||||
var searchQuery = null
|
||||
if (params.id === 'search' && query.query) {
|
||||
searchQuery = query.query
|
||||
searchResults = await app.$axios.$get(`/api/audiobooks?q=${query.query}`).catch((error) => {
|
||||
console.error('Search error', error)
|
||||
return []
|
||||
})
|
||||
store.commit('audiobooks/setSearchResults', searchResults)
|
||||
}
|
||||
var selectedSeries = query.series ? app.$decode(query.series) : null
|
||||
store.commit('audiobooks/setSelectedSeries', selectedSeries)
|
||||
var libraryPage = params.id || ''
|
||||
store.commit('audiobooks/setLibraryPage', libraryPage)
|
||||
|
||||
return {
|
||||
id: libraryPage,
|
||||
searchQuery,
|
||||
searchResults,
|
||||
selectedSeries
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
watch: {
|
||||
'$route.query'(newVal) {
|
||||
if (this.id === 'search' && this.$route.query.query) {
|
||||
if (this.$route.query.query !== this.searchQuery) {
|
||||
this.newQuery()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async newQuery() {
|
||||
var query = this.$route.query.query
|
||||
this.searchResults = await this.$axios.$get(`/api/audiobooks?q=${query}`).catch((error) => {
|
||||
console.error('Search error', error)
|
||||
return []
|
||||
})
|
||||
this.searchQuery = query
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -57,15 +57,14 @@ export default {
|
||||
password: this.password || ''
|
||||
}
|
||||
var authRes = await this.$axios.$post('/login', payload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
console.error('Failed', error.response)
|
||||
if (error.response) this.error = error.response.data
|
||||
else this.error = 'Unknown Error'
|
||||
return false
|
||||
})
|
||||
console.log('Auth res', authRes)
|
||||
if (!authRes) {
|
||||
this.error = 'Unknown Failure'
|
||||
} else if (authRes.error) {
|
||||
if (authRes && authRes.error) {
|
||||
this.error = authRes.error
|
||||
} else {
|
||||
} else if (authRes) {
|
||||
this.$store.commit('user/setUser', authRes.user)
|
||||
}
|
||||
this.processing = false
|
||||
@@ -77,7 +76,6 @@ export default {
|
||||
if (token) {
|
||||
this.processing = true
|
||||
|
||||
console.log('Authorize', token)
|
||||
this.$axios
|
||||
.$post('/api/authorize', null, {
|
||||
headers: {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<main class="container mx-auto h-full max-w-screen-lg p-6">
|
||||
<article class="max-h-full overflow-y-auto relative flex flex-col bg-primary shadow-xl rounded-md" @drop="drop" @dragover="dragover" @dragleave="dragleave" @dragenter="dragenter">
|
||||
<h1 class="text-xl font-book px-4 pt-4 pb-2"><span class="text-error pr-4">(Experimental)</span>Audiobook Uploader</h1>
|
||||
<article class="max-h-full overflow-y-auto relative flex flex-col rounded-md" @drop="drop" @dragover="dragover" @dragleave="dragleave" @dragenter="dragenter">
|
||||
<h1 class="text-xl font-book px-8 pt-4 pb-2">Audiobook Uploader</h1>
|
||||
|
||||
<div class="flex my-2 px-6">
|
||||
<div class="w-1/2 px-2">
|
||||
@@ -170,19 +170,16 @@ export default {
|
||||
}
|
||||
},
|
||||
drop(evt) {
|
||||
console.log('Dropped event', evt)
|
||||
this.isDragOver = false
|
||||
this.preventDefaults(evt)
|
||||
const files = [...evt.dataTransfer.files]
|
||||
this.filesChanged(files)
|
||||
},
|
||||
dragover(evt) {
|
||||
console.log('Dragged over', evt)
|
||||
this.isDragOver = true
|
||||
this.preventDefaults(evt)
|
||||
},
|
||||
dragleave(evt) {
|
||||
console.log('Dragged leave', evt)
|
||||
this.isDragOver = false
|
||||
this.preventDefaults(evt)
|
||||
},
|
||||
@@ -195,7 +192,6 @@ export default {
|
||||
e.stopPropagation()
|
||||
},
|
||||
filesChanged(files) {
|
||||
console.log('FilesChanged', files)
|
||||
this.showUploader = false
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function ({ $axios, store }) {
|
||||
|
||||
$axios.onError(error => {
|
||||
const code = parseInt(error.response && error.response.status)
|
||||
console.error('Axios error code', code)
|
||||
const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
console.error('Axios error', code, message)
|
||||
})
|
||||
}
|
||||
@@ -38,13 +38,26 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
Vue.prototype.$cleanString = (str) => {
|
||||
if (!str) return ''
|
||||
Vue.prototype.$calculateTextSize = (text, styles = {}) => {
|
||||
const el = document.createElement('p')
|
||||
|
||||
// No longer necessary to replace accented chars, full utf-8 charset is supported
|
||||
// replace accented characters: https://stackoverflow.com/a/49901740/7431543
|
||||
// str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
|
||||
return str.trim()
|
||||
let attr = 'margin:0px;opacity:1;position:absolute;top:100px;left:100px;z-index:99;'
|
||||
for (const key in styles) {
|
||||
if (styles[key] && String(styles[key]).length > 0) {
|
||||
attr += `${key}:${styles[key]};`
|
||||
}
|
||||
}
|
||||
|
||||
el.setAttribute('style', attr)
|
||||
el.innerText = text
|
||||
|
||||
document.body.appendChild(el)
|
||||
const boundingBox = el.getBoundingClientRect()
|
||||
el.remove()
|
||||
return {
|
||||
height: boundingBox.height,
|
||||
width: boundingBox.width
|
||||
}
|
||||
}
|
||||
|
||||
function isClickedOutsideEl(clickEvent, elToCheckOutside, ignoreSelectors = [], ignoreElems = []) {
|
||||
@@ -111,4 +124,7 @@ Vue.prototype.$decode = decode
|
||||
export {
|
||||
encode,
|
||||
decode
|
||||
}
|
||||
export default ({ app }, inject) => {
|
||||
app.$decode = decode
|
||||
}
|
||||
@@ -7,4 +7,4 @@ const options = {
|
||||
draggable: false
|
||||
}
|
||||
|
||||
Vue.use(Toast, options)
|
||||
Vue.use(Toast, options)
|
||||
|
||||
59
client/plugins/version.js
Normal file
59
client/plugins/version.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import packagejson from '../package.json'
|
||||
import axios from 'axios'
|
||||
|
||||
function parseSemver(ver) {
|
||||
if (!ver) return null
|
||||
var groups = ver.match(/^v((([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)$/)
|
||||
if (groups && groups.length > 6) {
|
||||
var total = Number(groups[3]) * 10000 + Number(groups[4]) * 100 + Number(groups[5])
|
||||
if (isNaN(total)) {
|
||||
console.warn('Invalid version total', groups[3], groups[4], groups[5])
|
||||
return null
|
||||
}
|
||||
return {
|
||||
total,
|
||||
version: groups[2],
|
||||
major: Number(groups[3]),
|
||||
minor: Number(groups[4]),
|
||||
patch: Number(groups[5]),
|
||||
preRelease: groups[6] || null
|
||||
}
|
||||
} else {
|
||||
console.warn('Invalid semver string', ver)
|
||||
}
|
||||
return null
|
||||
}
|
||||
export async function checkForUpdate() {
|
||||
if (!packagejson.version) {
|
||||
return
|
||||
}
|
||||
var currVerObj = parseSemver('v' + packagejson.version)
|
||||
if (!currVerObj) {
|
||||
console.error('Invalid version', packagejson.version)
|
||||
return
|
||||
}
|
||||
var largestVer = null
|
||||
await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/tags`).then((res) => {
|
||||
var tags = res.data
|
||||
if (tags && tags.length) {
|
||||
tags.forEach((tag) => {
|
||||
var verObj = parseSemver(tag.name)
|
||||
if (verObj) {
|
||||
if (!largestVer || largestVer.total < verObj.total) {
|
||||
largestVer = verObj
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
if (!largestVer) {
|
||||
console.error('No valid version tags to compare with')
|
||||
return
|
||||
}
|
||||
return {
|
||||
hasUpdate: largestVer.total > currVerObj.total,
|
||||
latestVersion: largestVer.version,
|
||||
githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${largestVer.version}`,
|
||||
currentVersion: currVerObj.version
|
||||
}
|
||||
}
|
||||
@@ -5,23 +5,43 @@ const STANDARD_GENRES = ['Adventure', 'Autobiography', 'Biography', 'Childrens',
|
||||
|
||||
export const state = () => ({
|
||||
audiobooks: [],
|
||||
lastLoad: 0,
|
||||
listeners: [],
|
||||
genres: [...STANDARD_GENRES],
|
||||
tags: [],
|
||||
series: [],
|
||||
keywordFilter: null
|
||||
keywordFilter: null,
|
||||
selectedSeries: null,
|
||||
libraryPage: null,
|
||||
searchResults: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getAudiobook: (state) => id => {
|
||||
return state.audiobooks.find(ab => ab.id === id)
|
||||
},
|
||||
getFiltered: (state, getters, rootState) => () => {
|
||||
getEntitiesShowing: (state, getters, rootState, rootGetters) => () => {
|
||||
if (!state.libraryPage) {
|
||||
return getters.getFiltered()
|
||||
} else if (state.libraryPage === 'search') {
|
||||
return state.searchResults
|
||||
} else if (state.libraryPage === 'series') {
|
||||
var series = getters.getSeriesGroups()
|
||||
if (state.selectedSeries) {
|
||||
var _series = series.find(__series => __series.name === state.selectedSeries)
|
||||
if (!_series) return []
|
||||
return _series.books || []
|
||||
}
|
||||
return series
|
||||
}
|
||||
return []
|
||||
},
|
||||
getFiltered: (state, getters, rootState, rootGetters) => () => {
|
||||
var filtered = state.audiobooks
|
||||
var settings = rootState.user.settings || {}
|
||||
var filterBy = settings.filterBy || ''
|
||||
|
||||
var searchGroups = ['genres', 'tags', 'series', 'authors']
|
||||
var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress']
|
||||
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
|
||||
if (group) {
|
||||
var filter = decode(filterBy.replace(`${group}.`, ''))
|
||||
@@ -29,6 +49,16 @@ export const getters = {
|
||||
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
|
||||
else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
|
||||
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
|
||||
else if (group === 'progress') {
|
||||
filtered = filtered.filter(ab => {
|
||||
var userAudiobook = rootGetters['user/getUserAudiobook'](ab.id)
|
||||
var isRead = userAudiobook && userAudiobook.isRead
|
||||
if (filter === 'Read' && isRead) return true
|
||||
if (filter === 'Unread' && !isRead) return true
|
||||
if (filter === 'In Progress' && (userAudiobook && !userAudiobook.isRead && userAudiobook.progress > 0)) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
if (state.keywordFilter) {
|
||||
const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrarator']
|
||||
@@ -45,11 +75,44 @@ export const getters = {
|
||||
var direction = settings.orderDesc ? 'desc' : 'asc'
|
||||
|
||||
var filtered = getters.getFiltered()
|
||||
var orderByNumber = settings.orderBy === 'book.volumeNumber'
|
||||
return sort(filtered)[direction]((ab) => {
|
||||
// Supports dot notation strings i.e. "book.title"
|
||||
return settings.orderBy.split('.').reduce((a, b) => a[b], ab)
|
||||
var value = settings.orderBy.split('.').reduce((a, b) => a[b], ab)
|
||||
if (orderByNumber && !isNaN(value)) return Number(value)
|
||||
return value
|
||||
})
|
||||
},
|
||||
getSeriesGroups: (state, getters, rootState) => () => {
|
||||
var series = {}
|
||||
state.audiobooks.forEach((audiobook) => {
|
||||
if (audiobook.book && audiobook.book.series) {
|
||||
if (series[audiobook.book.series]) {
|
||||
var bookLastUpdate = audiobook.book.lastUpdate
|
||||
if (bookLastUpdate > series[audiobook.book.series].lastUpdate) series[audiobook.book.series].lastUpdate = bookLastUpdate
|
||||
series[audiobook.book.series].books.push(audiobook)
|
||||
} else {
|
||||
series[audiobook.book.series] = {
|
||||
type: 'series',
|
||||
name: audiobook.book.series || '',
|
||||
books: [audiobook],
|
||||
lastUpdate: audiobook.book.lastUpdate
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
var seriesArray = Object.values(series).map((_series) => {
|
||||
_series.books = sort(_series.books)['asc']((ab) => {
|
||||
return ab.book && ab.book.volumeNumber && !isNaN(ab.book.volumeNumber) ? Number(ab.book.volumeNumber) : null
|
||||
})
|
||||
return _series
|
||||
})
|
||||
if (state.keywordFilter) {
|
||||
const keywordFilter = state.keywordFilter.toLowerCase()
|
||||
return seriesArray.filter((_series) => _series.name.toLowerCase().includes(keywordFilter))
|
||||
}
|
||||
return seriesArray
|
||||
},
|
||||
getUniqueAuthors: (state) => {
|
||||
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
|
||||
return [...new Set(_authors)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
@@ -58,32 +121,75 @@ export const getters = {
|
||||
var _genres = []
|
||||
state.audiobooks.filter(ab => !!(ab.book && ab.book.genres)).forEach(ab => _genres = _genres.concat(ab.book.genres))
|
||||
return [...new Set(_genres)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
},
|
||||
getBookCoverSrc: (state, getters, rootState, rootGetters) => (book, placeholder = '/book_placeholder.jpg') => {
|
||||
if (!book || !book.cover || book.cover === placeholder) return placeholder
|
||||
var cover = book.cover
|
||||
|
||||
// Absolute URL covers
|
||||
if (cover.startsWith('http:') || cover.startsWith('https:')) return cover
|
||||
|
||||
// Server hosted covers
|
||||
try {
|
||||
// Ensure cover is refreshed if cached
|
||||
var bookLastUpdate = book.lastUpdate || Date.now()
|
||||
var userToken = rootGetters['user/getToken']
|
||||
|
||||
var url = new URL(cover, document.baseURI)
|
||||
return url.href + `?token=${userToken}&ts=${bookLastUpdate}`
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return placeholder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
load({ commit, rootState }) {
|
||||
// Return true if calling load
|
||||
load({ state, commit, rootState }) {
|
||||
if (!rootState.user || !rootState.user.user) {
|
||||
console.error('audiobooks/load - User not set')
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
// Don't load again if already loaded in the last 5 minutes
|
||||
var lastLoadDiff = Date.now() - state.lastLoad
|
||||
if (lastLoadDiff < 5 * 60 * 1000) {
|
||||
// Already up to date
|
||||
return false
|
||||
}
|
||||
|
||||
this.$axios
|
||||
.$get(`/api/audiobooks`)
|
||||
.then((data) => {
|
||||
commit('set', data)
|
||||
commit('setLastLoad')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
commit('set', [])
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setLastLoad(state) {
|
||||
state.lastLoad = Date.now()
|
||||
},
|
||||
setKeywordFilter(state, val) {
|
||||
state.keywordFilter = val
|
||||
},
|
||||
setSelectedSeries(state, val) {
|
||||
state.selectedSeries = val
|
||||
},
|
||||
setLibraryPage(state, val) {
|
||||
state.libraryPage = val
|
||||
},
|
||||
setSearchResults(state, val) {
|
||||
state.searchResults = val
|
||||
},
|
||||
set(state, audiobooks) {
|
||||
// GENRES
|
||||
var genres = [...state.genres]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { checkForUpdate } from '@/plugins/version'
|
||||
import Vue from 'vue'
|
||||
|
||||
export const state = () => ({
|
||||
versionData: null,
|
||||
serverSettings: null,
|
||||
streamAudiobook: null,
|
||||
editModalTab: 'details',
|
||||
@@ -13,7 +15,9 @@ export const state = () => ({
|
||||
coverScanProgress: null,
|
||||
developerMode: false,
|
||||
selectedAudiobooks: [],
|
||||
processingBatch: false
|
||||
processingBatch: false,
|
||||
previousPath: '/',
|
||||
routeHistory: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
@@ -39,10 +43,39 @@ export const actions = {
|
||||
console.error('Failed to update server settings', error)
|
||||
return false
|
||||
})
|
||||
},
|
||||
checkForUpdate({ commit }) {
|
||||
return checkForUpdate()
|
||||
.then((res) => {
|
||||
commit('setVersionData', res)
|
||||
return res
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Update check failed', error)
|
||||
return false
|
||||
})
|
||||
},
|
||||
popRoute({ commit, state }) {
|
||||
if (!state.routeHistory.length) {
|
||||
return null
|
||||
}
|
||||
var _history = [...state.routeHistory]
|
||||
var last = _history.pop()
|
||||
commit('setRouteHistory', _history)
|
||||
return last
|
||||
}
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setRouteHistory(state, val) {
|
||||
state.routeHistory = val
|
||||
},
|
||||
setPreviousPath(state, val) {
|
||||
state.previousPath = val
|
||||
},
|
||||
setVersionData(state, versionData) {
|
||||
state.versionData = versionData
|
||||
},
|
||||
setServerSettings(state, settings) {
|
||||
state.serverSettings = settings
|
||||
},
|
||||
@@ -97,13 +130,18 @@ export const mutations = {
|
||||
state.developerMode = val
|
||||
},
|
||||
setSelectedAudiobooks(state, audiobooks) {
|
||||
state.selectedAudiobooks = audiobooks
|
||||
Vue.set(state, 'selectedAudiobooks', audiobooks)
|
||||
// state.selectedAudiobooks = audiobooks
|
||||
},
|
||||
toggleAudiobookSelected(state, audiobookId) {
|
||||
if (state.selectedAudiobooks.includes(audiobookId)) {
|
||||
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
|
||||
} else {
|
||||
state.selectedAudiobooks.push(audiobookId)
|
||||
var newSel = state.selectedAudiobooks.concat([audiobookId])
|
||||
// state.selectedAudiobooks = newSel
|
||||
console.log('Setting toggle on sel', newSel)
|
||||
Vue.set(state, 'selectedAudiobooks', newSel)
|
||||
// state.selectedAudiobooks.push(audiobookId)
|
||||
}
|
||||
},
|
||||
setProcessingBatch(state, val) {
|
||||
|
||||
@@ -33,6 +33,9 @@ export const getters = {
|
||||
},
|
||||
getUserCanDownload: (state) => {
|
||||
return state.user && state.user.permissions ? !!state.user.permissions.download : false
|
||||
},
|
||||
getUserCanUpload: (state) => {
|
||||
return state.user && state.user.permissions ? !!state.user.permissions.upload : false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +44,8 @@ export const actions = {
|
||||
var updatePayload = {
|
||||
...payload
|
||||
}
|
||||
// Immediately update
|
||||
commit('setSettings', updatePayload)
|
||||
return this.$axios.$patch('/api/user/settings', updatePayload).then((result) => {
|
||||
if (result.success) {
|
||||
commit('setSettings', result.settings)
|
||||
@@ -60,10 +65,8 @@ export const mutations = {
|
||||
state.user = user
|
||||
if (user) {
|
||||
if (user.token) localStorage.setItem('token', user.token)
|
||||
console.log('setUser', user.username)
|
||||
} else {
|
||||
localStorage.removeItem('token')
|
||||
console.warn('setUser cleared')
|
||||
}
|
||||
},
|
||||
setSettings(state, settings) {
|
||||
|
||||
@@ -16,6 +16,12 @@ module.exports = {
|
||||
height: {
|
||||
'7.5': '1.75rem'
|
||||
},
|
||||
spacing: {
|
||||
'-54': '-13.5rem'
|
||||
},
|
||||
rotate: {
|
||||
'-60': '-60deg'
|
||||
},
|
||||
colors: {
|
||||
bg: '#373838',
|
||||
primary: '#232323',
|
||||
|
||||
41
package-lock.json
generated
41
package-lock.json
generated
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "1.1.7",
|
||||
"version": "1.2.7",
|
||||
"lockfileVersion": 1,
|
||||
"requires": true,
|
||||
"dependencies": {
|
||||
@@ -138,6 +138,11 @@
|
||||
"is-primitive": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"array-back": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz",
|
||||
"integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q=="
|
||||
},
|
||||
"array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -288,6 +293,17 @@
|
||||
"mimic-response": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"command-line-args": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.0.tgz",
|
||||
"integrity": "sha512-4zqtU1hYsSJzcJBOcNZIbW5Fbk9BkjCp1pZVhQKoRaWL5J7N4XphDLwo8aWwdQpTugxwu+jf9u2ZhkXiqp5Z6A==",
|
||||
"requires": {
|
||||
"array-back": "^3.1.0",
|
||||
"find-replace": "^3.0.0",
|
||||
"lodash.camelcase": "^4.3.0",
|
||||
"typical": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"component-emitter": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
|
||||
@@ -552,6 +568,11 @@
|
||||
"busboy": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"express-rate-limit": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.3.0.tgz",
|
||||
"integrity": "sha512-qJhfEgCnmteSeZAeuOKQ2WEIFTX5ajrzE0xS6gCOBCoRQcU+xEzQmgYQQTpzCcqUAAzTEtu4YEih4pnLfvNtew=="
|
||||
},
|
||||
"finalhandler": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
|
||||
@@ -566,6 +587,14 @@
|
||||
"unpipe": "~1.0.0"
|
||||
}
|
||||
},
|
||||
"find-replace": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz",
|
||||
"integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==",
|
||||
"requires": {
|
||||
"array-back": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"fluent-ffmpeg": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.2.tgz",
|
||||
@@ -830,6 +859,11 @@
|
||||
"got": "11.3.x"
|
||||
}
|
||||
},
|
||||
"lodash.camelcase": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||
"integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY="
|
||||
},
|
||||
"lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
@@ -1339,6 +1373,11 @@
|
||||
"mime-types": "~2.1.24"
|
||||
}
|
||||
},
|
||||
"typical": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz",
|
||||
"integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw=="
|
||||
},
|
||||
"universalify": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
|
||||
|
||||
20
package.json
20
package.json
@@ -1,11 +1,23 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "1.1.8",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
|
||||
"version": "1.2.8",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "node index.js",
|
||||
"start": "node index.js"
|
||||
"start": "node index.js",
|
||||
"client": "cd client && npm install && npm run generate",
|
||||
"prod": "npm run client && npm install && node prod.js",
|
||||
"build-win": "npm run build-prep && pkg -t node12-win-x64 -o ./dist/win/audiobookshelf .",
|
||||
"build-linux": "build/linuxpackager"
|
||||
},
|
||||
"bin": "prod.js",
|
||||
"pkg": {
|
||||
"assets": "client/dist/**/*",
|
||||
"scripts": [
|
||||
"prod.js",
|
||||
"server/**/*.js"
|
||||
]
|
||||
},
|
||||
"author": "advplyr",
|
||||
"license": "ISC",
|
||||
@@ -13,9 +25,11 @@
|
||||
"archiver": "^5.3.0",
|
||||
"axios": "^0.21.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"command-line-args": "^5.2.0",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"express": "^4.17.1",
|
||||
"express-fileupload": "^1.2.1",
|
||||
"express-rate-limit": "^5.3.0",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"fs-extra": "^10.0.0",
|
||||
"ip": "^1.1.5",
|
||||
|
||||
31
prod.js
Normal file
31
prod.js
Normal file
@@ -0,0 +1,31 @@
|
||||
const optionDefinitions = [
|
||||
{ name: 'config', alias: 'c', type: String },
|
||||
{ name: 'audiobooks', alias: 'a', type: String },
|
||||
{ name: 'metadata', alias: 'm', type: String },
|
||||
{ name: 'port', alias: 'p', type: String }
|
||||
]
|
||||
|
||||
const commandLineArgs = require('command-line-args')
|
||||
const options = commandLineArgs(optionDefinitions)
|
||||
|
||||
const Path = require('path')
|
||||
process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
|
||||
process.env.NODE_ENV = 'production'
|
||||
|
||||
const server = require('./server/Server')
|
||||
|
||||
global.appRoot = __dirname
|
||||
|
||||
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
|
||||
|
||||
const PORT = options.port || process.env.PORT || 3333
|
||||
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')
|
||||
|
||||
console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
||||
|
||||
const Server = new server(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
|
||||
Server.start()
|
||||
140
readme.md
140
readme.md
@@ -2,6 +2,8 @@
|
||||
|
||||
AudioBookshelf is a self-hosted audiobook server for managing and playing your audiobooks.
|
||||
|
||||
See [Install guides](https://audiobookshelf.org/install) and [documentation](https://audiobookshelf.org/docs)
|
||||
|
||||
Android app is in beta, try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
|
||||
|
||||
**Free & open source Android/iOS app is in development**
|
||||
@@ -9,37 +11,139 @@ Android app is in beta, try it out on the [Google Play Store](https://play.googl
|
||||
<img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_streaming.png" />
|
||||
|
||||
|
||||
#### Folder Structures Supported:
|
||||
## Directory Structure
|
||||
|
||||
```bash
|
||||
/Title/...
|
||||
/Author/Title/...
|
||||
/Author/Series/Title/...
|
||||
See [documentation](https://audiobookshelf.org/docs) for directory structure and naming.
|
||||
|
||||
Title can start with the publish year like so:
|
||||
/1989 - Book Title/...
|
||||
Author, Series, Volume Number, Title and Publish Year can all be parsed from your folder structure.
|
||||
|
||||
(Optional Setting) Subtitle can be seperated to its own field:
|
||||
/Book Title - With a Subtitle/...
|
||||
/1989 - Book Title - With a Subtitle/...
|
||||
will store "With a Subtitle" as the subtitle
|
||||
```
|
||||
**Note**: Files in the root directory `/audiobooks` will be ignored, all audiobooks should be in a directory
|
||||
|
||||
**1 Folder:** `/Title/...`\
|
||||
**2 Folders:** `/Author/Title/...`\
|
||||
**3 Folders:** `/Author/Series/Title/...`
|
||||
|
||||
### Parsing publish year
|
||||
|
||||
`/1984 - Hackers/...`\
|
||||
Will save the publish year as `1984` and the title as `Hackers`
|
||||
|
||||
### Parsing volume number (only for series)
|
||||
|
||||
`/Book 3 - Hackers/...`\
|
||||
Will save the volume number as `3` and the title as `Hackers`
|
||||
|
||||
`Book` `Volume` `Vol` `Vol.` are all supported case insensitive
|
||||
|
||||
These combinations will also work:\
|
||||
`/Hackers - Vol. 3/...`\
|
||||
`/1984 - Volume 3 - Hackers/...`\
|
||||
`/1984 - Hackers Book 3/...`
|
||||
|
||||
|
||||
#### Features coming soon:
|
||||
### Parsing subtitles (optional in settings)
|
||||
|
||||
Title Folder: `/Hackers - Heroes of the Computer Revolution/...`
|
||||
|
||||
Will save the title as `Hackers` and the subtitle as `Heroes of the Computer Revolution`
|
||||
|
||||
|
||||
### Full example
|
||||
|
||||
`/Steven Levy/The Hacker Series/1984 - Hackers - Heroes of the Computer Revolution - Vol. 1/...`
|
||||
|
||||
**Becomes:**
|
||||
| Key | Value |
|
||||
|---------------|-----------------------------------|
|
||||
| Author | Steven Levy |
|
||||
| Series | The Hacker Series |
|
||||
| Publish Year | 1984 |
|
||||
| Title | Hackers |
|
||||
| Subtitle | Heroes of the Computer Revolution |
|
||||
| Volume Number | 1 |
|
||||
|
||||
|
||||
## Features coming soon
|
||||
|
||||
* Support different views to see more details of each audiobook
|
||||
* Option to download all files in a zip file
|
||||
* iOS App (Android is in beta [here](https://play.google.com/store/apps/details?id=com.audiobookshelf.app))
|
||||
|
||||
<img alt="Screenshot2" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_audiobook.png" />
|
||||
|
||||
## Installation
|
||||
|
||||
Built to run in Docker for now (also on Unraid server Community Apps)
|
||||
### Docker Install
|
||||
Available in Unraid Community Apps
|
||||
|
||||
```bash
|
||||
docker run -d -p 1337:80 -v /audiobooks:/audiobooks -v /config:/config -v /metadata:/metadata --name audiobookshelf --rm advplyr/audiobookshelf
|
||||
docker pull advplyr/audiobookshelf
|
||||
|
||||
docker run -d \
|
||||
-p 1337:80 \
|
||||
-v </path/to/audiobooks>:/audiobooks \
|
||||
-v </path/to/config>:/config \
|
||||
-v </path/to/metadata>:/metadata \
|
||||
--name audiobookshelf \
|
||||
--rm advplyr/audiobookshelf
|
||||
```
|
||||
|
||||
### Linux (amd64) Install
|
||||
|
||||
A simple installer is added to setup the initial config. If you already have audiobooks, you can enter the path to your audiobooks during the install. The installer will create a user and group named `audiobookshelf`.
|
||||
|
||||
|
||||
|
||||
### Ubuntu Install via PPA
|
||||
|
||||
A PPA is hosted on [github](https://github.com/advplyr/audiobookshelf-ppa), add and install:
|
||||
|
||||
```bash
|
||||
curl -s --compressed "https://advplyr.github.io/audiobookshelf-ppa/KEY.gpg" | sudo apt-key add -
|
||||
|
||||
sudo curl -s --compressed -o /etc/apt/sources.list.d/audiobookshelf.list "https://advplyr.github.io/audiobookshelf-ppa/audiobookshelf.list"
|
||||
|
||||
sudo apt update
|
||||
|
||||
sudo apt install audiobookshelf
|
||||
```
|
||||
|
||||
or use a single command
|
||||
|
||||
```bash
|
||||
curl -s --compressed "https://advplyr.github.io/audiobookshelf-ppa/KEY.gpg" | sudo apt-key add - && sudo curl -s --compressed -o /etc/apt/sources.list.d/audiobookshelf.list "https://advplyr.github.io/audiobookshelf-ppa/audiobookshelf.list" && sudo apt update && sudo apt install audiobookshelf
|
||||
```
|
||||
|
||||
### Install via debian package
|
||||
|
||||
Get the `deb` file from the [github repo](https://github.com/advplyr/audiobookshelf-ppa).
|
||||
|
||||
```bash
|
||||
wget https://advplyr.github.io/audiobookshelf-ppa/audiobookshelf_1.2.7_amd64.deb
|
||||
|
||||
sudo apt install ./audiobookshelf_1.2.7_amd64.deb
|
||||
```
|
||||
|
||||
|
||||
#### File locations
|
||||
|
||||
Project directory: `/usr/share/audiobookshelf/`
|
||||
|
||||
Config file: `/etc/default/audiobookshelf`
|
||||
|
||||
System Service: `/lib/systemd/system/audiobookshelf.service`
|
||||
|
||||
Ffmpeg static build: `/usr/lib/audiobookshelf-ffmpeg/`
|
||||
|
||||
## Run from source
|
||||
|
||||
Note: you will need `npm`, `node12`, and `ffmpeg` to run this project locally
|
||||
|
||||
```bash
|
||||
git clone https://github.com/advplyr/audiobookshelf.git
|
||||
cd audiobookshelf
|
||||
|
||||
# All paths default to root directory. Config path is the database.
|
||||
# Directories will be created if they don't exist
|
||||
# Paths are relative to the root directory, so "../Audiobooks" would be a valid path
|
||||
npm run prod -- -p [PORT] --audiobooks [AUDIOBOOKS_PATH] --config [CONFIG_PATH] --metadata [METADATA_PATH]
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
const express = require('express')
|
||||
const Path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const Logger = require('./Logger')
|
||||
const User = require('./objects/User')
|
||||
const { isObject } = require('./utils/index')
|
||||
const { isObject, isAcceptableCoverMimeType } = require('./utils/index')
|
||||
const { CoverDestination } = require('./utils/constants')
|
||||
|
||||
class ApiController {
|
||||
constructor(db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter, clientEmitter) {
|
||||
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter, clientEmitter) {
|
||||
this.db = db
|
||||
this.scanner = scanner
|
||||
this.auth = auth
|
||||
@@ -13,6 +16,7 @@ class ApiController {
|
||||
this.downloadManager = downloadManager
|
||||
this.emitter = emitter
|
||||
this.clientEmitter = clientEmitter
|
||||
this.MetadataPath = MetadataPath
|
||||
|
||||
this.router = express()
|
||||
this.init()
|
||||
@@ -30,6 +34,7 @@ class ApiController {
|
||||
this.router.get('/audiobook/:id', this.getAudiobook.bind(this))
|
||||
this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this))
|
||||
this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.bind(this))
|
||||
this.router.post('/audiobook/:id/cover', this.uploadAudiobookCover.bind(this))
|
||||
this.router.patch('/audiobook/:id', this.updateAudiobook.bind(this))
|
||||
|
||||
this.router.get('/metadata/:id/:trackIndex', this.getMetadata.bind(this))
|
||||
@@ -37,6 +42,8 @@ class ApiController {
|
||||
|
||||
this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this))
|
||||
this.router.patch('/user/audiobook/:id', this.updateUserAudiobookProgress.bind(this))
|
||||
this.router.patch('/user/audiobooks', this.batchUpdateUserAudiobooksProgress.bind(this))
|
||||
|
||||
this.router.patch('/user/password', this.userChangePassword.bind(this))
|
||||
this.router.patch('/user/settings', this.userUpdateSettings.bind(this))
|
||||
this.router.get('/users', this.getUsers.bind(this))
|
||||
@@ -215,6 +222,85 @@ class ApiController {
|
||||
res.json(audiobook.toJSON())
|
||||
}
|
||||
|
||||
async uploadAudiobookCover(req, res) {
|
||||
if (!req.user.canUpload || !req.user.canUpdate) {
|
||||
Logger.warn('User attempted to upload a cover without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
if (!req.files || !req.files.cover) {
|
||||
return res.status(400).send('No files were uploaded')
|
||||
}
|
||||
var audiobookId = req.params.id
|
||||
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
||||
if (!audiobook) {
|
||||
return res.status(404).send('Audiobook not found')
|
||||
}
|
||||
|
||||
var coverFile = req.files.cover
|
||||
var mimeType = coverFile.mimetype
|
||||
var extname = Path.extname(coverFile.name.toLowerCase()) || '.jpg'
|
||||
if (!isAcceptableCoverMimeType(mimeType)) {
|
||||
return res.status(400).send('Invalid image file type: ' + mimeType)
|
||||
}
|
||||
|
||||
var coverDestination = this.db.serverSettings ? this.db.serverSettings.coverDestination : CoverDestination.METADATA
|
||||
Logger.info(`[ApiController] Cover Upload destination ${coverDestination}`)
|
||||
|
||||
var coverDirpath = audiobook.fullPath
|
||||
var coverRelDirpath = Path.join('/local', audiobook.path)
|
||||
if (coverDestination === CoverDestination.METADATA) {
|
||||
coverDirpath = Path.join(this.MetadataPath, 'books', audiobookId)
|
||||
coverRelDirpath = Path.join('/metadata', 'books', audiobookId)
|
||||
Logger.debug(`[ApiController] storing in metadata | ${coverDirpath}`)
|
||||
await fs.ensureDir(coverDirpath)
|
||||
} else {
|
||||
Logger.debug(`[ApiController] storing in audiobook | ${coverRelDirpath}`)
|
||||
}
|
||||
|
||||
var coverFilename = `cover${extname}`
|
||||
var coverFullPath = Path.join(coverDirpath, coverFilename)
|
||||
var coverPath = Path.join(coverRelDirpath, coverFilename)
|
||||
|
||||
// If current cover is a metadata cover and does not match replacement, then remove it
|
||||
var currentBookCover = audiobook.book.cover
|
||||
if (currentBookCover && currentBookCover.startsWith(Path.sep + 'metadata')) {
|
||||
Logger.debug(`Current Book Cover is metadata ${currentBookCover}`)
|
||||
if (currentBookCover !== coverPath) {
|
||||
Logger.info(`[ApiController] removing old metadata cover "${currentBookCover}"`)
|
||||
var oldFullBookCoverPath = Path.join(this.MetadataPath, currentBookCover.replace(Path.sep + 'metadata', ''))
|
||||
|
||||
// Metadata path may have changed, check if exists first
|
||||
var exists = await fs.pathExists(oldFullBookCoverPath)
|
||||
if (exists) {
|
||||
try {
|
||||
await fs.remove(oldFullBookCoverPath)
|
||||
} catch (error) {
|
||||
Logger.error(`[ApiController] Failed to remove old metadata book cover ${oldFullBookCoverPath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
|
||||
Logger.error('Failed to move cover file', path, error)
|
||||
return false
|
||||
})
|
||||
|
||||
if (!success) {
|
||||
return res.status(500).send('Failed to move cover into destination')
|
||||
}
|
||||
|
||||
Logger.info(`[ApiController] Uploaded audiobook cover "${coverPath}" for "${audiobook.title}"`)
|
||||
|
||||
audiobook.updateBookCover(coverPath)
|
||||
await this.db.updateAudiobook(audiobook)
|
||||
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||
res.json({
|
||||
success: true,
|
||||
cover: coverPath
|
||||
})
|
||||
}
|
||||
|
||||
async updateAudiobook(req, res) {
|
||||
if (!req.user.canUpdate) {
|
||||
Logger.warn('User attempted to update without permission', req.user)
|
||||
@@ -271,6 +357,26 @@ class ApiController {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async batchUpdateUserAudiobooksProgress(req, res) {
|
||||
var abProgresses = req.body
|
||||
if (!abProgresses || !abProgresses.length) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
var shouldUpdate = false
|
||||
abProgresses.forEach((progress) => {
|
||||
var wasUpdated = req.user.updateAudiobookProgress(progress.audiobookId, progress)
|
||||
if (wasUpdated) shouldUpdate = true
|
||||
})
|
||||
|
||||
if (shouldUpdate) {
|
||||
await this.db.updateEntity('user', req.user)
|
||||
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
|
||||
}
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
userChangePassword(req, res) {
|
||||
this.auth.userChangePassword(req, res)
|
||||
}
|
||||
|
||||
@@ -38,8 +38,16 @@ class Auth {
|
||||
}
|
||||
|
||||
async authMiddleware(req, res, next) {
|
||||
const authHeader = req.headers['authorization']
|
||||
const token = authHeader && authHeader.split(' ')[1]
|
||||
var token = null
|
||||
|
||||
// If using a get request, the token can be passed as a query string
|
||||
if (req.method === 'GET' && req.query && req.query.token) {
|
||||
token = req.query.token
|
||||
} else {
|
||||
const authHeader = req.headers['authorization']
|
||||
token = authHeader && authHeader.split(' ')[1]
|
||||
}
|
||||
|
||||
if (token == null) {
|
||||
Logger.error('Api called without a token', req.path)
|
||||
return res.sendStatus(401)
|
||||
@@ -95,18 +103,18 @@ class Auth {
|
||||
|
||||
var user = this.users.find(u => u.username === username)
|
||||
|
||||
if (!user) {
|
||||
return res.json({ error: 'User not found' })
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
return res.json({ error: 'User unavailable' })
|
||||
if (!user || !user.isActive) {
|
||||
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
|
||||
if (req.rateLimit.remaining <= 2) {
|
||||
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
|
||||
}
|
||||
return res.status(401).send('Invalid user or password')
|
||||
}
|
||||
|
||||
// Check passwordless root user
|
||||
if (user.id === 'root' && (!user.pash || user.pash === '')) {
|
||||
if (password) {
|
||||
return res.json({ error: 'Invalid root password (hint: there is none)' })
|
||||
return res.status(401).send('Invalid root password (hint: there is none)')
|
||||
} else {
|
||||
return res.json({ user: user.toJSONForBrowser() })
|
||||
}
|
||||
@@ -119,12 +127,24 @@ class Auth {
|
||||
user: user.toJSONForBrowser()
|
||||
})
|
||||
} else {
|
||||
res.json({
|
||||
error: 'Invalid Password'
|
||||
})
|
||||
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
|
||||
if (req.rateLimit.remaining <= 2) {
|
||||
Logger.error(`[Auth] Failed login attempt for user ${user.username}. Attempts: ${req.rateLimit.current}`)
|
||||
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
|
||||
}
|
||||
return res.status(401).send('Invalid user or password')
|
||||
}
|
||||
}
|
||||
|
||||
// Not in use now
|
||||
lockUser(user) {
|
||||
user.isLocked = true
|
||||
return this.db.updateEntity('user', user).catch((error) => {
|
||||
Logger.error('[Auth] Failed to lock user', user.username, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
comparePassword(password, user) {
|
||||
if (user.type === 'root' && !password && !user.pash) return true
|
||||
if (!password || !user.pash) return false
|
||||
|
||||
@@ -7,7 +7,7 @@ const Logger = require('./Logger')
|
||||
const Download = require('./objects/Download')
|
||||
const { writeConcatFile, writeMetadataFile } = require('./utils/ffmpegHelpers')
|
||||
const { getFileSize } = require('./utils/fileUtils')
|
||||
|
||||
const TAG = 'DownloadManager'
|
||||
class DownloadManager {
|
||||
constructor(db, MetadataPath, AudiobookPath, emitter) {
|
||||
this.db = db
|
||||
@@ -260,7 +260,21 @@ class DownloadManager {
|
||||
output: download.fullPath,
|
||||
}
|
||||
|
||||
var worker = new workerThreads.Worker('./server/utils/downloadWorker.js', { workerData })
|
||||
var worker = null
|
||||
try {
|
||||
var workerPath = Path.join(global.appRoot, 'server/utils/downloadWorker.js')
|
||||
worker = new workerThreads.Worker(workerPath, { workerData })
|
||||
} catch (error) {
|
||||
Logger.error(`[${TAG}] Start worker thread failed`, error)
|
||||
if (download.socket) {
|
||||
var downloadJson = download.toJSON()
|
||||
download.socket.emit('download_failed', downloadJson)
|
||||
}
|
||||
this.removeDownload(download)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
worker.on('message', (message) => {
|
||||
if (message != null && typeof message === 'object') {
|
||||
if (message.type === 'RESULT') {
|
||||
|
||||
42
server/EbookReader.js
Normal file
42
server/EbookReader.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// const express = require('express')
|
||||
// const EPub = require('epub')
|
||||
// const Logger = require('./Logger')
|
||||
|
||||
// class EbookReader {
|
||||
// constructor(db, MetadataPath, AudiobookPath) {
|
||||
// this.db = db
|
||||
// this.MetadataPath = MetadataPath
|
||||
// this.AudiobookPath = AudiobookPath
|
||||
|
||||
// this.router = express()
|
||||
// this.init()
|
||||
// }
|
||||
|
||||
// init() {
|
||||
// this.router.get('/open/:id/:ino', this.openRequest.bind(this))
|
||||
// }
|
||||
|
||||
// openRequest(req, res) {
|
||||
// Logger.info('Open request received', req.params)
|
||||
// var audiobookId = req.params.id
|
||||
// var fileIno = req.params.ino
|
||||
// var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
||||
// if (!audiobook) {
|
||||
// return res.sendStatus(404)
|
||||
// }
|
||||
// var ebook = audiobook.ebooks.find(eb => eb.ino === fileIno)
|
||||
// if (!ebook) {
|
||||
// Logger.error('Ebook file not found', fileIno)
|
||||
// return res.sendStatus(404)
|
||||
// }
|
||||
// Logger.info('Ebook found', ebook)
|
||||
// this.open(ebook.fullPath)
|
||||
// res.sendStatus(200)
|
||||
// }
|
||||
|
||||
// open(path) {
|
||||
// var epub = new EPub(path)
|
||||
// console.log('epub', epub)
|
||||
// }
|
||||
// }
|
||||
// module.exports = EbookReader
|
||||
@@ -4,13 +4,13 @@ const fs = require('fs-extra')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
class HlsController {
|
||||
constructor(db, scanner, auth, streamManager, emitter, MetadataPath) {
|
||||
constructor(db, scanner, auth, streamManager, emitter, StreamsPath) {
|
||||
this.db = db
|
||||
this.scanner = scanner
|
||||
this.auth = auth
|
||||
this.streamManager = streamManager
|
||||
this.emitter = emitter
|
||||
this.MetadataPath = MetadataPath
|
||||
this.StreamsPath = StreamsPath
|
||||
|
||||
this.router = express()
|
||||
this.init()
|
||||
@@ -28,7 +28,7 @@ class HlsController {
|
||||
|
||||
async streamFileRequest(req, res) {
|
||||
var streamId = req.params.stream
|
||||
var fullFilePath = Path.join(this.MetadataPath, streamId, req.params.file)
|
||||
var fullFilePath = Path.join(this.StreamsPath, streamId, req.params.file)
|
||||
|
||||
// development test stream - ignore
|
||||
if (streamId === 'test') {
|
||||
|
||||
@@ -9,11 +9,12 @@ const { comparePaths, getIno } = require('./utils/index')
|
||||
const { secondsToTimestamp } = require('./utils/fileUtils')
|
||||
const { ScanResult } = require('./utils/constants')
|
||||
|
||||
|
||||
class Scanner {
|
||||
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) {
|
||||
this.AudiobookPath = AUDIOBOOK_PATH
|
||||
this.MetadataPath = METADATA_PATH
|
||||
this.BookMetadataPath = Path.join(this.MetadataPath, 'books')
|
||||
|
||||
this.db = db
|
||||
this.emitter = emitter
|
||||
|
||||
@@ -26,25 +27,6 @@ class Scanner {
|
||||
return this.db.audiobooks
|
||||
}
|
||||
|
||||
async setAudiobookDataInos(audiobookData) {
|
||||
for (let i = 0; i < audiobookData.length; i++) {
|
||||
var abd = audiobookData[i]
|
||||
var matchingAB = this.db.audiobooks.find(_ab => comparePaths(_ab.path, abd.path))
|
||||
if (matchingAB) {
|
||||
if (!matchingAB.ino) {
|
||||
matchingAB.ino = await getIno(matchingAB.fullPath)
|
||||
}
|
||||
abd.ino = matchingAB.ino
|
||||
} else {
|
||||
abd.ino = await getIno(abd.fullPath)
|
||||
if (!abd.ino) {
|
||||
Logger.error('[Scanner] Invalid ino - ignoring audiobook data', abd.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return audiobookData.filter(abd => !!abd.ino)
|
||||
}
|
||||
|
||||
async setAudioFileInos(audiobookDataAudioFiles, audiobookAudioFiles) {
|
||||
for (let i = 0; i < audiobookDataAudioFiles.length; i++) {
|
||||
var abdFile = audiobookDataAudioFiles[i]
|
||||
@@ -69,8 +51,8 @@ class Scanner {
|
||||
Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`)
|
||||
|
||||
if (existingAudiobook) {
|
||||
|
||||
// REMOVE: No valid audio files
|
||||
// TODO: Label as incomplete, do not actually delete
|
||||
if (!audiobookData.audioFiles.length) {
|
||||
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid audio files found - removing audiobook`)
|
||||
|
||||
@@ -80,7 +62,10 @@ class Scanner {
|
||||
return ScanResult.REMOVED
|
||||
}
|
||||
|
||||
audiobookData.audioFiles = await this.setAudioFileInos(audiobookData.audioFiles, existingAudiobook.audioFiles)
|
||||
// ino is now set for every file in scandir
|
||||
audiobookData.audioFiles = audiobookData.audioFiles.filter(af => af.ino)
|
||||
// audiobookData.audioFiles = await this.setAudioFileInos(audiobookData.audioFiles, existingAudiobook.audioFiles)
|
||||
|
||||
|
||||
// Check for audio files that were removed
|
||||
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
|
||||
@@ -90,6 +75,14 @@ class Scanner {
|
||||
removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af))
|
||||
}
|
||||
|
||||
// Check for mismatched audio tracks - tracks with no matching audio file
|
||||
var removedAudioTracks = existingAudiobook.tracks.filter(track => !abdAudioFileInos.includes(track.ino))
|
||||
if (removedAudioTracks.length) {
|
||||
Logger.info(`[Scanner] ${removedAudioTracks.length} tracks removed no matching audio file for audiobook "${existingAudiobook.title}"`)
|
||||
removedAudioTracks.forEach((at) => existingAudiobook.removeAudioTrack(at))
|
||||
}
|
||||
|
||||
|
||||
// Check for new audio files and sync existing audio files
|
||||
var newAudioFiles = []
|
||||
var hasUpdatedAudioFiles = false
|
||||
@@ -100,7 +93,12 @@ class Scanner {
|
||||
hasUpdatedAudioFiles = true
|
||||
}
|
||||
} else {
|
||||
newAudioFiles.push(file)
|
||||
var audioFileWithMatchingPath = existingAudiobook.getAudioFileByPath(file.fullPath)
|
||||
if (audioFileWithMatchingPath) {
|
||||
Logger.warn(`[Scanner] Audio file with path already exists with different inode, New: "${file.filename}" (${file.ino}) | Existing: ${audioFileWithMatchingPath.filename} (${audioFileWithMatchingPath.ino})`)
|
||||
} else {
|
||||
newAudioFiles.push(file)
|
||||
}
|
||||
}
|
||||
})
|
||||
if (newAudioFiles.length) {
|
||||
@@ -109,8 +107,8 @@ class Scanner {
|
||||
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
|
||||
}
|
||||
|
||||
|
||||
// REMOVE: No valid audio tracks
|
||||
// TODO: Label as incomplete, do not actually delete
|
||||
if (!existingAudiobook.tracks.length) {
|
||||
Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`)
|
||||
|
||||
@@ -119,7 +117,7 @@ class Scanner {
|
||||
return ScanResult.REMOVED
|
||||
}
|
||||
|
||||
var hasUpdates = removedAudioFiles.length || newAudioFiles.length || hasUpdatedAudioFiles
|
||||
var hasUpdates = removedAudioFiles.length || removedAudioTracks.length || newAudioFiles.length || hasUpdatedAudioFiles
|
||||
|
||||
if (existingAudiobook.checkUpdateMissingParts()) {
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`)
|
||||
@@ -135,6 +133,12 @@ class Scanner {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (existingAudiobook.isMissing) {
|
||||
existingAudiobook.isMissing = false
|
||||
hasUpdates = true
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was missing but now it is found`)
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
existingAudiobook.setChapters()
|
||||
|
||||
@@ -173,20 +177,22 @@ class Scanner {
|
||||
}
|
||||
|
||||
async scan() {
|
||||
// TODO: This temporary fix from pre-release should be removed soon, including the "fixRelativePath" and "checkUpdateInos"
|
||||
// TEMP - fix relative file paths
|
||||
// TEMP - update ino for each audiobook
|
||||
if (this.audiobooks.length) {
|
||||
for (let i = 0; i < this.audiobooks.length; i++) {
|
||||
var ab = this.audiobooks[i]
|
||||
var shouldUpdate = ab.fixRelativePath(this.AudiobookPath) || !ab.ino
|
||||
// var shouldUpdate = ab.fixRelativePath(this.AudiobookPath) || !ab.ino
|
||||
|
||||
// Update ino if an audio file has the same ino as the audiobook
|
||||
var shouldUpdateIno = !ab.ino || (ab.audioFiles || []).find(abf => abf.ino === ab.ino)
|
||||
// Update ino if inos are not set
|
||||
var shouldUpdateIno = ab.hasMissingIno
|
||||
if (shouldUpdateIno) {
|
||||
await ab.checkUpdateInos()
|
||||
}
|
||||
if (shouldUpdate) {
|
||||
await this.db.updateAudiobook(ab)
|
||||
Logger.debug(`Updating inos for ${ab.title}`)
|
||||
var hasUpdates = await ab.checkUpdateInos()
|
||||
if (hasUpdates) {
|
||||
await this.db.updateAudiobook(ab)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -194,8 +200,8 @@ class Scanner {
|
||||
const scanStart = Date.now()
|
||||
var audiobookDataFound = await scanRootDir(this.AudiobookPath, this.db.serverSettings)
|
||||
|
||||
// Set ino for each ab data as a string
|
||||
audiobookDataFound = await this.setAudiobookDataInos(audiobookDataFound)
|
||||
// Remove audiobooks with no inode
|
||||
audiobookDataFound = audiobookDataFound.filter(abd => abd.ino)
|
||||
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
@@ -205,18 +211,21 @@ class Scanner {
|
||||
var scanResults = {
|
||||
removed: 0,
|
||||
updated: 0,
|
||||
added: 0
|
||||
added: 0,
|
||||
missing: 0
|
||||
}
|
||||
|
||||
// Check for removed audiobooks
|
||||
for (let i = 0; i < this.audiobooks.length; i++) {
|
||||
var dataFound = audiobookDataFound.find(abd => abd.ino === this.audiobooks[i].ino)
|
||||
var audiobook = this.audiobooks[i]
|
||||
var dataFound = audiobookDataFound.find(abd => abd.ino === audiobook.ino)
|
||||
if (!dataFound) {
|
||||
Logger.info(`[Scanner] Removing audiobook "${this.audiobooks[i].title}" - no longer in dir`)
|
||||
var audiobookJSON = this.audiobooks[i].toJSONMinified()
|
||||
await this.db.removeEntity('audiobook', this.audiobooks[i].id)
|
||||
scanResults.removed++
|
||||
this.emitter('audiobook_removed', audiobookJSON)
|
||||
Logger.info(`[Scanner] Audiobook "${audiobook.title}" is missing`)
|
||||
audiobook.isMissing = true
|
||||
audiobook.lastUpdate = Date.now()
|
||||
scanResults.missing++
|
||||
await this.db.updateAudiobook(audiobook)
|
||||
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||
}
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
@@ -247,7 +256,7 @@ class Scanner {
|
||||
}
|
||||
}
|
||||
const scanElapsed = Math.floor((Date.now() - scanStart) / 1000)
|
||||
Logger.info(`[Scanned] Finished | ${scanResults.added} added | ${scanResults.updated} updated | ${scanResults.removed} removed | elapsed: ${secondsToTimestamp(scanElapsed)}`)
|
||||
Logger.info(`[Scanned] Finished | ${scanResults.added} added | ${scanResults.updated} updated | ${scanResults.removed} removed | ${scanResults.missing} missing | elapsed: ${secondsToTimestamp(scanElapsed)}`)
|
||||
return scanResults
|
||||
}
|
||||
|
||||
@@ -308,7 +317,11 @@ class Scanner {
|
||||
async filesChanged(filepaths) {
|
||||
if (!filepaths.length) return ScanResult.NOTHING
|
||||
var relfilepaths = filepaths.map(path => path.replace(this.AudiobookPath, ''))
|
||||
var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths)
|
||||
var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths, true)
|
||||
|
||||
|
||||
Logger.debug(`[Scanner] fileGroupings `, filepaths, fileGroupings)
|
||||
|
||||
|
||||
var results = []
|
||||
for (const dir in fileGroupings) {
|
||||
@@ -376,6 +389,39 @@ class Scanner {
|
||||
}
|
||||
}
|
||||
|
||||
async saveMetadata(audiobookId) {
|
||||
if (audiobookId) {
|
||||
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
||||
if (!audiobook) {
|
||||
return {
|
||||
error: 'Audiobook not found'
|
||||
}
|
||||
}
|
||||
var savedPath = await audiobook.writeNfoFile()
|
||||
return {
|
||||
audiobookId,
|
||||
audiobookTitle: audiobook.title,
|
||||
savedPath
|
||||
}
|
||||
} else {
|
||||
var response = {
|
||||
success: 0,
|
||||
failed: 0
|
||||
}
|
||||
for (let i = 0; i < this.db.audiobooks.length; i++) {
|
||||
var audiobook = this.db.audiobooks[i]
|
||||
var savedPath = await audiobook.writeNfoFile()
|
||||
if (savedPath) {
|
||||
Logger.info(`[Scanner] Saved metadata nfo ${savedPath}`)
|
||||
response.success++
|
||||
} else {
|
||||
response.failed++
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
async find(req, res) {
|
||||
var method = req.params.method
|
||||
var query = req.query
|
||||
|
||||
125
server/Server.js
125
server/Server.js
@@ -4,6 +4,7 @@ const http = require('http')
|
||||
const SocketIO = require('socket.io')
|
||||
const fs = require('fs-extra')
|
||||
const fileUpload = require('express-fileupload')
|
||||
const rateLimit = require('express-rate-limit')
|
||||
|
||||
const Auth = require('./Auth')
|
||||
const Watcher = require('./Watcher')
|
||||
@@ -14,6 +15,7 @@ const HlsController = require('./HlsController')
|
||||
const StreamManager = require('./StreamManager')
|
||||
const RssFeeds = require('./RssFeeds')
|
||||
const DownloadManager = require('./DownloadManager')
|
||||
// const EbookReader = require('./EbookReader')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
class Server {
|
||||
@@ -35,8 +37,9 @@ class Server {
|
||||
this.streamManager = new StreamManager(this.db, this.MetadataPath)
|
||||
this.rssFeeds = new RssFeeds(this.Port, this.db)
|
||||
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
|
||||
this.apiController = new ApiController(this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.MetadataPath)
|
||||
this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.emitter.bind(this), this.clientEmitter.bind(this))
|
||||
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
|
||||
// this.ebookReader = new EbookReader(this.db, this.MetadataPath, this.AudiobookPath)
|
||||
|
||||
this.server = null
|
||||
this.io = null
|
||||
@@ -108,10 +111,20 @@ class Server {
|
||||
this.scanner.cancelScan = true
|
||||
}
|
||||
|
||||
// Generates an NFO metadata file, if no audiobookId is passed then all audiobooks are done
|
||||
async saveMetadata(socket, audiobookId = null) {
|
||||
Logger.info('[Server] Starting save metadata files')
|
||||
var response = await this.scanner.saveMetadata(audiobookId)
|
||||
Logger.info(`[Server] Finished saving metadata files Successful: ${response.success}, Failed: ${response.failed}`)
|
||||
socket.emit('save_metadata_complete', response)
|
||||
}
|
||||
|
||||
async init() {
|
||||
Logger.info('[Server] Init')
|
||||
await this.streamManager.ensureStreamsDir()
|
||||
await this.streamManager.removeOrphanStreams()
|
||||
await this.downloadManager.removeOrphanDownloads()
|
||||
|
||||
await this.db.init()
|
||||
this.auth.init()
|
||||
|
||||
@@ -123,9 +136,68 @@ class Server {
|
||||
this.auth.authMiddleware(req, res, next)
|
||||
}
|
||||
|
||||
async handleUpload(req, res) {
|
||||
if (!req.user.canUpload) {
|
||||
Logger.warn('User attempted to upload without permission', req.user)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
var files = Object.values(req.files)
|
||||
var title = req.body.title
|
||||
var author = req.body.author
|
||||
var series = req.body.series
|
||||
|
||||
if (!files.length || !title || !author) {
|
||||
return res.json({
|
||||
error: 'Invalid post data received'
|
||||
})
|
||||
}
|
||||
|
||||
var outputDirectory = ''
|
||||
if (series && series.length && series !== 'null') {
|
||||
outputDirectory = Path.join(this.AudiobookPath, author, series, title)
|
||||
} else {
|
||||
outputDirectory = Path.join(this.AudiobookPath, author, title)
|
||||
}
|
||||
|
||||
var exists = await fs.pathExists(outputDirectory)
|
||||
if (exists) {
|
||||
Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`)
|
||||
return res.json({
|
||||
error: `Directory "${outputDirectory}" already exists`
|
||||
})
|
||||
}
|
||||
|
||||
await fs.ensureDir(outputDirectory)
|
||||
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
var file = files[i]
|
||||
|
||||
var path = Path.join(outputDirectory, file.name)
|
||||
await file.mv(path).catch((error) => {
|
||||
Logger.error('Failed to move file', path, error)
|
||||
})
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// First time login rate limit is hit
|
||||
loginLimitReached(req, res, options) {
|
||||
Logger.error(`[Server] Login rate limit (${options.max}) was hit for ip ${req.ip}`)
|
||||
options.message = 'Too many attempts. Login temporarily locked.'
|
||||
}
|
||||
|
||||
getLoginRateLimiter() {
|
||||
return rateLimit({
|
||||
windowMs: this.db.serverSettings.rateLimitLoginWindow, // 5 minutes
|
||||
max: this.db.serverSettings.rateLimitLoginRequests,
|
||||
skipSuccessfulRequests: true,
|
||||
onLimitReached: this.loginLimitReached
|
||||
})
|
||||
}
|
||||
|
||||
async start() {
|
||||
Logger.info('=== Starting Server ===')
|
||||
|
||||
await this.init()
|
||||
|
||||
const app = express()
|
||||
@@ -144,6 +216,8 @@ class Server {
|
||||
app.use(express.static(this.AudiobookPath))
|
||||
}
|
||||
|
||||
app.use('/metadata', this.authMiddleware.bind(this), express.static(this.MetadataPath))
|
||||
|
||||
app.use(express.static(this.MetadataPath))
|
||||
app.use(express.static(Path.join(global.appRoot, 'static')))
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
@@ -151,53 +225,28 @@ class Server {
|
||||
|
||||
// Dynamic routes are not generated on client
|
||||
app.get('/audiobook/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
app.get('/library/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
app.get('/library', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
|
||||
|
||||
app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
|
||||
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
|
||||
// app.use('/hls', this.hlsController.router)
|
||||
app.use('/feeds', this.rssFeeds.router)
|
||||
|
||||
app.post('/upload', this.authMiddleware.bind(this), async (req, res) => {
|
||||
var files = Object.values(req.files)
|
||||
var title = req.body.title
|
||||
var author = req.body.author
|
||||
var series = req.body.series
|
||||
// Incomplete work in progress
|
||||
// app.use('/ebook', this.ebookReader.router)
|
||||
// app.use('/feeds', this.rssFeeds.router)
|
||||
|
||||
if (!files.length || !title || !author) {
|
||||
return res.json({
|
||||
error: 'Invalid post data received'
|
||||
})
|
||||
}
|
||||
app.post('/upload', this.authMiddleware.bind(this), this.handleUpload.bind(this))
|
||||
|
||||
var outputDirectory = ''
|
||||
if (series && series.length && series !== 'null') {
|
||||
outputDirectory = Path.join(this.AudiobookPath, author, series, title)
|
||||
} else {
|
||||
outputDirectory = Path.join(this.AudiobookPath, author, title)
|
||||
}
|
||||
var loginRateLimiter = this.getLoginRateLimiter()
|
||||
app.post('/login', loginRateLimiter, (req, res) => this.auth.login(req, res))
|
||||
|
||||
await fs.ensureDir(outputDirectory)
|
||||
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
var file = files[i]
|
||||
|
||||
var path = Path.join(outputDirectory, file.name)
|
||||
await file.mv(path).catch((error) => {
|
||||
Logger.error('Failed to move file', path, error)
|
||||
})
|
||||
}
|
||||
res.sendStatus(200)
|
||||
})
|
||||
|
||||
app.post('/login', (req, res) => this.auth.login(req, res))
|
||||
app.post('/logout', this.logout.bind(this))
|
||||
|
||||
app.get('/ping', (req, res) => {
|
||||
Logger.info('Recieved ping')
|
||||
res.json({ success: true })
|
||||
})
|
||||
|
||||
|
||||
// Used in development to set-up streams without authentication
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
app.use('/test-hls', this.hlsController.router)
|
||||
@@ -211,7 +260,6 @@ class Server {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
this.server.listen(this.Port, this.Host, () => {
|
||||
Logger.info(`Running on http://${this.Host}:${this.Port}`)
|
||||
})
|
||||
@@ -239,6 +287,7 @@ class Server {
|
||||
socket.on('scan', this.scan.bind(this))
|
||||
socket.on('scan_covers', this.scanCovers.bind(this))
|
||||
socket.on('cancel_scan', this.cancelScan.bind(this))
|
||||
socket.on('save_metadata', (audiobookId) => this.saveMetadata(socket, audiobookId))
|
||||
|
||||
// Streaming
|
||||
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
const Stream = require('./objects/Stream')
|
||||
const StreamTest = require('./test/StreamTest')
|
||||
// const StreamTest = require('./test/StreamTest')
|
||||
const Logger = require('./Logger')
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
|
||||
class StreamManager {
|
||||
constructor(db, STREAM_PATH) {
|
||||
constructor(db, MetadataPath) {
|
||||
this.db = db
|
||||
|
||||
this.MetadataPath = MetadataPath
|
||||
this.streams = []
|
||||
this.streamPath = STREAM_PATH
|
||||
this.StreamsPath = Path.join(this.MetadataPath, 'streams')
|
||||
}
|
||||
|
||||
get audiobooks() {
|
||||
@@ -25,7 +26,7 @@ class StreamManager {
|
||||
}
|
||||
|
||||
async openStream(client, audiobook) {
|
||||
var stream = new Stream(this.streamPath, client, audiobook)
|
||||
var stream = new Stream(this.StreamsPath, client, audiobook)
|
||||
|
||||
stream.on('closed', () => {
|
||||
this.removeStream(stream)
|
||||
@@ -44,29 +45,54 @@ class StreamManager {
|
||||
return stream
|
||||
}
|
||||
|
||||
ensureStreamsDir() {
|
||||
return fs.ensureDir(this.StreamsPath)
|
||||
}
|
||||
|
||||
removeOrphanStreamFiles(streamId) {
|
||||
try {
|
||||
var streamPath = Path.join(this.streamPath, streamId)
|
||||
return fs.remove(streamPath)
|
||||
var StreamsPath = Path.join(this.StreamsPath, streamId)
|
||||
return fs.remove(StreamsPath)
|
||||
} catch (error) {
|
||||
Logger.debug('No orphan stream', streamId)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async removeOrphanStreams() {
|
||||
async tempCheckStrayStreams() {
|
||||
try {
|
||||
var dirs = await fs.readdir(this.streamPath)
|
||||
var dirs = await fs.readdir(this.MetadataPath)
|
||||
if (!dirs || !dirs.length) return true
|
||||
|
||||
await Promise.all(dirs.map(async (dirname) => {
|
||||
var fullPath = Path.join(this.streamPath, dirname)
|
||||
if (dirname !== 'streams' && dirname !== 'books' && dirname !== 'downloads') {
|
||||
var fullPath = Path.join(this.MetadataPath, dirname)
|
||||
Logger.warn(`Removing OLD Orphan Stream ${dirname}`)
|
||||
return fs.remove(fullPath)
|
||||
}
|
||||
}))
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.debug('No old orphan streams', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async removeOrphanStreams() {
|
||||
await this.tempCheckStrayStreams()
|
||||
try {
|
||||
var dirs = await fs.readdir(this.StreamsPath)
|
||||
if (!dirs || !dirs.length) return true
|
||||
|
||||
await Promise.all(dirs.map(async (dirname) => {
|
||||
var fullPath = Path.join(this.StreamsPath, dirname)
|
||||
Logger.info(`Removing Orphan Stream ${dirname}`)
|
||||
return fs.remove(fullPath)
|
||||
}))
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.debug('No orphan stream', streamId)
|
||||
Logger.debug('No orphan stream', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -102,21 +128,21 @@ class StreamManager {
|
||||
this.db.updateUserStream(client.user.id, null)
|
||||
}
|
||||
|
||||
async openTestStream(streamPath, audiobookId) {
|
||||
async openTestStream(StreamsPath, audiobookId) {
|
||||
Logger.info('Open Stream Test Request', audiobookId)
|
||||
var audiobook = this.audiobooks.find(ab => ab.id === audiobookId)
|
||||
var stream = new StreamTest(streamPath, audiobook)
|
||||
// var audiobook = this.audiobooks.find(ab => ab.id === audiobookId)
|
||||
// var stream = new StreamTest(StreamsPath, audiobook)
|
||||
|
||||
stream.on('closed', () => {
|
||||
console.log('Stream closed')
|
||||
})
|
||||
// stream.on('closed', () => {
|
||||
// console.log('Stream closed')
|
||||
// })
|
||||
|
||||
var playlistUri = await stream.generatePlaylist()
|
||||
stream.start()
|
||||
// var playlistUri = await stream.generatePlaylist()
|
||||
// stream.start()
|
||||
|
||||
Logger.info('Stream Playlist', playlistUri)
|
||||
Logger.info('Test Stream Opened for audiobook', audiobook.title, 'with streamId', stream.id)
|
||||
return playlistUri
|
||||
// Logger.info('Stream Playlist', playlistUri)
|
||||
// Logger.info('Test Stream Opened for audiobook', audiobook.title, 'with streamId', stream.id)
|
||||
// return playlistUri
|
||||
}
|
||||
|
||||
streamUpdate(socket, { currentTime, streamId }) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const Path = require('path')
|
||||
const { bytesPretty, elapsedPretty } = require('../utils/fileUtils')
|
||||
const { comparePaths, getIno } = require('../utils/index')
|
||||
const nfoGenerator = require('../utils/nfoGenerator')
|
||||
const Logger = require('../Logger')
|
||||
const Book = require('./Book')
|
||||
const AudioTrack = require('./AudioTrack')
|
||||
@@ -28,6 +29,9 @@ class Audiobook {
|
||||
this.book = null
|
||||
this.chapters = []
|
||||
|
||||
// Audiobook was scanned and not found
|
||||
this.isMissing = false
|
||||
|
||||
if (audiobook) {
|
||||
this.construct(audiobook)
|
||||
}
|
||||
@@ -55,6 +59,8 @@ class Audiobook {
|
||||
if (audiobook.chapters) {
|
||||
this.chapters = audiobook.chapters.map(c => ({ ...c }))
|
||||
}
|
||||
|
||||
this.isMissing = !!audiobook.isMissing
|
||||
}
|
||||
|
||||
get title() {
|
||||
@@ -101,6 +107,14 @@ class Audiobook {
|
||||
return (this.audioFiles || []).filter(af => af.invalid).map(af => ({ filename: af.filename, error: af.error || 'Unknown Error' }))
|
||||
}
|
||||
|
||||
get ebooks() {
|
||||
return this.otherFiles.filter(file => file.filetype === 'ebook')
|
||||
}
|
||||
|
||||
get hasMissingIno() {
|
||||
return !this.ino || (this.audioFiles || []).find(abf => !abf.ino) || (this.otherFiles || []).find(f => !f.ino) || (this.tracks || []).find(t => !t.ino)
|
||||
}
|
||||
|
||||
bookToJSON() {
|
||||
return this.book ? this.book.toJSON() : null
|
||||
}
|
||||
@@ -127,7 +141,8 @@ class Audiobook {
|
||||
tracks: this.tracksToJSON(),
|
||||
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
|
||||
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
|
||||
chapters: this.chapters || []
|
||||
chapters: this.chapters || [],
|
||||
isMissing: !!this.isMissing
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,8 +161,10 @@ class Audiobook {
|
||||
hasBookMatch: !!this.book,
|
||||
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
|
||||
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
|
||||
numEbooks: this.ebooks.length,
|
||||
numTracks: this.tracks.length,
|
||||
chapters: this.chapters || []
|
||||
chapters: this.chapters || [],
|
||||
isMissing: !!this.isMissing
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,10 +183,12 @@ class Audiobook {
|
||||
invalidParts: this.invalidParts,
|
||||
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
|
||||
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
|
||||
ebooks: (this.ebooks || []).map(ebook => ebook.toJSON()),
|
||||
tags: this.tags,
|
||||
book: this.bookToJSON(),
|
||||
tracks: this.tracksToJSON(),
|
||||
chapters: this.chapters || []
|
||||
chapters: this.chapters || [],
|
||||
isMissing: !!this.isMissing
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +206,8 @@ class Audiobook {
|
||||
return false
|
||||
}
|
||||
|
||||
// Update was made to add ino values, ensure they are set
|
||||
// Originally files did not store the inode value
|
||||
// this function checks all files and sets the inode
|
||||
async checkUpdateInos() {
|
||||
var hasUpdates = false
|
||||
if (!this.ino) {
|
||||
@@ -196,15 +216,55 @@ class Audiobook {
|
||||
}
|
||||
for (let i = 0; i < this.audioFiles.length; i++) {
|
||||
var af = this.audioFiles[i]
|
||||
var at = this.tracks.find(t => t.ino === af.ino)
|
||||
if (!at) {
|
||||
at = this.tracks.find(t => comparePaths(t.path, af.path))
|
||||
}
|
||||
if (!af.ino || af.ino === this.ino) {
|
||||
af.ino = await getIno(af.fullPath)
|
||||
if (!af.ino) {
|
||||
Logger.error('[Audiobook] checkUpdateInos: Failed to set ino for audio file', af.fullPath)
|
||||
} else {
|
||||
var track = this.tracks.find(t => comparePaths(t.path, af.path))
|
||||
if (track) {
|
||||
track.ino = af.ino
|
||||
Logger.debug(`[Audiobook] Set INO For audio file ${af.path}`)
|
||||
if (at) at.ino = af.ino
|
||||
}
|
||||
hasUpdates = true
|
||||
} else if (at && at.ino !== af.ino) {
|
||||
at.ino = af.ino
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < this.tracks.length; i++) {
|
||||
var at = this.tracks[i]
|
||||
if (!at.ino) {
|
||||
Logger.debug(`[Audiobook] Track ${at.filename} still does not have ino`)
|
||||
var atino = await getIno(at.fullPath)
|
||||
var af = this.audioFiles.find(_af => _af.ino === atino)
|
||||
if (!af) {
|
||||
Logger.debug(`[Audiobook] Track ${at.filename} no matching audio file with ino ${atino}`)
|
||||
af = this.audioFiles.find(_af => _af.filename === at.filename)
|
||||
if (!af) {
|
||||
Logger.debug(`[Audiobook] Track ${at.filename} no matching audio file with filename`)
|
||||
} else {
|
||||
Logger.debug(`[Audiobook] Track ${at.filename} found matching filename but mismatch ino ${atino}/${af.ino}`)
|
||||
// at.ino = af.ino
|
||||
// at.path = af.path
|
||||
// at.fullPath = af.fullPath
|
||||
// hasUpdates = true
|
||||
}
|
||||
} else {
|
||||
Logger.debug(`[Audiobook] Track ${at.filename} found audio file with matching ino ${at.path}/${af.path}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < this.otherFiles.length; i++) {
|
||||
var file = this.otherFiles[i]
|
||||
if (!file.ino || file.ino === this.ino) {
|
||||
file.ino = await getIno(file.fullPath)
|
||||
if (!file.ino) {
|
||||
Logger.error('[Audiobook] checkUpdateInos: Failed to set ino for other file', file.fullPath)
|
||||
} else {
|
||||
Logger.debug(`[Audiobook] Set INO For other file ${file.path}`)
|
||||
}
|
||||
hasUpdates = true
|
||||
}
|
||||
@@ -280,6 +340,12 @@ class Audiobook {
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
// Cover Url may be the same, this ensures the lastUpdate is updated
|
||||
updateBookCover(cover) {
|
||||
if (!this.book) return false
|
||||
return this.book.updateCover(cover)
|
||||
}
|
||||
|
||||
updateAudioTracks(orderedFileData) {
|
||||
var index = 1
|
||||
this.audioFiles = orderedFileData.map((fileData) => {
|
||||
@@ -315,6 +381,11 @@ class Audiobook {
|
||||
this.audioFiles = this.audioFiles.filter(f => f.ino !== audioFile.ino)
|
||||
}
|
||||
|
||||
removeAudioTrack(track) {
|
||||
this.tracks = this.tracks.filter(t => t.ino !== track.ino)
|
||||
this.audioFiles = this.audioFiles.filter(f => f.ino !== track.ino)
|
||||
}
|
||||
|
||||
checkUpdateMissingParts() {
|
||||
var currMissingParts = (this.missingParts || []).join(',') || ''
|
||||
|
||||
@@ -349,6 +420,7 @@ class Audiobook {
|
||||
var newOtherFilePaths = newOtherFiles.map(f => f.path)
|
||||
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
|
||||
|
||||
// TODO: Should use inode
|
||||
newOtherFiles.forEach((file) => {
|
||||
var existingOtherFile = this.otherFiles.find(f => f.path === file.path)
|
||||
if (!existingOtherFile) {
|
||||
@@ -411,9 +483,12 @@ class Audiobook {
|
||||
return this.audioFiles.find(af => af.ino === ino)
|
||||
}
|
||||
|
||||
getAudioFileByPath(fullPath) {
|
||||
return this.audioFiles.find(af => af.fullPath === fullPath)
|
||||
}
|
||||
|
||||
setChapters() {
|
||||
// If 1 audio file without chapters, then no chapters will be set
|
||||
|
||||
var includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
|
||||
if (includedAudioFiles.length === 1) {
|
||||
// 1 audio file with chapters
|
||||
@@ -449,12 +524,16 @@ class Audiobook {
|
||||
id: currChapterId++,
|
||||
start: currStartTime,
|
||||
end: currStartTime + file.duration,
|
||||
title: `Chapter ${currChapterId}`
|
||||
title: file.filename ? Path.basename(file.filename, Path.extname(file.filename)) : `Chapter ${currChapterId}`
|
||||
})
|
||||
currStartTime += file.duration
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
writeNfoFile(nfoFilename = 'metadata.nfo') {
|
||||
return nfoGenerator(this, nfoFilename)
|
||||
}
|
||||
}
|
||||
module.exports = Audiobook
|
||||
@@ -1,3 +1,4 @@
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const parseAuthors = require('../utils/parseAuthors')
|
||||
@@ -18,6 +19,7 @@ class Book {
|
||||
this.description = null
|
||||
this.cover = null
|
||||
this.genres = []
|
||||
this.lastUpdate = null
|
||||
|
||||
if (book) {
|
||||
this.construct(book)
|
||||
@@ -45,6 +47,7 @@ class Book {
|
||||
this.description = book.description
|
||||
this.cover = book.cover
|
||||
this.genres = book.genres
|
||||
this.lastUpdate = book.lastUpdate || Date.now()
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
@@ -62,7 +65,8 @@ class Book {
|
||||
publisher: this.publisher,
|
||||
description: this.description,
|
||||
cover: this.cover,
|
||||
genres: this.genres
|
||||
genres: this.genres,
|
||||
lastUpdate: this.lastUpdate
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +101,7 @@ class Book {
|
||||
this.description = data.description || null
|
||||
this.cover = data.cover || null
|
||||
this.genres = data.genres || []
|
||||
this.lastUpdate = Date.now()
|
||||
|
||||
if (data.author) {
|
||||
this.setParseAuthor(this.author)
|
||||
@@ -145,13 +150,28 @@ class Book {
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
updateCover(cover) {
|
||||
if (!cover) return false
|
||||
if (!cover.startsWith('http:') && !cover.startsWith('https:')) {
|
||||
cover = Path.normalize(cover)
|
||||
}
|
||||
this.cover = cover
|
||||
this.lastUpdate = Date.now()
|
||||
return true
|
||||
}
|
||||
|
||||
// If audiobook directory path was changed, check and update properties set from dirnames
|
||||
// May be worthwhile checking if these were manually updated and not override manual updates
|
||||
syncPathsUpdated(audiobookData) {
|
||||
var keysToSync = ['author', 'title', 'series', 'publishYear']
|
||||
var keysToSync = ['author', 'title', 'series', 'publishYear', 'volumeNumber']
|
||||
var syncPayload = {}
|
||||
keysToSync.forEach((key) => {
|
||||
if (audiobookData[key]) syncPayload[key] = audiobookData[key]
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
const { CoverDestination } = require('../utils/constants')
|
||||
|
||||
class ServerSettings {
|
||||
constructor(settings) {
|
||||
this.id = 'server-settings'
|
||||
|
||||
this.autoTagNew = false
|
||||
this.newTagExpireDays = 15
|
||||
this.scannerParseSubtitle = false
|
||||
this.coverDestination = CoverDestination.METADATA
|
||||
this.saveMetadataFile = false
|
||||
this.rateLimitLoginRequests = 10
|
||||
this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes
|
||||
|
||||
if (settings) {
|
||||
this.construct(settings)
|
||||
@@ -14,6 +21,10 @@ class ServerSettings {
|
||||
this.autoTagNew = settings.autoTagNew
|
||||
this.newTagExpireDays = settings.newTagExpireDays
|
||||
this.scannerParseSubtitle = settings.scannerParseSubtitle
|
||||
this.coverDestination = settings.coverDestination || CoverDestination.METADATA
|
||||
this.saveMetadataFile = !!settings.saveMetadataFile
|
||||
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
|
||||
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
@@ -21,7 +32,11 @@ class ServerSettings {
|
||||
id: this.id,
|
||||
autoTagNew: this.autoTagNew,
|
||||
newTagExpireDays: this.newTagExpireDays,
|
||||
scannerParseSubtitle: this.scannerParseSubtitle
|
||||
scannerParseSubtitle: this.scannerParseSubtitle,
|
||||
coverDestination: this.coverDestination,
|
||||
saveMetadataFile: !!this.saveMetadataFile,
|
||||
rateLimitLoginRequests: this.rateLimitLoginRequests,
|
||||
rateLimitLoginWindow: this.rateLimitLoginWindow
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -171,13 +171,11 @@ class Stream extends EventEmitter {
|
||||
this.furthestSegmentCreated = lastSegment
|
||||
}
|
||||
|
||||
// console.log('SORT', [...this.segmentsCreated].slice(0, 200).join(', '), segments.slice(0, 200).join(', '))
|
||||
segments.forEach((seg) => {
|
||||
if (!current_chunk.length || last_seg_in_chunk + 1 === seg) {
|
||||
last_seg_in_chunk = seg
|
||||
current_chunk.push(seg)
|
||||
} else {
|
||||
// console.log('Last Seg is not equal to - 1', last_seg_in_chunk, seg)
|
||||
if (current_chunk.length === 1) chunks.push(current_chunk[0])
|
||||
else chunks.push(`${current_chunk[0]}-${current_chunk[current_chunk.length - 1]}`)
|
||||
last_seg_in_chunk = seg
|
||||
@@ -191,7 +189,7 @@ class Stream extends EventEmitter {
|
||||
|
||||
var perc = (this.segmentsCreated.size * 100 / this.numSegments).toFixed(2) + '%'
|
||||
Logger.info('[STREAM-CHECK] Check Files', this.segmentsCreated.size, 'of', this.numSegments, perc, `Furthest Segment: ${this.furthestSegmentCreated}`)
|
||||
Logger.info('[STREAM-CHECK] Chunks', chunks.join(', '))
|
||||
Logger.debug('[STREAM-CHECK] Chunks', chunks.join(', '))
|
||||
|
||||
this.socket.emit('stream_progress', {
|
||||
stream: this.id,
|
||||
@@ -200,7 +198,7 @@ class Stream extends EventEmitter {
|
||||
numSegments: this.numSegments
|
||||
})
|
||||
} catch (error) {
|
||||
Logger.error('Failed checkign files', error)
|
||||
Logger.error('Failed checking files', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +286,7 @@ class Stream extends EventEmitter {
|
||||
} else {
|
||||
Logger.error('Ffmpeg Err', err.message)
|
||||
}
|
||||
clearInterval(this.loop)
|
||||
})
|
||||
|
||||
this.ffmpeg.on('end', (stdout, stderr) => {
|
||||
@@ -300,6 +299,7 @@ class Stream extends EventEmitter {
|
||||
}
|
||||
this.isTranscodeComplete = true
|
||||
this.ffmpeg = null
|
||||
clearInterval(this.loop)
|
||||
})
|
||||
|
||||
this.ffmpeg.run()
|
||||
|
||||
@@ -9,6 +9,7 @@ class User {
|
||||
this.stream = null
|
||||
this.token = null
|
||||
this.isActive = true
|
||||
this.isLocked = false
|
||||
this.createdAt = null
|
||||
this.audiobooks = null
|
||||
|
||||
@@ -32,6 +33,9 @@ class User {
|
||||
get canDownload() {
|
||||
return !!this.permissions.download && this.isActive
|
||||
}
|
||||
get canUpload() {
|
||||
return !!this.permissions.upload && this.isActive
|
||||
}
|
||||
|
||||
getDefaultUserSettings() {
|
||||
return {
|
||||
@@ -47,7 +51,8 @@ class User {
|
||||
return {
|
||||
download: true,
|
||||
update: true,
|
||||
delete: this.id === 'root'
|
||||
delete: this.type === 'root',
|
||||
upload: this.type === 'root' || this.type === 'admin'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +77,7 @@ class User {
|
||||
token: this.token,
|
||||
audiobooks: this.audiobooksToJSON(),
|
||||
isActive: this.isActive,
|
||||
isLocked: this.isLocked,
|
||||
createdAt: this.createdAt,
|
||||
settings: this.settings,
|
||||
permissions: this.permissions
|
||||
@@ -87,6 +93,7 @@ class User {
|
||||
token: this.token,
|
||||
audiobooks: this.audiobooksToJSON(),
|
||||
isActive: this.isActive,
|
||||
isLocked: this.isLocked,
|
||||
createdAt: this.createdAt,
|
||||
settings: this.settings,
|
||||
permissions: this.permissions
|
||||
@@ -108,10 +115,13 @@ class User {
|
||||
}
|
||||
}
|
||||
}
|
||||
this.isActive = (user.isActive === undefined || user.id === 'root') ? true : !!user.isActive
|
||||
this.isActive = (user.isActive === undefined || user.type === 'root') ? true : !!user.isActive
|
||||
this.isLocked = user.type === 'root' ? false : !!user.isLocked
|
||||
this.createdAt = user.createdAt || Date.now()
|
||||
this.settings = user.settings || this.getDefaultUserSettings()
|
||||
this.permissions = user.permissions || this.getDefaultUserPermissions()
|
||||
// Upload permission added v1.1.13, make sure root user has upload permissions
|
||||
if (this.type === 'root' && !this.permissions.upload) this.permissions.upload = true
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
|
||||
@@ -85,10 +85,12 @@ function getTrackNumberFromFilename(title, author, series, publishYear, filename
|
||||
|
||||
async function scanAudioFiles(audiobook, newAudioFiles) {
|
||||
if (!newAudioFiles || !newAudioFiles.length) {
|
||||
Logger.error('[AudioFileScanner] Scan Audio Files no files', audiobook.title)
|
||||
Logger.error('[AudioFileScanner] Scan Audio Files no new files', audiobook.title)
|
||||
return
|
||||
}
|
||||
var tracks = []
|
||||
var numDuplicateTracks = 0
|
||||
var numInvalidTracks = 0
|
||||
for (let i = 0; i < newAudioFiles.length; i++) {
|
||||
var audioFile = newAudioFiles[i]
|
||||
var scanData = await scan(audioFile.fullPath)
|
||||
@@ -118,17 +120,19 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
|
||||
if (newAudioFiles.length > 1) {
|
||||
trackNumber = isNumber(trackNumFromMeta) ? trackNumFromMeta : trackNumFromFilename
|
||||
if (trackNumber === null) {
|
||||
Logger.error('[AudioFileScanner] Invalid track number for', audioFile.filename)
|
||||
Logger.debug('[AudioFileScanner] Invalid track number for', audioFile.filename)
|
||||
audioFile.invalid = true
|
||||
audioFile.error = 'Failed to get track number'
|
||||
numInvalidTracks++
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (tracks.find(t => t.index === trackNumber)) {
|
||||
Logger.error('[AudioFileScanner] Duplicate track number for', audioFile.filename)
|
||||
Logger.debug('[AudioFileScanner] Duplicate track number for', audioFile.filename)
|
||||
audioFile.invalid = true
|
||||
audioFile.error = 'Duplicate track number'
|
||||
numDuplicateTracks++
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -141,6 +145,13 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
|
||||
return
|
||||
}
|
||||
|
||||
if (numDuplicateTracks > 0) {
|
||||
Logger.warn(`[AudioFileScanner] ${numDuplicateTracks} Duplicate tracks for "${audiobook.title}"`)
|
||||
}
|
||||
if (numInvalidTracks > 0) {
|
||||
Logger.error(`[AudioFileScanner] ${numDuplicateTracks} Invalid tracks for "${audiobook.title}"`)
|
||||
}
|
||||
|
||||
tracks.sort((a, b) => a.index - b.index)
|
||||
audiobook.audioFiles.sort((a, b) => {
|
||||
var aNum = isNumber(a.trackNumFromMeta) ? a.trackNumFromMeta : isNumber(a.trackNumFromFilename) ? a.trackNumFromFilename : 0
|
||||
|
||||
@@ -4,4 +4,9 @@ module.exports.ScanResult = {
|
||||
UPDATED: 2,
|
||||
REMOVED: 3,
|
||||
UPTODATE: 4
|
||||
}
|
||||
|
||||
module.exports.CoverDestination = {
|
||||
METADATA: 0,
|
||||
AUDIOBOOK: 1
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
const Ffmpeg = require('fluent-ffmpeg')
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (process.env.FFMPEG_PATH) {
|
||||
Ffmpeg.setFfmpegPath(process.env.FFMPEG_PATH)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,9 +29,10 @@ function bytesPretty(bytes, decimals = 0) {
|
||||
return '0 Bytes'
|
||||
}
|
||||
const k = 1024
|
||||
const dm = decimals < 0 ? 0 : decimals
|
||||
var dm = decimals < 0 ? 0 : decimals
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
if (i > 2 && dm === 0) dm = 1
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
|
||||
}
|
||||
module.exports.bytesPretty = bytesPretty
|
||||
|
||||
@@ -62,4 +62,8 @@ module.exports.getIno = (path) => {
|
||||
Logger.error('[Utils] Failed to get ino for path', path, err)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
module.exports.isAcceptableCoverMimeType = (mimeType) => {
|
||||
return mimeType && mimeType.startsWith('image/')
|
||||
}
|
||||
91
server/utils/nfoGenerator.js
Normal file
91
server/utils/nfoGenerator.js
Normal file
@@ -0,0 +1,91 @@
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
const { bytesPretty } = require('./fileUtils')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const LEFT_COL_LEN = 25
|
||||
|
||||
function sectionHeaderLines(title) {
|
||||
return [title, ''.padEnd(10, '=')]
|
||||
}
|
||||
|
||||
function generateSection(sectionTitle, sectionData) {
|
||||
var lines = sectionHeaderLines(sectionTitle)
|
||||
for (const key in sectionData) {
|
||||
var line = key.padEnd(LEFT_COL_LEN) + (sectionData[key] || '')
|
||||
lines.push(line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
async function generate(audiobook, nfoFilename = 'metadata.nfo') {
|
||||
var jsonObj = audiobook.toJSON()
|
||||
var book = jsonObj.book
|
||||
|
||||
var generalSectionData = {
|
||||
'Title': book.title,
|
||||
'Subtitle': book.subtitle,
|
||||
'Author': book.author,
|
||||
'Narrator': book.narrarator,
|
||||
'Series': book.series,
|
||||
'Volume Number': book.volumeNumber,
|
||||
'Publish Year': book.publishYear,
|
||||
'Genre': book.genres ? book.genres.join(', ') : '',
|
||||
'Duration': audiobook.durationPretty,
|
||||
'Chapters': jsonObj.chapters.length
|
||||
}
|
||||
|
||||
if (!book.subtitle) {
|
||||
delete generalSectionData['Subtitle']
|
||||
}
|
||||
|
||||
if (!book.series) {
|
||||
delete generalSectionData['Series']
|
||||
delete generalSectionData['Volume Number']
|
||||
}
|
||||
|
||||
var tracks = audiobook.tracks
|
||||
var audioTrack = tracks.length ? audiobook.tracks[0] : {}
|
||||
|
||||
var totalBitrate = 0
|
||||
var numBitrates = 0
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
if (tracks[i].bitRate) {
|
||||
totalBitrate += tracks[i].bitRate
|
||||
numBitrates++
|
||||
}
|
||||
}
|
||||
var averageBitrate = numBitrates ? totalBitrate / numBitrates : 0
|
||||
|
||||
var mediaSectionData = {
|
||||
'Tracks': jsonObj.tracks.length,
|
||||
'Size': audiobook.sizePretty,
|
||||
'Codec': audioTrack.codec,
|
||||
'Ext': audioTrack.ext,
|
||||
'Channels': audioTrack.channels,
|
||||
'Channel Layout': audioTrack.channelLayout,
|
||||
'Average Bitrate': bytesPretty(averageBitrate)
|
||||
}
|
||||
|
||||
var bookSection = generateSection('Book Info', generalSectionData)
|
||||
|
||||
var descriptionSection = null
|
||||
if (book.description) {
|
||||
descriptionSection = sectionHeaderLines('Book Description')
|
||||
descriptionSection.push(book.description)
|
||||
}
|
||||
|
||||
var mediaSection = generateSection('Media Info', mediaSectionData)
|
||||
|
||||
var fullFile = bookSection.join('\n') + '\n\n'
|
||||
if (descriptionSection) fullFile += descriptionSection.join('\n') + '\n\n'
|
||||
fullFile += mediaSection.join('\n')
|
||||
|
||||
var nfoPath = Path.join(audiobook.fullPath, nfoFilename)
|
||||
var relativePath = Path.join(audiobook.path, nfoFilename)
|
||||
return fs.writeFile(nfoPath, fullFile).then(() => relativePath).catch((error) => {
|
||||
Logger.error(`Failed to write nfo file ${error}`)
|
||||
return false
|
||||
})
|
||||
}
|
||||
module.exports = generate
|
||||
@@ -1,6 +1,7 @@
|
||||
const Path = require('path')
|
||||
const dir = require('node-dir')
|
||||
const Logger = require('../Logger')
|
||||
const { getIno } = require('./index')
|
||||
|
||||
const AUDIO_FORMATS = ['m4b', 'mp3', 'm4a']
|
||||
const INFO_FORMATS = ['nfo']
|
||||
@@ -19,11 +20,17 @@ function getPaths(path) {
|
||||
})
|
||||
}
|
||||
|
||||
function groupFilesIntoAudiobookPaths(paths) {
|
||||
function isAudioFile(path) {
|
||||
if (!path) return false
|
||||
var ext = Path.extname(path)
|
||||
if (!ext) return false
|
||||
return AUDIO_FORMATS.includes(ext.slice(1).toLowerCase())
|
||||
}
|
||||
|
||||
function groupFilesIntoAudiobookPaths(paths, useAllFileTypes = false) {
|
||||
// Step 1: Normalize path, Remove leading "/", Filter out files in root dir
|
||||
var pathsFiltered = paths.map(path => Path.normalize(path.slice(1))).filter(path => Path.parse(path).dir)
|
||||
|
||||
|
||||
// Step 2: Sort by least number of directories
|
||||
pathsFiltered.sort((a, b) => {
|
||||
var pathsA = Path.dirname(a).split(Path.sep).length
|
||||
@@ -31,25 +38,55 @@ function groupFilesIntoAudiobookPaths(paths) {
|
||||
return pathsA - pathsB
|
||||
})
|
||||
|
||||
// Step 3: Group into audiobooks
|
||||
// Step 2.5: Seperate audio files and other files (optional)
|
||||
var audioFilePaths = []
|
||||
var otherFilePaths = []
|
||||
pathsFiltered.forEach(path => {
|
||||
if (isAudioFile(path) || useAllFileTypes) audioFilePaths.push(path)
|
||||
else otherFilePaths.push(path)
|
||||
})
|
||||
|
||||
// Step 3: Group audio files in audiobooks
|
||||
var audiobookGroup = {}
|
||||
pathsFiltered.forEach((path) => {
|
||||
audioFilePaths.forEach((path) => {
|
||||
var dirparts = Path.dirname(path).split(Path.sep)
|
||||
var numparts = dirparts.length
|
||||
var _path = ''
|
||||
|
||||
// Iterate over directories in path
|
||||
for (let i = 0; i < numparts; i++) {
|
||||
var dirpart = dirparts.shift()
|
||||
_path = Path.join(_path, dirpart)
|
||||
if (audiobookGroup[_path]) {
|
||||
|
||||
|
||||
if (audiobookGroup[_path]) { // Directory already has files, add file
|
||||
var relpath = Path.join(dirparts.join(Path.sep), Path.basename(path))
|
||||
audiobookGroup[_path].push(relpath)
|
||||
return
|
||||
} else if (!dirparts.length) {
|
||||
} else if (!dirparts.length) { // This is the last directory, create group
|
||||
audiobookGroup[_path] = [Path.basename(path)]
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Step 4: Add other files into audiobook groups
|
||||
otherFilePaths.forEach((path) => {
|
||||
var dirparts = Path.dirname(path).split(Path.sep)
|
||||
var numparts = dirparts.length
|
||||
var _path = ''
|
||||
|
||||
// Iterate over directories in path
|
||||
for (let i = 0; i < numparts; i++) {
|
||||
var dirpart = dirparts.shift()
|
||||
_path = Path.join(_path, dirpart)
|
||||
if (audiobookGroup[_path]) { // Directory is audiobook group
|
||||
var relpath = Path.join(dirparts.join(Path.sep), Path.basename(path))
|
||||
audiobookGroup[_path].push(relpath)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
return audiobookGroup
|
||||
}
|
||||
module.exports.groupFilesIntoAudiobookPaths = groupFilesIntoAudiobookPaths
|
||||
@@ -98,7 +135,12 @@ async function scanRootDir(abRootPath, serverSettings = {}) {
|
||||
var audiobookData = getAudiobookDataFromDir(abRootPath, audiobookPath, parseSubtitle)
|
||||
|
||||
var fileObjs = cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath])
|
||||
for (let i = 0; i < fileObjs.length; i++) {
|
||||
fileObjs[i].ino = await getIno(fileObjs[i].fullPath)
|
||||
}
|
||||
var audiobookIno = await getIno(audiobookData.fullPath)
|
||||
audiobooks.push({
|
||||
ino: audiobookIno,
|
||||
...audiobookData,
|
||||
audioFiles: fileObjs.filter(f => f.filetype === 'audio'),
|
||||
otherFiles: fileObjs.filter(f => f.filetype !== 'audio')
|
||||
@@ -119,10 +161,39 @@ function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) {
|
||||
// If there are at least 2 more directories, next furthest will be the series
|
||||
if (splitDir.length > 1) series = splitDir.pop()
|
||||
if (splitDir.length > 0) author = splitDir.pop()
|
||||
|
||||
// There could be many more directories, but only the top 3 are used for naming /author/series/title/
|
||||
|
||||
|
||||
// If in a series directory check for volume number match
|
||||
/* ACCEPTS:
|
||||
Book 2 - Title Here - Subtitle Here
|
||||
Title Here - Subtitle Here - Vol 12
|
||||
Title Here - volume 9 - Subtitle Here
|
||||
Vol. 3 Title Here - Subtitle Here
|
||||
1980 - Book 2-Title Here
|
||||
Title Here-Volume 999-Subtitle Here
|
||||
*/
|
||||
var volumeNumber = null
|
||||
if (series) {
|
||||
var volumeMatch = title.match(/(-? ?)\b((?:Book|Vol.?|Volume) (\d{1,3}))\b( ?-?)/i)
|
||||
if (volumeMatch && volumeMatch.length > 3 && volumeMatch[2] && volumeMatch[3]) {
|
||||
volumeNumber = volumeMatch[3]
|
||||
var replaceChunk = volumeMatch[2]
|
||||
|
||||
// "1980 - Book 2-Title Here"
|
||||
// Group 1 would be "- "
|
||||
// Group 3 would be "-"
|
||||
// Only remove the first group
|
||||
if (volumeMatch[1]) {
|
||||
replaceChunk = volumeMatch[1] + replaceChunk
|
||||
} else if (volumeMatch[4]) {
|
||||
replaceChunk += volumeMatch[4]
|
||||
}
|
||||
title = title.replace(replaceChunk, '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var publishYear = null
|
||||
// If Title is of format 1999 - Title, then use 1999 as publish year
|
||||
var publishYearMatch = title.match(/^([0-9]{4}) - (.+)/)
|
||||
@@ -133,7 +204,9 @@ function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Subtitle can be parsed from the title if user enabled
|
||||
// Subtitle is everything after " - "
|
||||
var subtitle = null
|
||||
if (parseSubtitle && title.includes(' - ')) {
|
||||
var splitOnSubtitle = title.split(' - ')
|
||||
@@ -146,6 +219,7 @@ function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) {
|
||||
title,
|
||||
subtitle,
|
||||
series,
|
||||
volumeNumber,
|
||||
publishYear,
|
||||
path: dir, // relative audiobook path i.e. /Author Name/Book Name/..
|
||||
fullPath: Path.join(abRootPath, dir) // i.e. /audiobook/Author Name/Book Name/..
|
||||
@@ -173,11 +247,15 @@ async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings =
|
||||
otherFiles: []
|
||||
}
|
||||
|
||||
filepaths.forEach((filepath) => {
|
||||
for (let i = 0; i < filepaths.length; i++) {
|
||||
var filepath = filepaths[i]
|
||||
|
||||
var relpath = Path.normalize(filepath).replace(abRootPath, '').slice(1)
|
||||
var extname = Path.extname(filepath)
|
||||
var basename = Path.basename(filepath)
|
||||
var ino = await getIno(filepath)
|
||||
var fileObj = {
|
||||
ino,
|
||||
filetype: getFileType(extname),
|
||||
filename: basename,
|
||||
path: relpath,
|
||||
@@ -189,7 +267,7 @@ async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings =
|
||||
} else {
|
||||
audiobook.otherFiles.push(fileObj)
|
||||
}
|
||||
})
|
||||
}
|
||||
return audiobook
|
||||
}
|
||||
module.exports.getAudiobookFileData = getAudiobookFileData
|
||||
Reference in New Issue
Block a user