Compare commits

...

70 Commits

Author SHA1 Message Date
Mark Cooper 0da327222e Fix config page not scrolling, add scroll arrows on home page, fix routing issue on back button, fix continue reading shelf 2021-09-28 17:36:41 -05:00
Mark Cooper 868e1af28a Starting point for home page 2021-09-28 06:44:40 -05:00
Mark Cooper a343a1038c Remove ino from file tables 2021-09-27 07:02:31 -05:00
Mark Cooper 3e5338ec8e Fixing scanner inodes, select all fix, starting ebook reader 2021-09-27 06:52:21 -05:00
Mark Cooper 01fdca4bf9 Fix docs link url 2021-09-26 17:21:10 -05:00
Mark Cooper ed96dd7c81 Readme update docs 2021-09-26 17:20:41 -05:00
Mark Cooper 1ead5de9f5 Hide volume number on selection mode 2021-09-26 15:44:25 -05:00
Mark Cooper 06554811e2 Series order by volume number, show volume number, keyword filter, fix overflow bug 2021-09-26 15:34:08 -05:00
Mark Cooper 9935bd2ffa Readme audiobookshelf.org 2021-09-26 12:08:48 -05:00
Mark Cooper 4260903bbe Readme update install instructions 2021-09-25 17:58:06 -05:00
Mark Cooper 64ae8ef849 linux installer use existing config 2021-09-25 17:37:21 -05:00
Mark Cooper be15f2f5a0 Fix installer 2021-09-25 17:14:06 -05:00
Mark Cooper 341de6a196 Fix linuxpackager control file 2021-09-25 17:10:02 -05:00
Mark Cooper 740f6966ba Linuxpackager create debian control file 2021-09-25 17:07:48 -05:00
Mark Cooper 2e889ff9fe Increment version, linuxpacakger executable permissions 2021-09-25 16:45:53 -05:00
Mark Cooper d123abd4cd Linuxpackager to use /dist directory 2021-09-25 16:34:36 -05:00
Mark Cooper cc84349d6d Adding linux and ppa install to readme 2021-09-25 16:23:57 -05:00
Mark Cooper b5e83d8866 Preinstall prompt more readable 2021-09-25 14:14:22 -05:00
Mark Cooper ca9521ac9a Debian package remove conf file, generate in preinst now 2021-09-25 14:05:27 -05:00
Mark Cooper 078f404fe4 Linux builder bash script 2021-09-25 14:02:50 -05:00
Mark Cooper bc47dfa343 Debian preinstall script for config and ffmpeg 2021-09-25 13:01:53 -05:00
Mark Cooper 03f39d71e3 Fix check old streams in metadata, download manager worker thread path 2021-09-25 10:35:33 -05:00
Mark Cooper 69cd6aa4d0 Update debian build 2021-09-24 20:18:02 -05:00
Mark Cooper e161f70710 Debian package builder 2021-09-24 19:37:35 -05:00
Mark Cooper 3ef0173226 build script permissions workaround 2021-09-24 17:38:35 -05:00
Mark Cooper 87a308f749 Fix build script 2021-09-24 17:26:10 -05:00
Mark Cooper f45a1c4161 Ignore dist dir 2021-09-24 17:23:40 -05:00
Mark Cooper 5e15695f64 Build scripts 2021-09-24 17:21:47 -05:00
Mark Cooper b35997e8be Update chapters modal, search page, fix version check, ignore matching audio file paths on rescan 2021-09-24 16:14:33 -05:00
Mark Cooper fcd664c16e Side rail, book group cards, fix dropdown select 2021-09-24 07:32:38 -05:00
Mark Cooper 94741598af Pkg scripts win/linux 2021-09-22 20:40:35 -05:00
Mark Cooper 6cb418a871 Book cover uploader, moving streams to /metadata/streams, adding jwt auth from query string, auth check static metadata 2021-09-21 20:57:33 -05:00
Mark Cooper d234fdea11 Fix player track tooltip overflowing page 2021-09-21 17:28:43 -05:00
Mark Cooper 13ac5f1d2a Fix package.json script 2021-09-21 16:55:32 -05:00
Mark Cooper f4d6e65380 Player track chapter tickmarks, highlight current chapter, progress filters, links in stream container 2021-09-21 16:42:01 -05:00
Mark Cooper baccbaf82a Remove production from prod script 2021-09-19 20:38:24 -05:00
Mark Cooper d6969e0b85 Update readme for running on local. Add command line arg parser. 2021-09-19 19:52:08 -05:00
Mark Cooper b3ad9c95ce Add script & file for running production without docker 2021-09-19 19:22:35 -05:00
Mark Cooper 2f6417dec2 Remove test stream, add prod script 2021-09-18 16:42:20 -05:00
Mark Cooper f54d48270e Update regex for volume scanner 2021-09-18 13:26:02 -05:00
Mark Cooper 57321084af Fix regex misplaced \b in volume parser 2021-09-18 13:09:30 -05:00
Mark Cooper 1d97422011 Readme update 2021-09-18 12:53:52 -05:00
Mark Cooper 2f2a64b89e Readme update 2021-09-18 12:53:14 -05:00
Mark Cooper b2e129eec7 Readme update 2021-09-18 12:52:38 -05:00
Mark Cooper cb79e48685 Readme Update 2021-09-18 12:50:22 -05:00
Mark Cooper e735ef7869 Readme update 2021-09-18 12:49:21 -05:00
Mark Cooper 8f1152762a Adding upload permission to users, directory structure readme update 2021-09-18 12:45:34 -05:00
Mark Cooper 587adb3773 Add volume number parsing to scanner 2021-09-18 11:13:05 -05:00
Mark Cooper db01db3a2b Missing audiobooks flagged not deleted, fix close progress loop on stream errors, clickable download toast, consolidate duplicate track error log, improved scanner to ignore non-audio files 2021-09-17 18:40:30 -05:00
Mark Cooper 0851a1e71e Fix sort by volume number, show batch read/not read update for users 2021-09-17 14:15:15 -05:00
Mark Cooper 0addfc8269 Readme upcoming features update 2021-09-16 08:44:39 -05:00
Mark Cooper b2ab5730f5 Add batch read/not read update, Update tooltip positions 2021-09-16 08:37:09 -05:00
Mark Cooper 7859d7a502 Add version checker 2021-09-15 17:59:38 -05:00
Mark Cooper 174fce9614 Adding download zip file, fix local cover art for m4b download 2021-09-14 20:45:00 -05:00
Mark Cooper 3dfd7ea035 Add audiobook uploader 2021-09-13 20:18:58 -05:00
Mark Cooper 6cb253598b Update user progress reset and delete logic 2021-09-12 18:22:52 -05:00
Mark Cooper 11f4caffa8 Add socket event to remove download, fix clearInterval on stream loop 2021-09-12 16:10:12 -05:00
Mark Cooper 80f90907d4 Update merge for m4b files, add progress event for local audiobooks 2021-09-11 19:59:48 -05:00
Mark Cooper 4e92ea3992 Update scanner v3, add isActive support for users 2021-09-10 19:55:02 -05:00
Mark Cooper ddbf678a8b Merge tracks with codec copy' 2021-09-09 05:10:55 -05:00
Mark Cooper 315de87bfc Adding chapters and downloading m4b file 2021-09-08 09:15:54 -05:00
Mark Cooper 26d922d3dc Auto add/update/remove audiobooks, update screenshots 2021-09-06 20:14:04 -05:00
Mark Cooper ee452d41ee Adding permissions per user, add volume number sort 2021-09-06 17:42:15 -05:00
Mark Cooper 1d7d2a1dac Fix details tab save 2021-09-06 16:11:37 -05:00
Mark Cooper 41c391e87b Update user audiobook progress model, add mark as read/not read, download individual tracks 2021-09-06 14:13:01 -05:00
Mark Cooper 1f2afe4d92 Editing accounts, change root account username, removed token expiration 2021-09-05 18:20:29 -05:00
Mark Cooper e534d015be Allow any utf-8 char in genre and tags, fix stream manager user undefined 2021-09-05 14:30:33 -05:00
Mark Cooper d2a2f3ff6a New filters using base64 strings, keyword filter 2021-09-05 13:21:02 -05:00
Mark Cooper af05e78cdf Add Subtitle and Narrarator fields, add server settings object, scanner to parse out subtitles 2021-09-04 19:58:39 -05:00
Mark Cooper e566c6c9d5 Improve track order detection, allow for excluding audio files from tracklist 2021-09-04 18:02:42 -05:00
112 changed files with 6456 additions and 949 deletions
+2 -1
View File
@@ -10,4 +10,5 @@ npm-debug.log
dev.js dev.js
test/ test/
/client/.nuxt/ /client/.nuxt/
/client/dist/ /client/dist/
/dist/
+2 -1
View File
@@ -7,4 +7,5 @@ node_modules/
/metadata/ /metadata/
test/ test/
/client/.nuxt/ /client/.nuxt/
/client/dist/ /client/dist/
/dist/
+8
View 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
View 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
View 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
View 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
View 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
+57
View 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"
+35 -1
View File
@@ -9,20 +9,38 @@
height: calc(100% - 64px - 165px); height: calc(100% - 64px - 165px);
max-height: calc(100% - 64px - 165px); max-height: calc(100% - 64px - 165px);
} }
#bookshelf {
height: calc(100% - 40px);
}
/* width */ /* width */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
} }
/* ::-webkit-scrollbar:horizontal { */
/* height: 16px; */
/* height: 24px;
} */
/* Track */ /* Track */
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background-color: rgba(0,0,0,0); background-color: rgba(0,0,0,0);
} }
/* ::-webkit-scrollbar-track:horizontal { */
/* background: rgb(149, 119, 90); */
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
/* background: linear-gradient(180deg, rgb(117, 88, 60) 0%, rgb(65, 41, 17) 17%, rgb(71, 43, 15) 88%, rgb(3, 2, 1) 100%);
box-shadow: 2px 14px 8px #111111aa;
} */
/* Handle */ /* Handle */
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #855620; background: #855620;
border-radius: 4px; border-radius: 4px;
} }
/* ::-webkit-scrollbar-thumb:horizontal { */
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
/* box-shadow: 2px 14px 8px #111111aa;
border-radius: 4px;
} */
/* Handle on hover */ /* Handle on hover */
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #704922; background: #704922;
@@ -83,6 +101,14 @@
box-shadow: 2px 8px 6px #111111aa; box-shadow: 2px 8px 6px #111111aa;
} }
.box-shadow-sm-up {
box-shadow: 0px -5px 8px #11111122;
}
.box-shadow-md-up {
box-shadow: 0px -8px 8px #11111144;
}
.box-shadow-lg-up { .box-shadow-lg-up {
box-shadow: 0px -12px 8px #111111ee; box-shadow: 0px -12px 8px #111111ee;
} }
@@ -93,4 +119,12 @@
.box-shadow-book { .box-shadow-book {
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166; box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
} }
.box-shadow-book3d {
box-shadow: 4px 1px 8px #11111166, 1px -4px 8px #11111166;
}
.box-shadow-side {
box-shadow: 5px 0px 5px #11111166;
}
+74 -12
View File
@@ -8,7 +8,13 @@
<p class="font-mono text-sm">{{ totalDurationPretty }}</p> <p class="font-mono text-sm">{{ totalDurationPretty }}</p>
</div> </div>
<div class="absolute right-24 top-0 bottom-0"> <div class="absolute right-20 top-0 bottom-0">
<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>
<div class="absolute right-32 top-0 bottom-0">
<controls-volume-control v-model="volume" @input="updateVolume" /> <controls-volume-control v-model="volume" @input="updateVolume" />
</div> </div>
<div class="flex my-2"> <div class="flex my-2">
@@ -47,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 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 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>
<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 --> <!-- Hover timestamp -->
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none"> <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="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
<div class="arrow-down" /> <div class="arrow-down" />
</div> </div>
@@ -58,6 +71,8 @@
</div> </div>
<audio ref="audio" @pause="paused" @playing="playing" @progress="progress" @timeupdate="timeupdate" @loadeddata="audioLoadedData" /> <audio ref="audio" @pause="paused" @playing="playing" @progress="progress" @timeupdate="timeupdate" @loadeddata="audioLoadedData" />
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
</div> </div>
</template> </template>
@@ -66,7 +81,11 @@ import Hls from 'hls.js'
export default { export default {
props: { props: {
loading: Boolean loading: Boolean,
chapters: {
type: Array,
default: () => []
}
}, },
data() { data() {
return { return {
@@ -84,7 +103,10 @@ export default {
audioEl: null, audioEl: null,
totalDuration: 0, totalDuration: 0,
seekedTime: 0, seekedTime: 0,
seekLoading: false seekLoading: false,
showChaptersModal: false,
currentTime: 0,
trackOffsetLeft: 16 // Track is 16px from edge
} }
}, },
computed: { computed: {
@@ -93,9 +115,25 @@ export default {
}, },
totalDurationPretty() { totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration) 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: { methods: {
selectChapter(chapter) {
this.seek(chapter.start)
this.showChaptersModal = false
},
seek(time) { seek(time) {
if (this.loading) { if (this.loading) {
return return
@@ -110,7 +148,7 @@ export default {
} }
this.seekedTime = time this.seekedTime = time
this.seekLoading = true this.seekLoading = true
console.warn('SEEK TO', this.$secondsToTimestamp(time))
this.audioEl.currentTime = time this.audioEl.currentTime = time
if (this.$refs.playedTrack) { if (this.$refs.playedTrack) {
@@ -152,10 +190,29 @@ export default {
if (this.$refs.hoverTimestamp) { if (this.$refs.hoverTimestamp) {
var width = this.$refs.hoverTimestamp.clientWidth var width = this.$refs.hoverTimestamp.clientWidth
this.$refs.hoverTimestamp.style.opacity = 1 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) { 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) { if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 1 this.$refs.trackCursor.style.opacity = 1
@@ -166,6 +223,9 @@ export default {
if (this.$refs.hoverTimestamp) { if (this.$refs.hoverTimestamp) {
this.$refs.hoverTimestamp.style.opacity = 0 this.$refs.hoverTimestamp.style.opacity = 0
} }
if (this.$refs.hoverTimestampArrow) {
this.$refs.hoverTimestampArrow.style.opacity = 0
}
if (this.$refs.trackCursor) { if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 0 this.$refs.trackCursor.style.opacity = 0
} }
@@ -269,7 +329,6 @@ export default {
end: end + offset end: end + offset
}) })
} }
return ranges return ranges
}, },
getLastBufferedTime() { getLastBufferedTime() {
@@ -295,7 +354,6 @@ export default {
this.bufferTrackWidth = bufferlen this.bufferTrackWidth = bufferlen
}, },
timeupdate() { timeupdate() {
// console.log('Time update', this.audioEl.currentTime)
if (!this.$refs.playedTrack) { if (!this.$refs.playedTrack) {
console.error('Invalid no played track ref') console.error('Invalid no played track ref')
return return
@@ -315,6 +373,8 @@ export default {
this.updateTimestamp() this.updateTimestamp()
this.currentTime = this.audioEl.currentTime
var perc = this.audioEl.currentTime / this.audioEl.duration var perc = this.audioEl.currentTime / this.audioEl.duration
var ptWidth = Math.round(perc * this.trackWidth) var ptWidth = Math.round(perc * this.trackWidth)
if (this.playedTrackWidth === ptWidth) { if (this.playedTrackWidth === ptWidth) {
@@ -361,7 +421,7 @@ export default {
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`) xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
} }
} }
console.log('[AudioPlayer-Set] HLS Config', hlsOptions) // console.log('[AudioPlayer-Set] HLS Config', hlsOptions)
this.hlsInstance = new Hls(hlsOptions) this.hlsInstance = new Hls(hlsOptions)
var audio = this.$refs.audio var audio = this.$refs.audio
audio.volume = this.volume audio.volume = this.volume
@@ -386,10 +446,13 @@ export default {
} }
}) })
this.hlsInstance.on(Hls.Events.DESTROYING, () => { this.hlsInstance.on(Hls.Events.DESTROYING, () => {
console.warn('[HLS] Destroying HLS Instance') console.log('[HLS] Destroying HLS Instance')
}) })
}) })
}, },
showChapters() {
this.showChaptersModal = true
},
play() { play() {
if (!this.$refs.audio) { if (!this.$refs.audio) {
console.error('No Audio ref') console.error('No Audio ref')
@@ -410,7 +473,6 @@ export default {
this.staleHlsInstance = this.hlsInstance this.staleHlsInstance = this.hlsInstance
this.staleHlsInstance.destroy() this.staleHlsInstance.destroy()
this.hlsInstance = null this.hlsInstance = null
console.log('Terminated HLS Instance', this.staleHlsInstance)
} }
}, },
async resetStream(startTime) { async resetStream(startTime) {
+93 -41
View File
@@ -1,30 +1,55 @@
<template> <template>
<div class="w-full h-16 bg-primary relative"> <div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-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"> <div class="flex h-full items-center">
<img v-if="!showBack" src="/Logo48.png" class="w-12 h-12 mr-4" /> <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"> <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> <span class="material-icons text-4xl text-white">arrow_back</span>
</a> </a>
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1> <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 /> <controls-global-search />
<div class="flex-grow" /> <div class="flex-grow" />
<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="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 ml-4">
<span class="material-icons">settings</span> <span class="material-icons">settings</span>
</nuxt-link> </nuxt-link>
<ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" /> <nuxt-link to="/account" class="relative w-32 bg-fg border border-gray-500 rounded shadow-sm ml-5 pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
<span class="flex items-center">
<span class="block truncate">{{ username }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons text-gray-100">person</span>
</span>
</nuxt-link>
</div> </div>
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center"> <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> <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" /> <div class="flex-grow" />
<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-btn color="error" small class="mx-2" :loading="processingBatchDelete" @click="batchDeleteClick"><span class="material-icons text-gray-200 pt-1">delete</span></ui-btn> <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-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
</template>
<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> <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>
</div> </div>
@@ -35,23 +60,15 @@
export default { export default {
data() { data() {
return { return {
menuItems: [
{
value: 'account',
text: 'Account',
to: '/account'
},
{
value: 'logout',
text: 'Logout'
}
],
processingBatchDelete: false processingBatchDelete: false
} }
}, },
computed: { computed: {
isHome() {
return this.$route.name === 'index'
},
showBack() { showBack() {
return this.$route.name !== 'index' return this.$route.name !== 'library-id' && !this.isHome
}, },
user() { user() {
return this.$store.state.user.user return this.$store.state.user.user
@@ -59,6 +76,7 @@ export default {
isRootUser() { isRootUser() {
return this.$store.getters['user/getIsRoot'] return this.$store.getters['user/getIsRoot']
}, },
username() { username() {
return this.user ? this.user.username : 'err' return this.user ? this.user.username : 'err'
}, },
@@ -71,31 +89,41 @@ export default {
isAllSelected() { isAllSelected() {
return this.audiobooksShowing.length === this.selectedAudiobooks.length return this.audiobooksShowing.length === this.selectedAudiobooks.length
}, },
userAudiobooks() {
return this.$store.state.user.user.audiobooks || {}
},
audiobooksShowing() { 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: { methods: {
back() { async back() {
if (this.$route.name === 'audiobook-id-edit') { var popped = await this.$store.dispatch('popRoute')
this.$router.push(`/audiobook/${this.$route.params.id}`) var backTo = popped || '/'
} else { this.$router.push(backTo)
this.$router.push('/')
}
},
logout() {
this.$axios.$post('/logout').catch((error) => {
console.error(error)
})
if (localStorage.getItem('token')) {
localStorage.removeItem('token')
}
this.$router.push('/login')
},
menuAction(action) {
if (action === 'logout') {
this.logout()
}
}, },
cancelSelectionMode() { cancelSelectionMode() {
if (this.processingBatchDelete) return if (this.processingBatchDelete) return
@@ -109,8 +137,32 @@ export default {
this.$store.commit('setSelectedAudiobooks', audiobookIds) 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() { 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.processingBatchDelete = true
this.$store.commit('setProcessingBatch', true) this.$store.commit('setProcessingBatch', true)
this.$axios this.$axios
+121 -42
View File
@@ -1,5 +1,5 @@
<template> <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 --> <!-- Cover size widget -->
<div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-20"> <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> <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,38 +10,79 @@
</div> </div>
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12"> <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> <p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
<ui-btn color="success" @click="scan">Scan your Audiobooks</ui-btn> <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>
<div class="w-full flex flex-col items-center"> <div v-else id="bookshelf" class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in groupedBooks"> <template v-for="(shelf, index) in shelves">
<div :key="index" class="w-full bookshelfRow relative"> <div :key="index" class="w-full bookshelfRow relative">
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
<template v-for="audiobook in shelf"> <template v-for="entity in shelf">
<cards-book-card :ref="`audiobookCard-${audiobook.id}`" :key="audiobook.id" :width="bookCoverWidth" :user-progress="userAudiobooks[audiobook.id]" :audiobook="audiobook" /> <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> </template>
</div> </div>
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" /> <div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
</div> </div>
</template> </template>
<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>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
props: {
page: String,
selectedSeries: String,
searchResults: {
type: Array,
default: () => []
},
searchQuery: String
},
data() { data() {
return { return {
width: 0, shelves: [],
booksPerRow: 0,
groupedBooks: [],
currFilterOrderKey: null, currFilterOrderKey: null,
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220], availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
selectedSizeIndex: 3, selectedSizeIndex: 3,
rowPaddingX: 40 rowPaddingX: 40,
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: { computed: {
keywordFilter() {
return this.$store.state.audiobooks.keywordFilter
},
userAudiobooks() { userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {} return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
}, },
@@ -65,9 +106,48 @@ export default {
}, },
isSelectionMode() { isSelectionMode() {
return this.$store.getters['getNumAudiobooksSelected'] return this.$store.getters['getNumAudiobooksSelected']
},
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: { methods: {
clickGroup(group) {
this.$emit('update:selectedSeries', group.name)
},
clearFilter() {
this.$store.commit('audiobooks/setKeywordFilter', null)
if (this.filterBy !== 'all') {
this.$store.dispatch('user/updateUserSettings', {
filterBy: 'all'
})
} else {
this.setBookshelfEntities()
}
},
checkKeywordFilter() {
clearTimeout(this.keywordFilterTimeout)
this.keywordFilterTimeout = setTimeout(() => {
this.setBookshelfEntities()
}, 500)
},
increaseSize() { increaseSize() {
this.selectedSizeIndex = Math.min(this.availableSizes.length - 1, this.selectedSizeIndex + 1) this.selectedSizeIndex = Math.min(this.availableSizes.length - 1, this.selectedSizeIndex + 1)
this.resize() this.resize()
@@ -78,59 +158,52 @@ export default {
this.resize() this.resize()
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth }) 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 groups = []
var currentRow = 0 var currentRow = 0
var currentGroup = [] var currentGroup = []
var audiobooksSorted = this.$store.getters['audiobooks/getFilteredAndSorted']() for (let i = 0; i < entities.length; i++) {
this.currFilterOrderKey = this.filterOrderKey var row = Math.floor(i / booksPerRow)
for (let i = 0; i < audiobooksSorted.length; i++) {
var row = Math.floor(i / this.booksPerRow)
if (row > currentRow) { if (row > currentRow) {
groups.push([...currentGroup]) groups.push([...currentGroup])
currentRow = row currentRow = row
currentGroup = [] currentGroup = []
} }
currentGroup.push(audiobooksSorted[i]) currentGroup.push(entities[i])
} }
if (currentGroup.length) { if (currentGroup.length) {
groups.push([...currentGroup]) groups.push([...currentGroup])
} }
this.groupedBooks = groups this.shelves = groups
}, },
calculateBookshelf() { async init() {
this.width = this.$refs.wrapper.clientWidth this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
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() {
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize') var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
var sizeIndex = this.availableSizes.findIndex((s) => s === bookshelfCoverSize) var sizeIndex = this.availableSizes.findIndex((s) => s === bookshelfCoverSize)
if (!isNaN(sizeIndex)) this.selectedSizeIndex = sizeIndex if (!isNaN(sizeIndex)) this.selectedSizeIndex = sizeIndex
this.calculateBookshelf()
var isLoading = await this.$store.dispatch('audiobooks/load')
if (!isLoading) {
this.setBookshelfEntities()
}
}, },
resize() { resize() {
this.$nextTick(() => { this.$nextTick(this.setBookshelfEntities)
this.calculateBookshelf()
this.setGroupedBooks()
})
}, },
audiobooksUpdated() { audiobooksUpdated() {
console.log('[AudioBookshelf] Audiobooks Updated') console.log('[AudioBookshelf] Audiobooks Updated')
this.setGroupedBooks() this.setBookshelfEntities()
}, },
settingsUpdated(settings) { settingsUpdated(settings) {
if (this.currFilterOrderKey !== this.filterOrderKey) { if (this.currFilterOrderKey !== this.filterOrderKey) {
this.setGroupedBooks() this.setBookshelfEntities()
} }
if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) { if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) {
var index = this.availableSizes.indexOf(settings.bookshelfCoverSize) var index = this.availableSizes.indexOf(settings.bookshelfCoverSize)
@@ -144,18 +217,24 @@ export default {
this.$root.socket.emit('scan') this.$root.socket.emit('scan')
} }
}, },
updated() {
if (this.$refs.wrapper) {
if (this.wrapperClientWidth !== this.$refs.wrapper.clientWidth) {
this.$nextTick(this.setBookshelfEntities)
}
}
},
mounted() { mounted() {
window.addEventListener('resize', this.resize)
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated }) this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated }) this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
this.$store.dispatch('audiobooks/load')
this.init() this.init()
window.addEventListener('resize', this.resize)
}, },
beforeDestroy() { beforeDestroy() {
window.removeEventListener('resize', this.resize)
this.$store.commit('audiobooks/removeListener', 'bookshelf') this.$store.commit('audiobooks/removeListener', 'bookshelf')
this.$store.commit('user/removeSettingsListener', 'bookshelf') this.$store.commit('user/removeSettingsListener', 'bookshelf')
window.removeEventListener('resize', this.resize)
} }
} }
</script> </script>
@@ -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
View 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>
+75 -9
View File
@@ -1,17 +1,48 @@
<template> <template>
<div class="w-full h-10 relative"> <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"> <div id="toolbar" class="absolute top-0 left-0 w-full h-full z-40 flex items-center px-8">
<p class="font-book">{{ numShowing }} Audiobooks</p> <template v-if="page !== 'search' && !isHome">
<div class="flex-grow" /> <p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
<controls-filter-select v-model="settings.filterBy" class="w-48 h-7.5" @change="updateFilter" /> <div v-else class="flex items-center">
<span class="px-4 text-sm">by</span> <div @click="seriesBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
<controls-order-select v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5" @change="updateOrder" /> <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-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>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
props: {
page: String,
isHome: Boolean,
selectedSeries: String,
searchResults: {
type: Array,
default: () => []
},
searchQuery: String
},
data() { data() {
return { return {
settings: {}, settings: {},
@@ -19,11 +50,47 @@ export default {
} }
}, },
computed: { computed: {
showSortFilters() {
return this.page === ''
},
numShowing() { 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() {
return this.$store.state.audiobooks.keywordFilter
},
set(val) {
this.$store.commit('audiobooks/setKeywordFilter', val)
}
} }
}, },
methods: { methods: {
searchBackArrow() {
this.$router.replace('/library')
},
seriesBackArrow() {
this.$router.replace('/library/series')
this.$emit('update:selectedSeries', null)
},
updateOrder() { updateOrder() {
this.saveSettings() this.saveSettings()
}, },
@@ -31,7 +98,6 @@ export default {
this.saveSettings() this.saveSettings()
}, },
saveSettings() { saveSettings() {
this.$store.commit('user/setSettings', this.settings) // Immediate update
this.$store.dispatch('user/updateUserSettings', this.settings) this.$store.dispatch('user/updateUserSettings', this.settings)
}, },
init() { init() {
@@ -56,6 +122,6 @@ export default {
<style> <style>
#toolbar { #toolbar {
box-shadow: 0px 8px 8px #111111aa; box-shadow: 0px 8px 6px #111111aa;
} }
</style> </style>
+85
View 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>
+21 -9
View File
@@ -1,20 +1,20 @@
<template> <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 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" /> <cards-book-cover :audiobook="streamAudiobook" :width="88" />
</div> </nuxt-link>
<div class="flex items-center pl-24"> <div class="flex items-center pl-24">
<div> <div>
<h1> <nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer">
{{ title }} <span v-if="stream" class="text-xs text-gray-400">({{ stream.id }})</span> {{ title }} <span v-if="stream && $isDev" class="text-xs text-gray-400">({{ stream.id }})</span>
</h1> </nuxt-link>
<p class="text-gray-400 text-sm">by {{ author }}</p> <p class="text-gray-400 text-sm hover:underline cursor-pointer" @click="filterByAuthor">by {{ author }}</p>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span> <span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
</div> </div>
<audio-player ref="audioPlayer" :loading="isLoading" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" /> <audio-player ref="audioPlayer" :chapters="chapters" :loading="isLoading" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
</div> </div>
</template> </template>
@@ -49,6 +49,9 @@ export default {
book() { book() {
return this.streamAudiobook ? this.streamAudiobook.book || {} : {} return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
}, },
chapters() {
return this.streamAudiobook ? this.streamAudiobook.chapters || [] : []
},
title() { title() {
return this.book.title || 'No Title' return this.book.title || 'No Title'
}, },
@@ -63,6 +66,15 @@ export default {
} }
}, },
methods: { 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() { audioPlayerMounted() {
this.audioPlayerReady = true this.audioPlayerReady = true
if (this.stream) { if (this.stream) {
@@ -94,7 +106,7 @@ export default {
streamProgress(data) { streamProgress(data) {
if (!data.numSegments) return if (!data.numSegments) return
var chunks = data.chunks var chunks = data.chunks
console.log(`[STREAM-CONTAINER] Stream Progress ${data.percent}`) console.log(`[StreamContainer] Stream Progress ${data.percent}`)
if (this.$refs.audioPlayer) { if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments) this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
} else { } else {
@@ -104,7 +116,7 @@ export default {
streamOpen(stream) { streamOpen(stream) {
this.stream = stream this.stream = stream
if (this.$refs.audioPlayer) { if (this.$refs.audioPlayer) {
console.log('[STREAM-CONTAINER] streamOpen', stream) console.log('[StreamContainer] streamOpen', stream)
this.openStream() this.openStream()
} else if (this.audioPlayerReady) { } else if (this.audioPlayerReady) {
console.error('No Audio Ref') console.error('No Audio Ref')
+254
View 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>
+40 -12
View File
@@ -1,32 +1,43 @@
<template> <template>
<div class="relative"> <div class="relative">
<!-- New Book Flag --> <!-- 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"> <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> <p class="text-center text-sm">New</p>
</div> </div>
<div class="absolute -bottom-4 left-0 triangle-right" /> <div class="absolute -bottom-4 left-0 triangle-right" />
</div> </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"> <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" /> <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="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"> <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> <span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
</div> </div>
</div> </div>
<div v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span> <span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
</div> </div>
<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"> <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> <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> </div>
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :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"> <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"> <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">
@@ -53,7 +64,8 @@ export default {
width: { width: {
type: Number, type: Number,
default: 120 default: 120
} },
showVolumeNumber: Boolean
}, },
data() { data() {
return { return {
@@ -62,7 +74,7 @@ export default {
}, },
computed: { computed: {
isNew() { isNew() {
return this.tags.includes('new') return this.tags.includes('New')
}, },
tags() { tags() {
return this.audiobook.tags || [] return this.audiobook.tags || []
@@ -70,8 +82,12 @@ export default {
audiobookId() { audiobookId() {
return this.audiobook.id return this.audiobook.id
}, },
hasEbook() {
return this.audiobook.numEbooks
},
isSelectionMode() { isSelectionMode() {
return this.$store.getters['getNumAudiobooksSelected'] // return this.$store.getters['getNumAudiobooksSelected']
return !!this.selectedAudiobooks.length
}, },
selectedAudiobooks() { selectedAudiobooks() {
return this.$store.state.selectedAudiobooks return this.$store.state.selectedAudiobooks
@@ -125,8 +141,14 @@ export default {
userProgressPercent() { userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0 return this.userProgress ? this.userProgress.progress || 0 : 0
}, },
userIsRead() {
return this.userProgress ? !!this.userProgress.isRead : false
},
showError() { showError() {
return this.hasMissingParts || this.hasInvalidParts return this.hasMissingParts || this.hasInvalidParts || this.isMissing
},
isMissing() {
return this.audiobook.isMissing
}, },
hasMissingParts() { hasMissingParts() {
return this.audiobook.hasMissingParts return this.audiobook.hasMissingParts
@@ -135,6 +157,7 @@ export default {
return this.audiobook.hasInvalidParts return this.audiobook.hasInvalidParts
}, },
errorText() { errorText() {
if (this.isMissing) return 'Audiobook directory is missing!'
var txt = '' var txt = ''
if (this.hasMissingParts) { if (this.hasMissingParts) {
txt = `${this.hasMissingParts} missing parts.` txt = `${this.hasMissingParts} missing parts.`
@@ -153,6 +176,12 @@ export default {
classes.push('border-2 border-yellow-400') classes.push('border-2 border-yellow-400')
} }
return classes return classes
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
} }
}, },
methods: { methods: {
@@ -178,7 +207,6 @@ export default {
this.selectBtnClick() this.selectBtnClick()
} }
} }
}, }
mounted() {}
} }
</script> </script>
+8 -10
View File
@@ -4,7 +4,7 @@
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full"> <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 class="w-full h-full z-0" ref="coverBg" />
</div> </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>
<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 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() { book() {
return this.audiobook.book || {} return this.audiobook.book || {}
}, },
bookLastUpdate() {
return this.book.lastUpdate || Date.now()
},
title() { title() {
return this.book.title || 'No Title' return this.book.title || 'No Title'
}, },
@@ -76,15 +79,7 @@ export default {
return '/book_placeholder.jpg' return '/book_placeholder.jpg'
}, },
fullCoverUrl() { fullCoverUrl() {
if (!this.cover || this.cover === this.placeholderUrl) return '' return this.$store.getters['audiobooks/getBookCoverSrc'](this.book, this.placeholderUrl)
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 ''
}
}, },
cover() { cover() {
return this.book.cover || this.placeholderUrl return this.book.cover || this.placeholderUrl
@@ -106,6 +101,9 @@ export default {
}, },
authorBottom() { authorBottom() {
return 0.75 * this.sizeMultiplier return 0.75 * this.sizeMultiplier
},
userToken() {
return this.$store.getters['user/getToken']
} }
}, },
methods: { methods: {
+98
View 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
View 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
View 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>
+26 -14
View File
@@ -42,9 +42,9 @@
</div> </div>
</li> </li>
<template v-for="item in sublistItems"> <template v-for="item in sublistItems">
<li :key="item" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item)"> <li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal truncate py-2 text-xs">{{ snakeToNormal(item) }}</span> <span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
</div> </div>
</li> </li>
</template> </template>
@@ -81,6 +81,16 @@ export default {
text: 'Series', text: 'Series',
value: 'series', value: 'series',
sublist: true sublist: true
},
{
text: 'Authors',
value: 'authors',
sublist: true
},
{
text: 'Progress',
value: 'progress',
sublist: true
} }
] ]
} }
@@ -109,14 +119,14 @@ export default {
if (!this.selected) return '' if (!this.selected) return ''
var parts = this.selected.split('.') var parts = this.selected.split('.')
if (parts.length > 1) { if (parts.length > 1) {
return this.snakeToNormal(parts[1]) return this.$decode(parts[1])
} }
var _sel = this.items.find((i) => i.value === this.selected) var _sel = this.items.find((i) => i.value === this.selected)
if (!_sel) return '' if (!_sel) return ''
return _sel.text return _sel.text
}, },
genres() { genres() {
return this.$store.state.audiobooks.genres return this.$store.getters['audiobooks/getGenresUsed']
}, },
tags() { tags() {
return this.$store.state.audiobooks.tags return this.$store.state.audiobooks.tags
@@ -124,8 +134,19 @@ export default {
series() { series() {
return this.$store.state.audiobooks.series return this.$store.state.audiobooks.series
}, },
authors() {
return this.$store.getters['audiobooks/getUniqueAuthors']
},
progress() {
return ['Read', 'Unread', 'In Progress']
},
sublistItems() { sublistItems() {
return this[this.sublist] || [] return (this[this.sublist] || []).map((item) => {
return {
text: item,
value: this.$encode(item)
}
})
} }
}, },
methods: { methods: {
@@ -134,15 +155,6 @@ export default {
this.showMenu = false this.showMenu = false
this.$nextTick(() => this.$emit('change', 'all')) this.$nextTick(() => this.$emit('change', 'all'))
}, },
snakeToNormal(kebab) {
if (!kebab) {
return 'err'
}
return String(kebab)
.split('_')
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
.join(' ')
},
clickOutside() { clickOutside() {
if (!this.selectedItemSublist) this.sublist = null if (!this.selectedItemSublist) this.sublist = null
this.showMenu = false this.showMenu = false
+21 -2
View File
@@ -1,6 +1,8 @@
<template> <template>
<div class="w-64 ml-8 relative"> <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"> <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-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> <span v-else class="material-icons" style="font-size: 1.2rem">close</span>
@@ -18,7 +20,7 @@
</li> </li>
<template v-else> <template v-else>
<template v-for="item in items"> <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'"> <template v-if="item.type === 'audiobook'">
<cards-audiobook-search-card :audiobook="item.data" /> <cards-audiobook-search-card :audiobook="item.data" />
</template> </template>
@@ -51,6 +53,19 @@ export default {
} }
}, },
methods: { 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() { focussed() {
this.isFocused = true this.isFocused = true
this.showMenu = true this.showMenu = true
@@ -73,6 +88,10 @@ export default {
return [] return []
}) })
this.isFetching = false this.isFetching = false
if (!this.showMenu) {
return
}
this.items = results.map((res) => { this.items = results.map((res) => {
return { return {
id: res.id, id: res.id,
@@ -48,6 +48,10 @@ export default {
text: 'Added At', text: 'Added At',
value: 'addedAt' value: 'addedAt'
}, },
{
text: 'Volume #',
value: 'book.volumeNumber'
},
{ {
text: 'Duration', text: 'Duration',
value: 'duration' value: 'duration'
@@ -74,10 +74,10 @@ export default {
this.showMenu = false this.showMenu = false
}, },
leftArrowClick() { leftArrowClick() {
this.rateIndex = Math.max(0, this.rateIndex - 4) this.rateIndex = Math.max(0, this.rateIndex - 1)
}, },
rightArrowClick() { 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() {} mounted() {}
+121 -29
View File
@@ -1,5 +1,5 @@
<template> <template>
<modals-modal v-model="show" :width="800" :height="500" :processing="processing"> <modals-modal v-model="show" :width="800" :height="'unset'" :processing="processing">
<template #outer> <template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden"> <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p> <p class="font-book text-3xl text-white truncate">{{ title }}</p>
@@ -8,20 +8,64 @@
<form @submit.prevent="submitForm"> <form @submit.prevent="submitForm">
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300"> <div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<div class="w-full p-8"> <div class="w-full p-8">
<div class="flex py-2"> <div class="flex py-2 -mx-2">
<ui-text-input-with-label v-model="newUser.username" label="Username" class="mx-2" /> <div class="w-1/2 px-2">
<ui-text-input-with-label v-model="newUser.password" label="Password" type="password" class="mx-2" /> <ui-text-input-with-label v-model="newUser.username" label="Username" class="mx-2" />
</div>
<div class="w-1/2 px-2">
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? 'Password' : 'Change Password'" type="password" class="mx-2" />
</div>
</div> </div>
<div class="flex py-2"> <div class="flex py-2">
<div class="px-2"> <div class="px-2">
<ui-input-dropdown v-model="newUser.type" label="Account Type" :editable="false" :items="accountTypes" /> <ui-input-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :editable="false" :items="accountTypes" @input="userTypeUpdated" />
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
<div class="flex items-center pt-4 px-2"> <div v-show="!isEditingRoot" class="flex items-center pt-4 px-2">
<p class="px-3 font-semibold">Is Active</p> <p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p>
<ui-toggle-switch v-model="newUser.isActive" /> <ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
</div> </div>
</div> </div>
<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>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.download" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Update</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.update" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Delete</p>
</div>
<div class="w-1/2">
<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"> <div class="flex pt-4">
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn color="success" type="submit">Submit</ui-btn> <ui-btn color="success" type="submit">Submit</ui-btn>
@@ -68,7 +112,10 @@ export default {
} }
}, },
title() { title() {
return this.isNew ? 'Add New Account' : `Update "${(this.account || {}).username}" Account` return this.isNew ? 'Add New Account' : `Update Account: ${(this.account || {}).username}`
},
isEditingRoot() {
return this.account && this.account.type === 'root'
} }
}, },
methods: { methods: {
@@ -77,6 +124,39 @@ export default {
this.$toast.error('Enter a username') this.$toast.error('Enter a username')
return return
} }
if (this.isNew) {
this.submitCreateAccount()
} else {
this.submitUpdateAccount()
}
},
submitUpdateAccount() {
var account = { ...this.newUser }
if (!account.password || account.type === 'root') {
delete account.password
}
if (account.type === 'root' && !account.isActive) return
this.processing = true
this.$axios
.$patch(`/api/user/${this.account.id}`, account)
.then((data) => {
this.processing = false
if (data.error) {
this.$toast.error(`Failed to update account: ${data.error}`)
} else {
this.$toast.success('Account updated')
this.show = false
}
})
.catch((error) => {
console.error('Failed to update account', error)
this.processing = false
this.$toast.error('Failed to update account')
})
},
submitCreateAccount() {
if (!this.newUser.password) { if (!this.newUser.password) {
this.$toast.error('Must have a password, only root user can have an empty password') this.$toast.error('Must have a password, only root user can have an empty password')
return return
@@ -84,29 +164,34 @@ export default {
var account = { ...this.newUser } var account = { ...this.newUser }
this.processing = true this.processing = true
if (this.isNew) { this.$axios
this.$axios .$post('/api/user', account)
.$post('/api/user', account) .then((data) => {
.then((data) => { this.processing = false
this.processing = false if (data.error) {
if (data.error) { this.$toast.error(`Failed to create account: ${data.error}`)
this.$toast.error(`Failed to create account: ${data.error}`) } else {
} else {
console.log('New Account:', data.user)
this.$toast.success('New account created')
this.show = false
}
})
.catch((error) => {
console.error('Failed to create account', error)
this.processing = false
this.$toast.success('New account created') this.$toast.success('New account created')
}) this.show = false
} }
})
.catch((error) => {
console.error('Failed to create account', error)
this.processing = false
this.$toast.error('Failed to create account')
})
}, },
toggleActive() { toggleActive() {
this.newUser.isActive = !this.newUser.isActive this.newUser.isActive = !this.newUser.isActive
}, },
userTypeUpdated(type) {
this.newUser.permissions = {
download: type !== 'guest',
update: type === 'admin',
delete: type === 'admin',
upload: type === 'admin'
}
},
init() { init() {
this.isNew = !this.account this.isNew = !this.account
if (this.account) { if (this.account) {
@@ -114,14 +199,21 @@ export default {
username: this.account.username, username: this.account.username,
password: this.account.password, password: this.account.password,
type: this.account.type, type: this.account.type,
isActive: this.account.isActive isActive: this.account.isActive,
permissions: { ...this.account.permissions }
} }
} else { } else {
this.newUser = { this.newUser = {
username: null, username: null,
password: null, password: null,
type: 'user', type: 'user',
isActive: true isActive: true,
permissions: {
download: true,
update: false,
delete: false,
upload: false
}
} }
} }
} }
@@ -0,0 +1,70 @@
<template>
<modals-modal v-model="show" :width="500" :height="'unset'">
<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" :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>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
chapters: {
type: Array,
default: () => []
},
currentChapter: {
type: Object,
default: () => null
}
},
data() {
return {}
},
watch: {
value(newVal) {
this.$nextTick(this.scrollToChapter)
}
},
computed: {
show: {
get() {
return this.value
},
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 })
}
}
}
}
}
</script>
+47 -4
View File
@@ -1,16 +1,16 @@
<template> <template>
<modals-modal v-model="show" :width="800" :height="500" :processing="processing"> <modals-modal v-model="show" :width="800" :height="height" :processing="processing" :content-margin-top="75">
<template #outer> <template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden"> <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p> <p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div> </div>
</template> </template>
<div class="absolute -top-10 left-0 w-full flex"> <div class="absolute -top-10 left-0 w-full flex">
<template v-for="tab in tabs"> <template v-for="tab in availableTabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div> <div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template> </template>
</div> </div>
<div class="px-4 w-full h-full text-sm py-6 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300"> <div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
<keep-alive> <keep-alive>
<component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" /> <component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" />
</keep-alive> </keep-alive>
@@ -22,7 +22,6 @@
export default { export default {
data() { data() {
return { return {
selectedTab: 'details',
processing: false, processing: false,
audiobook: null, audiobook: null,
fetchOnShow: false, fetchOnShow: false,
@@ -47,6 +46,11 @@ export default {
title: 'Tracks', title: 'Tracks',
component: 'modals-edit-tabs-tracks' component: 'modals-edit-tabs-tracks'
}, },
{
id: 'chapters',
title: 'Chapters',
component: 'modals-edit-tabs-chapters'
},
{ {
id: 'download', id: 'download',
title: 'Download', title: 'Download',
@@ -59,6 +63,15 @@ export default {
show: { show: {
handler(newVal) { handler(newVal) {
if (newVal) { if (newVal) {
var availableTabIds = this.availableTabs.map((tab) => tab.id)
if (!availableTabIds.length) {
this.show = false
return
}
if (!availableTabIds.includes(this.selectedTab)) {
this.selectedTab = availableTabIds[0]
}
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) { if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) {
if (this.fetchOnShow) this.fetchFull() if (this.fetchOnShow) this.fetchFull()
return return
@@ -79,10 +92,40 @@ export default {
this.$store.commit('setShowEditModal', val) this.$store.commit('setShowEditModal', val)
} }
}, },
selectedTab: {
get() {
return this.$store.state.editModalTab
},
set(val) {
this.$store.commit('setEditModalTab', val)
}
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
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
})
},
height() {
var maxHeightAllowed = window.innerHeight - 150
return Math.min(maxHeightAllowed, 650)
},
tabName() { tabName() {
var _tab = this.tabs.find((t) => t.id === this.selectedTab) var _tab = this.tabs.find((t) => t.id === this.selectedTab)
return _tab ? _tab.component : '' return _tab ? _tab.component : ''
}, },
isMissing() {
return this.selectedAudiobook.isMissing
},
selectedAudiobook() { selectedAudiobook() {
return this.$store.state.selectedAudiobook || {} return this.$store.state.selectedAudiobook || {}
}, },
+6 -2
View File
@@ -1,12 +1,12 @@
<template> <template>
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-30 opacity-0"> <div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-40 opacity-0">
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" /> <div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false"> <div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
<span class="material-icons text-4xl">close</span> <span class="material-icons text-4xl">close</span>
</div> </div>
<slot name="outer" /> <slot name="outer" />
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickBg"> <div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" v-click-outside="clickBg">
<slot /> <slot />
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center"> <div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
<ui-loading-indicator /> <ui-loading-indicator />
@@ -31,6 +31,10 @@ export default {
height: { height: {
type: [String, Number], type: [String, Number],
default: 'unset' default: 'unset'
},
contentMarginTop: {
type: Number,
default: 50
} }
}, },
data() { data() {
@@ -0,0 +1,59 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
<table v-else class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left w-16"><span class="px-4">Id</span></th>
<th class="text-left">Title</th>
<th class="text-center">Start</th>
<th class="text-center">End</th>
</tr>
<template v-for="chapter in chapters">
<tr :key="chapter.id">
<td class="text-left">
<p class="px-4">{{ chapter.id }}</p>
</td>
<td class="font-book">
{{ chapter.title }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.start) }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.end) }}
</td>
</tr>
</template>
</table>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
chapters: []
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {},
methods: {
init() {
this.chapters = this.audiobook.chapters || []
}
}
}
</script>
+60 -6
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-1"> <div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
<div class="flex"> <div class="flex">
<div class="relative"> <div class="relative">
<cards-book-cover :audiobook="audiobook" /> <cards-book-cover :audiobook="audiobook" />
@@ -12,12 +12,15 @@
</div> </div>
</div> </div>
<div class="flex-grow pl-6 pr-2"> <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-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> <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 v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
<div class="flex items-center justify-center py-2"> <div class="flex items-center justify-center py-2">
@@ -63,6 +66,18 @@
</div> </div>
</div> </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> </div>
</template> </template>
@@ -79,12 +94,15 @@ export default {
}, },
data() { data() {
return { return {
processingUpload: false,
searchTitle: null, searchTitle: null,
searchAuthor: null, searchAuthor: null,
imageUrl: null, imageUrl: null,
coversFound: [], coversFound: [],
hasSearched: false, hasSearched: false,
showLocalCovers: false showLocalCovers: false,
previewUpload: null,
selectedFile: null
} }
}, },
watch: { watch: {
@@ -112,6 +130,9 @@ export default {
otherFiles() { otherFiles() {
return this.audiobook ? this.audiobook.otherFiles || [] : [] return this.audiobook ? this.audiobook.otherFiles || [] : []
}, },
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
},
localCovers() { localCovers() {
return this.otherFiles return this.otherFiles
.filter((f) => f.filetype === 'image') .filter((f) => f.filetype === 'image')
@@ -123,6 +144,39 @@ export default {
} }
}, },
methods: { 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() { init() {
this.showLocalCovers = false this.showLocalCovers = false
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.author)) { if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.author)) {
+98 -49
View File
@@ -1,50 +1,63 @@
<template> <template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-1"> <div class="w-full h-full relative">
<div v-if="userProgress" class="bg-success bg-opacity-40 rounded-md w-full px-4 py-1 mb-4 border border-success border-opacity-50"> <form class="w-full h-full" @submit.prevent="submitForm">
<div class="w-full flex items-center"> <div ref="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
<p> <!-- <div v-if="userProgress" class="bg-success bg-opacity-40 rounded-md w-full px-4 py-1 mb-4 border border-success border-opacity-50">
Your progress: <span class="font-mono text-lg">{{ (userProgress * 100).toFixed(0) }}%</span> <div class="w-full flex items-center">
</p> <p>
<div class="flex-grow" /> Your progress: <span class="font-mono text-lg">{{ (userProgress * 100).toFixed(0) }}%</span>
<ui-btn v-if="!resettingProgress" small :padding-x="2" class="-mr-3" @click="resetProgress">Reset</ui-btn> </p>
</div> <div class="flex-grow" />
</div> <ui-btn v-if="!resettingProgress" small :padding-x="2" class="-mr-3" @click="resetProgress">Reset</ui-btn>
<form @submit.prevent="submitForm">
<ui-text-input-with-label v-model="details.title" label="Title" />
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<ui-text-input-with-label v-model="details.author" label="Author" />
</div> </div>
<div class="flex-grow px-1"> </div> -->
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
<ui-text-input-with-label v-model="details.title" label="Title" />
<ui-text-input-with-label v-model="details.subtitle" label="Subtitle" class="mt-2" />
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<ui-text-input-with-label v-model="details.author" label="Author" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<ui-input-dropdown v-model="details.series" label="Series" :items="series" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" />
</div>
</div>
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-multi-select v-model="details.genres" label="Genres" :items="genres" />
</div>
<div class="flex-grow px-1">
<ui-multi-select v-model="newTags" label="Tags" :items="tags" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-text-input-with-label v-model="details.narrarator" label="Narrarator" />
</div>
</div> </div>
</div> </div>
<div class="flex mt-2 -mx-1"> <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="w-3/4 px-1"> <div class="flex px-4">
<ui-input-dropdown v-model="details.series" label="Series" :items="series" /> <ui-btn v-if="userCanDelete" color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
<div class="flex-grow" />
<ui-btn type="submit">Submit</ui-btn>
</div> </div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" />
</div>
</div>
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-multi-select v-model="details.genres" label="Genres" :items="genres" />
</div>
<div class="flex-grow px-1">
<ui-multi-select v-model="newTags" label="Tags" :items="tags" />
</div>
</div>
<div class="flex py-4">
<ui-btn color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
<div class="flex-grow" />
<ui-btn type="submit">Submit</ui-btn>
</div> </div>
</form> </form>
</div> </div>
@@ -63,15 +76,18 @@ export default {
return { return {
details: { details: {
title: null, title: null,
subtitle: null,
description: null, description: null,
author: null, author: null,
narrarator: null,
series: null, series: null,
volumeNumber: null, volumeNumber: null,
publishYear: null, publishYear: null,
genres: [] genres: []
}, },
newTags: [], newTags: [],
resettingProgress: false resettingProgress: false,
isScrollable: false
} }
}, },
watch: { watch: {
@@ -97,11 +113,8 @@ export default {
book() { book() {
return this.audiobook ? this.audiobook.book || {} : {} return this.audiobook ? this.audiobook.book || {} : {}
}, },
userAudiobook() { userCanDelete() {
return this.$store.getters['user/getUserAudiobook'](this.audiobookId) return this.$store.getters['user/getUserCanDelete']
},
userProgress() {
return this.userAudiobook ? this.userAudiobook.progress : 0
}, },
genres() { genres() {
return this.$store.state.audiobooks.genres return this.$store.state.audiobooks.genres
@@ -136,8 +149,10 @@ export default {
}, },
init() { init() {
this.details.title = this.book.title this.details.title = this.book.title
this.details.subtitle = this.book.subtitle
this.details.description = this.book.description this.details.description = this.book.description
this.details.author = this.book.author this.details.author = this.book.author
this.details.narrarator = this.book.narrarator
this.details.genres = this.book.genres || [] this.details.genres = this.book.genres || []
this.details.series = this.book.series this.details.series = this.book.series
this.details.volumeNumber = this.book.volumeNumber this.details.volumeNumber = this.book.volumeNumber
@@ -162,7 +177,7 @@ export default {
} }
}, },
deleteAudiobook() { deleteAudiobook() {
if (confirm(`Are you sure you want to remove this audiobook?`)) { if (confirm(`Are you sure you want to remove this audiobook?\n\n*Does not delete your files, only removes the audiobook from AudioBookshelf`)) {
this.isProcessing = true this.isProcessing = true
this.$axios this.$axios
.$delete(`/api/audiobook/${this.audiobookId}`) .$delete(`/api/audiobook/${this.audiobookId}`)
@@ -177,7 +192,41 @@ export default {
this.isProcessing = false this.isProcessing = false
}) })
} }
},
checkIsScrollable() {
this.$nextTick(() => {
if (this.$refs.formWrapper) {
if (this.$refs.formWrapper.scrollHeight > this.$refs.formWrapper.clientHeight) {
this.isScrollable = true
} else {
this.isScrollable = false
}
}
})
},
setResizeObserver() {
try {
this.$nextTick(() => {
const resizeObserver = new ResizeObserver(() => {
this.checkIsScrollable()
})
resizeObserver.observe(this.$refs.formWrapper)
})
} catch (error) {
console.error('Failed to set resize observer')
}
} }
},
mounted() {
// this.init()
this.setResizeObserver()
} }
} }
</script> </script>
<style scoped>
.details-form-wrapper {
height: calc(100% - 70px);
max-height: calc(100% - 70px);
}
</style>
+86 -29
View File
@@ -1,24 +1,54 @@
<template> <template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-1"> <div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<p class="text-center text-lg mb-4 py-8">Preparing downloads can take several minutes and will be stored in <span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 60 minutes, then be deleted.<br />Download will timeout after 15 minutes.</p>
<div class="w-full border border-black-200 p-4 my-4"> <div class="w-full border border-black-200 p-4 my-4">
<p class="text-center text-lg mb-4 pb-8 border-b border-black-200">
<span class="text-error">Experimental Feature!</span> If your audiobook is made up of multiple audio files, this will concatenate them into a single file. The file type will be the same as the first track. Preparing downloads can take anywhere from a few seconds to several minutes and will be stored in
<span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 10 minutes then get deleted.
</p>
<div class="flex items-center"> <div class="flex items-center">
<p class="text-lg">Single audio file</p> <div>
<!-- <p class="text-lg">{{ isSingleTrack ? 'Single Track' : 'M4B Audiobook File' }}</p> -->
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
</div>
<div class="flex-grow" /> <div class="flex-grow" />
<div> <div>
<p v-if="singleAudioDownloadFailed" class="text-error mb-2">Download Failed</p> <p v-if="singleDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
<p v-if="singleAudioDownloadReady" class="text-success mb-2">Download Ready!</p> <p v-if="singleDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="singleAudioDownloadExpired" class="text-error mb-2">Download Expired</p> <p v-if="singleDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
<ui-btn v-if="!singleAudioDownloadReady" :loading="singleAudioDownloadPending" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn> <!-- <a v-if="isSingleTrack" :href="`/local/${singleTrackPath}`" class="btn outline-none rounded-md shadow-md relative border border-gray-600 px-4 py-2 bg-primary">Download Track</a> -->
<ui-btn v-else @click="downloadWithProgress">Download</ui-btn> <ui-btn v-if="singleDownloadStatus !== $constants.DownloadStatus.READY" :loading="singleDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
<div v-else>
<ui-btn @click="downloadWithProgress(singleAudioDownload)">Download</ui-btn>
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(singleAudioDownload.size) }}</p>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="w-full border border-black-200 p-4 my-4">
<div class="flex items-center">
<div>
<p v-if="totalFiles > 1" class="text-lg">Zip {{ totalFiles }} Files</p>
<p v-else>Zip 1 File</p>
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .ZIP file from the contents of the audiobook directory.</p>
</div>
<div class="flex-grow" />
<div>
<p v-if="zipDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
<p v-if="zipDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="zipDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
<ui-btn v-if="zipDownloadStatus !== $constants.DownloadStatus.READY" :loading="zipDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startZipDownload">Start Download</ui-btn>
<div v-else>
<ui-btn @click="downloadWithProgress(zipDownload)">Download</ui-btn>
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(zipDownload.size) }}</p>
</div>
</div>
</div>
</div>
<div class="w-full flex items-center justify-center absolute bottom-4 left-0 right-0 text-center">
<p class="text-error text-lg">* <strong>Experimental:</strong> Merging multiple .m4b files may have issues. <a href="https://github.com/advplyr/audiobookshelf/issues" class="underline text-blue-600" target="_blank">Report issues here.</a></p>
</div>
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center"> <div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20"> <div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
@@ -51,7 +81,7 @@ export default {
} }
}, },
watch: { watch: {
singleAudioDownloadPending(newVal) { singleDownloadStatus(newVal) {
if (newVal) { if (newVal) {
this.tempDisable = false this.tempDisable = false
} }
@@ -67,25 +97,36 @@ export default {
singleAudioDownload() { singleAudioDownload() {
return this.downloads.find((d) => d.type === 'singleAudio') return this.downloads.find((d) => d.type === 'singleAudio')
}, },
singleAudioDownloadPending() { singleDownloadStatus() {
return this.singleAudioDownload && this.singleAudioDownload.isPending return this.singleAudioDownload ? this.singleAudioDownload.status : false
}, },
singleAudioDownloadFailed() { zipDownload() {
return this.singleAudioDownload && this.singleAudioDownload.isFailed return this.downloads.find((d) => d.type === 'zip')
}, },
singleAudioDownloadReady() { zipDownloadStatus() {
return this.singleAudioDownload && this.singleAudioDownload.isReady return this.zipDownload ? this.zipDownload.status : false
}, },
singleAudioDownloadExpired() { isSingleTrack() {
return this.singleAudioDownload && this.singleAudioDownload.isExpired if (!this.audiobook.tracks) return false
return this.audiobook.tracks.length === 1
}, },
zipBundleDownload() { singleTrackPath() {
return this.downloads.find((d) => d.type === 'zipBundle') if (!this.isSingleTrack) return null
return this.audiobook.tracks[0].path
},
audioFiles() {
return this.audiobook ? this.audiobook.audioFiles || [] : []
},
otherFiles() {
return this.audiobook ? this.audiobook.otherFiles || [] : []
},
totalFiles() {
return this.audioFiles.length + this.otherFiles.length
} }
}, },
methods: { methods: {
startSingleAudioDownload() { startZipDownload() {
console.log('Download request received', this.audiobook) // console.log('Download request received', this.audiobook)
this.tempDisable = true this.tempDisable = true
setTimeout(() => { setTimeout(() => {
@@ -94,14 +135,30 @@ export default {
var downloadPayload = { var downloadPayload = {
audiobookId: this.audiobook.id, audiobookId: this.audiobook.id,
type: 'singleAudio' type: 'zip'
} }
this.$root.socket.emit('download', downloadPayload) this.$root.socket.emit('download', downloadPayload)
}, },
downloadWithProgress() { startSingleAudioDownload() {
var downloadId = this.singleAudioDownload.id // console.log('Download request received', this.audiobook)
this.tempDisable = true
setTimeout(() => {
this.tempDisable = false
}, 1000)
var downloadPayload = {
audiobookId: this.audiobook.id,
type: 'singleAudio',
includeMetadata: true,
includeCover: true
}
this.$root.socket.emit('download', downloadPayload)
},
downloadWithProgress(download) {
var downloadId = download.id
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}` var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
var filename = this.singleAudioDownload.filename var filename = download.filename
this.isDownloading = true this.isDownloading = true
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="w-full h-full overflow-hidden"> <div class="w-full h-full overflow-hidden px-4 py-6">
<form @submit.prevent="submitSearch"> <form @submit.prevent="submitSearch">
<div class="flex items-center justify-start -mx-1 h-20"> <div class="flex items-center justify-start -mx-1 h-20">
<div class="w-72 px-1"> <div class="w-72 px-1">
+20 -4
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden"> <div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div class="flex mb-4"> <div class="flex mb-4">
<nuxt-link :to="`/audiobook/${audiobook.id}/edit`"> <nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`">
<ui-btn color="primary">Edit Track Order</ui-btn> <ui-btn color="primary">Edit Track Order</ui-btn>
</nuxt-link> </nuxt-link>
</div> </div>
@@ -11,6 +11,7 @@
<th class="text-left">Filename</th> <th class="text-left">Filename</th>
<th class="text-left">Size</th> <th class="text-left">Size</th>
<th class="text-left">Duration</th> <th class="text-left">Duration</th>
<th v-if="showDownload" class="text-center">Download</th>
</tr> </tr>
<template v-for="track in tracks"> <template v-for="track in tracks">
<tr :key="track.index"> <tr :key="track.index">
@@ -26,6 +27,9 @@
<td class="font-mono"> <td class="font-mono">
{{ $secondsToTimestamp(track.duration) }} {{ $secondsToTimestamp(track.duration) }}
</td> </td>
<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> </tr>
</template> </template>
</table> </table>
@@ -54,12 +58,24 @@ export default {
} }
} }
}, },
computed: {}, computed: {
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
isMissing() {
return this.audiobook.isMissing
},
showDownload() {
return this.userCanDownload && !this.isMissing
}
},
methods: { methods: {
init() { init() {
this.audioFiles = this.audiobook.audioFiles this.audioFiles = this.audiobook.audioFiles
this.tracks = this.audiobook.tracks this.tracks = this.audiobook.tracks
console.log('INIT', this.audiobook)
} }
} }
} }
+6 -2
View File
@@ -4,7 +4,7 @@
<p class="pr-4">Other Audio Files</p> <p class="pr-4">Other Audio Files</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ files.length }}</span> <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ files.length }}</span>
<div class="flex-grow" /> <div class="flex-grow" />
<nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4"> <nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn> <ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link> </nuxt-link>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''"> <div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
@@ -56,7 +56,11 @@ export default {
showTracks: false showTracks: false
} }
}, },
computed: {}, computed: {
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
}
},
methods: { methods: {
clickBar() { clickBar() {
this.showTracks = !this.showTracks this.showTracks = !this.showTracks
+13 -2
View File
@@ -4,7 +4,7 @@
<p class="pr-4">Audio Tracks</p> <p class="pr-4">Audio Tracks</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span>
<div class="flex-grow" /> <div class="flex-grow" />
<nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4"> <nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn> <ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link> </nuxt-link>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''"> <div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
@@ -19,6 +19,7 @@
<th class="text-left">Filename</th> <th class="text-left">Filename</th>
<th class="text-left">Size</th> <th class="text-left">Size</th>
<th class="text-left">Duration</th> <th class="text-left">Duration</th>
<th v-if="userCanDownload" class="text-center">Download</th>
</tr> </tr>
<template v-for="track in tracks"> <template v-for="track in tracks">
<tr :key="track.index"> <tr :key="track.index">
@@ -34,6 +35,9 @@
<td class="font-mono"> <td class="font-mono">
{{ $secondsToTimestamp(track.duration) }} {{ $secondsToTimestamp(track.duration) }}
</td> </td>
<td v-if="userCanDownload" class="text-center">
<a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr> </tr>
</template> </template>
</table> </table>
@@ -56,7 +60,14 @@ export default {
showTracks: false showTracks: false
} }
}, },
computed: {}, computed: {
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
}
},
methods: { methods: {
clickBar() { clickBar() {
this.showTracks = !this.showTracks this.showTracks = !this.showTracks
+13 -3
View File
@@ -1,5 +1,14 @@
<template> <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 /> <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"> <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> --> <!-- <span class="material-icons animate-spin">refresh</span> -->
@@ -13,6 +22,7 @@
<script> <script>
export default { export default {
props: { props: {
to: String,
color: { color: {
type: String, type: String,
default: 'primary' default: 'primary'
@@ -59,7 +69,7 @@ export default {
</script> </script>
<style> <style>
button.btn::before { .btn::before {
content: ''; content: '';
position: absolute; position: absolute;
border-radius: 6px; border-radius: 6px;
@@ -70,7 +80,7 @@ button.btn::before {
background-color: rgba(255, 255, 255, 0); background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out; transition: all 0.1s ease-in-out;
} }
button.btn:hover:not(:disabled)::before { .btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
} }
button:disabled::before { button:disabled::before {
+39
View 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>
+62
View File
@@ -0,0 +1,62 @@
<template>
<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>
<script>
export default {
props: {
icon: String,
disabled: Boolean,
bgColor: {
type: String,
default: 'primary'
}
},
data() {
return {}
},
computed: {
className() {
var classes = []
classes.push(`bg-${this.bgColor}`)
return classes.join(' ')
}
},
methods: {
clickBtn(e) {
if (this.disabled) {
e.preventDefault()
return
}
this.$emit('click')
e.stopPropagation()
}
},
mounted() {}
}
</script>
<style>
button.icon-btn:disabled {
cursor: not-allowed;
}
button.icon-btn::before {
content: '';
position: absolute;
border-radius: 6px;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
button.icon-btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button.icon-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style>
+12 -9
View File
@@ -1,10 +1,10 @@
<template> <template>
<div class="w-full"> <div class="w-full" :class="disabled ? 'cursor-not-allowed' : ''">
<p class="px-1 text-sm font-semibold">{{ label }}</p> <p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<div ref="wrapper" class="relative"> <div ref="wrapper" class="relative">
<form @submit.prevent="submitForm"> <form @submit.prevent="submitForm">
<div ref="inputWrapper" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded px-2 py-2"> <div ref="inputWrapper" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'bg-bg pointer-events-none text-gray-400' : 'bg-primary'">
<input ref="input" v-model="textInput" :readonly="!editable" class="h-full w-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" /> <input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
</div> </div>
</form> </form>
@@ -33,6 +33,7 @@
export default { export default {
props: { props: {
value: [String, Number], value: [String, Number],
disabled: Boolean,
label: String, label: String,
items: { items: {
type: Array, type: Array,
@@ -95,7 +96,7 @@ export default {
} }
this.isFocused = false this.isFocused = false
if (this.input !== this.textInput) { if (this.input !== this.textInput) {
var val = this.$cleanString(this.textInput) || null var val = this.textInput ? this.textInput.trim() : null
this.input = val this.input = val
if (val && !this.items.includes(val)) { if (val && !this.items.includes(val)) {
this.$emit('newItem', val) this.$emit('newItem', val)
@@ -104,7 +105,7 @@ export default {
}, 50) }, 50)
}, },
submitForm() { submitForm() {
var val = this.$cleanString(this.textInput) || null var val = this.textInput ? this.textInput.trim() : null
this.input = val this.input = val
if (val && !this.items.includes(val)) { if (val && !this.items.includes(val)) {
this.$emit('newItem', val) this.$emit('newItem', val)
@@ -112,13 +113,15 @@ export default {
this.currentSearch = null this.currentSearch = null
}, },
clickedOption(e, item) { clickedOption(e, item) {
var newValue = this.input === item ? null : item
this.textInput = null this.textInput = null
this.currentSearch = 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() if (this.$refs.input) this.$refs.input.blur()
} }
}, },
mounted() {} mounted() {}
} }
</script> </script>
+2 -2
View File
@@ -1,13 +1,13 @@
<template> <template>
<div class="w-40"> <div class="w-40">
<div class="bg-white border py-2 px-5 rounded-lg flex items-center flex-col"> <div class="bg-bg border border-gray-500 py-2 px-5 rounded-lg flex items-center flex-col box-shadow-md">
<div class="loader-dots block relative w-20 h-5 mt-2"> <div class="loader-dots block relative w-20 h-5 mt-2">
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div> <div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div> <div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div> <div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div> <div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
</div> </div>
<div class="text-gray-500 text-xs font-light mt-2 text-center">{{ text }}</div> <div class="text-gray-200 text-xs font-light mt-2 text-center">{{ text }}</div>
</div> </div>
</div> </div>
</template> </template>
+6 -9
View File
@@ -8,7 +8,7 @@
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer"> <div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span> <span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
</div> </div>
{{ $snakeToNormal(item) }} {{ item }}
</div> </div>
<input ref="input" v-model="textInput" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" /> <input ref="input" v-model="textInput" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
</div> </div>
@@ -18,7 +18,7 @@
<template v-for="item in itemsToShow"> <template v-for="item in itemsToShow">
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> <li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ $snakeToNormal(item) }}</span> <span class="font-normal ml-3 block truncate">{{ item }}</span>
</div> </div>
<span v-if="selected.includes(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> <span v-if="selected.includes(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">checkmark</span> <span class="material-icons text-xl">checkmark</span>
@@ -75,8 +75,7 @@ export default {
} }
return this.items.filter((i) => { return this.items.filter((i) => {
var normie = this.$snakeToNormal(i) var iValue = String(i).toLowerCase()
var iValue = String(normie).toLowerCase()
return iValue.includes(this.currentSearch.toLowerCase()) return iValue.includes(this.currentSearch.toLowerCase())
}) })
} }
@@ -170,8 +169,7 @@ export default {
}) })
}, },
insertNewItem(item) { insertNewItem(item) {
var kebabItem = this.$normalToSnake(item) this.selected.push(item)
this.selected.push(kebabItem)
this.$emit('input', this.selected) this.$emit('input', this.selected)
this.textInput = null this.textInput = null
this.currentSearch = null this.currentSearch = null
@@ -182,10 +180,9 @@ export default {
submitForm() { submitForm() {
if (!this.textInput) return if (!this.textInput) return
var cleaned = this.textInput.toLowerCase().trim() var cleaned = this.textInput.trim()
var cleanedKebab = this.$normalToSnake(cleaned)
var matchesItem = this.items.find((i) => { var matchesItem = this.items.find((i) => {
return i === cleaned || cleanedKebab === i return i === cleaned
}) })
if (matchesItem) { if (matchesItem) {
this.clickedOption(null, matchesItem) this.clickedOption(null, matchesItem)
+56
View File
@@ -0,0 +1,56 @@
<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">
<div class="w-5 h-5 text-white relative">
<svg v-if="isRead" 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-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
</svg>
<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>
</div>
</button>
</template>
<script>
export default {
props: {
isRead: Boolean,
disabled: Boolean
},
data() {
return {}
},
computed: {},
methods: {
clickBtn(e) {
if (this.disabled) {
e.preventDefault()
return
}
this.$emit('click')
e.stopPropagation()
}
},
mounted() {}
}
</script>
<style>
button.icon-btn::before {
content: '';
position: absolute;
border-radius: 6px;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
button.icon-btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button.icon-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style>
+19 -3
View File
@@ -1,5 +1,5 @@
<template> <template>
<input v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @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> </template>
<script> <script>
@@ -12,8 +12,15 @@ export default {
type: String, type: String,
default: 'text' default: 'text'
}, },
transparent: Boolean, disabled: Boolean,
disabled: Boolean paddingY: {
type: Number,
default: 2
},
paddingX: {
type: Number,
default: 3
}
}, },
data() { data() {
return {} return {}
@@ -26,6 +33,12 @@ export default {
set(val) { set(val) {
this.$emit('input', val) this.$emit('input', val)
} }
},
classList() {
var _list = []
_list.push(`px-${this.paddingX}`)
_list.push(`py-${this.paddingY}`)
return _list.join(' ')
} }
}, },
methods: { methods: {
@@ -40,6 +53,9 @@ export default {
}, },
keyup(e) { keyup(e) {
this.$emit('keyup', e) this.$emit('keyup', e)
},
blur() {
if (this.$refs.input) this.$refs.input.blur()
} }
}, },
mounted() {} mounted() {}
+4 -1
View File
@@ -1,6 +1,8 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<p class="px-1 text-sm font-semibold">{{ label }}</p> <p class="px-1 text-sm font-semibold">
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</p>
<ui-text-input v-model="inputValue" :disabled="disabled" :type="type" class="w-full" /> <ui-text-input v-model="inputValue" :disabled="disabled" :type="type" class="w-full" />
</div> </div>
</template> </template>
@@ -10,6 +12,7 @@ export default {
props: { props: {
value: [String, Number], value: [String, Number],
label: String, label: String,
note: String,
type: { type: {
type: String, type: String,
default: 'text' default: 'text'
+20 -6
View File
@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<div class="border rounded-full border-black-100 flex items-center cursor-pointer w-12 justify-end" :class="toggleColor" @click="clickToggle"> <div class="border rounded-full border-black-100 flex items-center cursor-pointer w-12 justify-start" :class="className" @click="clickToggle">
<span class="rounded-full border w-6 h-6 border-black-50 bg-white shadow transform transition-transform duration-100" :class="!toggleValue ? '-translate-x-6' : ''"> </span> <span class="rounded-full border w-6 h-6 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
</div> </div>
</div> </div>
</template> </template>
@@ -9,7 +9,16 @@
<script> <script>
export default { export default {
props: { props: {
value: Boolean value: Boolean,
onColor: {
type: String,
default: 'success'
},
offColor: {
type: String,
default: 'primary'
},
disabled: Boolean
}, },
computed: { computed: {
toggleValue: { toggleValue: {
@@ -20,13 +29,18 @@ export default {
this.$emit('input', val) this.$emit('input', val)
} }
}, },
toggleColor() { className() {
return this.toggleValue ? 'bg-success' : 'bg-primary' if (this.disabled) return 'bg-bg cursor-not-allowed'
return this.toggleValue ? `bg-${this.onColor}` : `bg-${this.offColor}`
},
switchClassName() {
var bgColor = this.disabled ? 'bg-gray-300' : 'bg-white'
return this.toggleValue ? 'translate-x-6 ' + bgColor : bgColor
} }
}, },
methods: { methods: {
clickToggle() { clickToggle() {
console.log('click toggle', this.toggleValue) if (this.disabled) return
this.toggleValue = !this.toggleValue this.toggleValue = !this.toggleValue
} }
} }
+55 -9
View File
@@ -1,5 +1,5 @@
<template> <template>
<div ref="box" class="tooltip-box" @mouseover="mouseover" @mouseleave="mouseleave"> <div ref="box" @mouseover="mouseover" @mouseleave="mouseleave">
<slot /> <slot />
</div> </div>
</template> </template>
@@ -14,7 +14,8 @@ export default {
direction: { direction: {
type: String, type: String,
default: 'right' default: 'right'
} },
disabled: Boolean
}, },
data() { data() {
return { return {
@@ -22,27 +23,72 @@ export default {
isShowing: false isShowing: false
} }
}, },
watch: {
text() {
this.updateText()
},
disabled(newVal) {
if (newVal && this.isShowing) {
this.hideTooltip()
}
}
},
methods: { 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() { 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 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 top = 0
var left = 0 var left = 0
if (this.direction === 'right') { if (this.direction === 'right') {
top = boxChow.top top = boxChow.top - height / 2 + boxChow.height / 2
left = boxChow.left + boxChow.width + 4 left = boxChow.left + boxChow.width + 4
} else if (this.direction === 'bottom') { } else if (this.direction === 'bottom') {
top = boxChow.top + boxChow.height + 4 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 - 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.top = top + 'px'
tooltip.style.left = left + 'px' tooltip.style.left = left + 'px'
tooltip.style.zIndex = 100
tooltip.innerText = this.text
this.tooltip = tooltip
}, },
showTooltip() { showTooltip() {
if (this.disabled) return
if (!this.tooltip) { if (!this.tooltip) {
this.createTooltip() this.createTooltip()
} }
+87 -20
View File
@@ -1,7 +1,9 @@
<template> <template>
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg"> <div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
<app-appbar /> <app-appbar />
<Nuxt /> <Nuxt />
<app-stream-container ref="streamContainer" /> <app-stream-container ref="streamContainer" />
<modals-edit-modal /> <modals-edit-modal />
<widgets-scan-alert /> <widgets-scan-alert />
@@ -24,6 +26,9 @@ export default {
if (this.$store.state.selectedAudiobooks) { if (this.$store.state.selectedAudiobooks) {
this.$store.commit('setSelectedAudiobooks', []) this.$store.commit('setSelectedAudiobooks', [])
} }
if (this.$store.state.audiobooks.keywordFilter) {
this.$store.commit('audiobooks/setKeywordFilter', '')
}
} }
}, },
computed: { computed: {
@@ -56,6 +61,9 @@ export default {
this.$store.commit('user/setUser', payload.user) this.$store.commit('user/setUser', payload.user)
this.$store.commit('user/setSettings', payload.user.settings) this.$store.commit('user/setSettings', payload.user.settings)
} }
if (payload.serverSettings) {
this.$store.commit('setServerSettings', payload.serverSettings)
}
}, },
streamOpen(stream) { streamOpen(stream) {
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream) if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
@@ -81,7 +89,7 @@ export default {
audiobookRemoved(audiobook) { audiobookRemoved(audiobook) {
if (this.$route.name.startsWith('audiobook')) { if (this.$route.name.startsWith('audiobook')) {
if (this.$route.params.id === audiobook.id) { if (this.$route.params.id === audiobook.id) {
this.$router.replace('/') this.$router.replace('/library')
} }
} }
this.$store.commit('audiobooks/remove', audiobook) this.$store.commit('audiobooks/remove', audiobook)
@@ -99,6 +107,7 @@ export default {
if (results.added) scanResultMsgs.push(`${results.added} added`) if (results.added) scanResultMsgs.push(`${results.added} added`)
if (results.updated) scanResultMsgs.push(`${results.updated} updated`) if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
if (results.removed) scanResultMsgs.push(`${results.removed} removed`) 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') if (!scanResultMsgs.length) this.$toast.success('Scan Finished\nEverything was up to date')
else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n')) else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n'))
} }
@@ -124,39 +133,61 @@ export default {
this.$store.commit('user/setSettings', user.settings) this.$store.commit('user/setSettings', user.settings)
} }
}, },
downloadToastClick(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) { downloadStarted(download) {
var filename = download.filename download.status = this.$constants.DownloadStatus.PENDING
this.$toast.success(`Preparing download for "${filename}"`) download.toastId = this.$toast(`Preparing download "${download.filename}"`, { timeout: false, draggable: false, closeOnClick: false, onClick: () => this.downloadToastClick(download) })
download.isPending = true
this.$store.commit('downloads/addUpdateDownload', download) this.$store.commit('downloads/addUpdateDownload', download)
}, },
downloadReady(download) { downloadReady(download) {
var filename = download.filename download.status = this.$constants.DownloadStatus.READY
this.$toast.success(`Download "${filename}" is ready!`) var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
download.isPending = false 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(download) } }, true)
} else {
this.$toast.success(`Download "${download.filename}" is ready!`)
}
this.$store.commit('downloads/addUpdateDownload', download) this.$store.commit('downloads/addUpdateDownload', download)
}, },
downloadFailed(download) { downloadFailed(download) {
var filename = download.filename download.status = this.$constants.DownloadStatus.FAILED
this.$toast.error(`Download "${filename}" is failed`) var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
download.isFailed = true var failedMsg = download.isTimedOut ? 'timed out' : 'failed'
download.isReady = false
download.isPending = false 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(download) } }, true)
} else {
console.warn('Download failed no existing download', existingDownload)
this.$toast.error(`Download "${download.filename}" ${failedMsg}`)
}
this.$store.commit('downloads/addUpdateDownload', download) this.$store.commit('downloads/addUpdateDownload', download)
}, },
downloadKilled(download) { downloadKilled(download) {
var filename = download.filename var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
this.$toast.error(`Download "${filename}" was terminated`) 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(download) } }, true)
} else {
console.warn('Download killed no existing download found', existingDownload)
this.$toast.error(`Download "${download.filename}" was terminated`)
}
this.$store.commit('downloads/removeDownload', download) this.$store.commit('downloads/removeDownload', download)
}, },
downloadExpired(download) { downloadExpired(download) {
download.isExpired = true download.status = this.$constants.DownloadStatus.EXPIRED
download.isReady = false
download.isPending = false
this.$store.commit('downloads/addUpdateDownload', download) this.$store.commit('downloads/addUpdateDownload', download)
}, },
initializeSocket() { initializeSocket() {
@@ -206,10 +237,40 @@ export default {
this.socket.on('download_failed', this.downloadFailed) this.socket.on('download_failed', this.downloadFailed)
this.socket.on('download_killed', this.downloadKilled) this.socket.on('download_killed', this.downloadKilled)
this.socket.on('download_expired', this.downloadExpired) 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() { mounted() {
this.initializeSocket() 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) { if (this.$route.query.error) {
this.$toast.error(this.$route.query.error) this.$toast.error(this.$route.query.error)
@@ -217,4 +278,10 @@ export default {
} }
} }
} }
</script> </script>
<style>
.Vue-Toastification__toast-body.custom-class-1 {
font-size: 14px;
}
</style>
+3 -3
View File
@@ -1,7 +1,7 @@
export default function ({ store, redirect, route }) { export default function ({ store, redirect, route, app }) {
// If the user is not authenticated // If the user is not authenticated
if (!store.state.user.user) { if (!store.state.user.user) {
if (route.name === 'batch') return redirect('/login') if (route.name === 'batch' || route.name === 'index') return redirect('/login')
return redirect(`/login?redirect=${route.path}`) return redirect(`/login?redirect=${route.fullPath}`)
} }
} }
+19
View 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)
}
}
}
}
}
+7 -1
View File
@@ -40,6 +40,10 @@ module.exports = {
] ]
}, },
router: {
middleware: ['routed']
},
// Global CSS: https://go.nuxtjs.dev/config-css // Global CSS: https://go.nuxtjs.dev/config-css
css: [ css: [
'@/assets/app.css' '@/assets/app.css'
@@ -47,6 +51,7 @@ module.exports = {
// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins // Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
plugins: [ plugins: [
'@/plugins/constants.js',
'@/plugins/init.client.js', '@/plugins/init.client.js',
'@/plugins/axios.js', '@/plugins/axios.js',
'@/plugins/toast.js' '@/plugins/toast.js'
@@ -70,7 +75,8 @@ module.exports = {
proxy: { proxy: {
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } }, '/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: { io: {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "1.0.0", "version": "1.2.4",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "1.0.0", "version": "1.2.7",
"description": "Audiobook manager and player", "description": "Audiobook manager and player",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
@@ -9,7 +9,7 @@
"start": "nuxt start", "start": "nuxt start",
"generate": "nuxt generate" "generate": "nuxt generate"
}, },
"author": "", "author": "advplyr",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
+14 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="w-full h-full p-8"> <div class="w-full h-full p-8">
<div class="w-full max-w-2xl mx-auto"> <div class="w-full max-w-xl mx-auto">
<h1 class="text-2xl">Account</h1> <h1 class="text-2xl">Account</h1>
<div class="my-4"> <div class="my-4">
@@ -27,6 +27,10 @@
</div> </div>
</form> </form>
</div> </div>
<div class="py-4 mt-8 flex">
<ui-btn color="primary flex items-center text-lg" @click="logout"><span class="material-icons mr-4 icon-text">logout</span>Logout</ui-btn>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -56,6 +60,15 @@ export default {
} }
}, },
methods: { methods: {
logout() {
this.$axios.$post('/logout').catch((error) => {
console.error(error)
})
if (localStorage.getItem('token')) {
localStorage.removeItem('token')
}
this.$router.push('/login')
},
resetForm() { resetForm() {
this.password = null this.password = null
this.newPassword = null this.newPassword = null
+56 -14
View File
@@ -19,16 +19,15 @@
<div class="font-mono w-20 text-center">Duration</div> <div class="font-mono w-20 text-center">Duration</div>
<div class="font-mono text-center w-20">Status</div> <div class="font-mono text-center w-20">Status</div>
<div class="font-mono w-56">Notes</div> <div class="font-mono w-56">Notes</div>
<div class="font-book w-40">Include in Tracklist</div>
</div> </div>
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false"> <draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false">
<transition-group type="transition" :name="!drag ? 'flip-list' : null"> <transition-group type="transition" :name="!drag ? 'flip-list' : null">
<li v-for="(audio, index) in files" :key="audio.path" class="w-full list-group-item item flex items-center"> <li v-for="(audio, index) in files" :key="audio.path" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center">
<div class="font-book text-center px-4 py-1 w-12"> <div class="font-book text-center px-4 py-1 w-12">
{{ index + 1 }} {{ audio.include ? index - numExcluded + 1 : -1 }}
</div>
<div class="font-book text-center px-4 w-12">
{{ audio.index }}
</div> </div>
<div class="font-book text-center px-4 w-12">{{ audio.index }}</div>
<div class="font-book text-center px-2 w-32"> <div class="font-book text-center px-2 w-32">
{{ audio.trackNumFromFilename }} {{ audio.trackNumFromFilename }}
</div> </div>
@@ -51,6 +50,9 @@
<div class="font-sans text-xs font-normal w-56"> <div class="font-sans text-xs font-normal w-56">
{{ audio.error }} {{ audio.error }}
</div> </div>
<div class="font-sans text-xs font-normal w-40 flex justify-center">
<ui-toggle-switch v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" />
</div>
</li> </li>
</transition-group> </transition-group>
</draggable> </draggable>
@@ -69,6 +71,9 @@ export default {
if (!store.state.user.user) { if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`) return redirect(`/login?redirect=${route.path}`)
} }
if (!store.getters['user/getUserCanUpdate']) {
return redirect('/?error=unauthorized')
}
var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => { var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => {
console.error('Failed', error) console.error('Failed', error)
return false return false
@@ -77,10 +82,9 @@ export default {
console.error('No audiobook...', params.id) console.error('No audiobook...', params.id)
return redirect('/') return redirect('/')
} }
let index = 0
return { return {
audiobook, audiobook,
files: audiobook.audioFiles ? audiobook.audioFiles.map((af) => ({ ...af, index: ++index })) : [] files: audiobook.audioFiles ? audiobook.audioFiles.map((af) => ({ ...af, include: !af.exclude })) : []
} }
}, },
data() { data() {
@@ -98,6 +102,13 @@ export default {
audioFiles() { audioFiles() {
return this.audiobook.audioFiles || [] return this.audiobook.audioFiles || []
}, },
numExcluded() {
var count = 0
this.files.forEach((file) => {
if (!file.include) count++
})
return count
},
missingPartChunks() { missingPartChunks() {
if (this.missingParts === 1) return this.missingParts[0] if (this.missingParts === 1) return this.missingParts[0]
var chunks = [] var chunks = []
@@ -164,15 +175,36 @@ export default {
} }
}, },
methods: { methods: {
includeToggled(audio) {
var new_index = 0
if (audio.include) {
new_index = this.numExcluded
}
var old_index = this.files.findIndex((f) => f.ino === audio.ino)
if (new_index >= this.files.length) {
var k = new_index - this.files.length + 1
while (k--) {
this.files.push(undefined)
}
}
this.files.splice(new_index, 0, this.files.splice(old_index, 1)[0])
},
saveTracklist() { saveTracklist() {
console.log('Tracklist', this.files) var orderedFileData = this.files.map((file) => {
return {
index: file.index,
filename: file.filename,
ino: file.ino,
exclude: !file.include
}
})
this.saving = true this.saving = true
this.$axios this.$axios
.$patch(`/api/audiobook/${this.audiobook.id}/tracks`, { files: this.files }) .$patch(`/api/audiobook/${this.audiobook.id}/tracks`, { orderedFileData })
.then((data) => { .then((data) => {
console.log('Finished patching files', data) console.log('Finished patching files', data)
this.saving = false this.saving = false
// this.$router.go()
this.$toast.success('Tracks Updated') this.$toast.success('Tracks Updated')
this.$router.push(`/audiobook/${this.audiobookId}`) this.$router.push(`/audiobook/${this.audiobookId}`)
}) })
@@ -207,16 +239,26 @@ export default {
.list-group { .list-group {
min-height: 30px; min-height: 30px;
} }
.list-group-item { .list-group-item:not(.exclude) {
cursor: n-resize; cursor: n-resize;
} }
.list-group-item:not(.ghost):hover { .list-group-item.exclude {
cursor: not-allowed;
}
.list-group-item:not(.ghost):not(.exclude):hover {
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
} }
.list-group-item:nth-child(even):not(.ghost) { .list-group-item:nth-child(even):not(.ghost):not(.exclude) {
background-color: rgba(0, 0, 0, 0.25); background-color: rgba(0, 0, 0, 0.25);
} }
.list-group-item:nth-child(even):not(.ghost):hover { .list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
} }
.list-group-item.exclude:not(.ghost) {
background-color: rgba(255, 0, 0, 0.25);
}
.list-group-item.exclude:not(.ghost):hover {
background-color: rgba(223, 0, 0, 0.25);
}
</style> </style>
+108 -19
View File
@@ -5,7 +5,7 @@
<div class="w-52" style="min-width: 208px"> <div class="w-52" style="min-width: 208px">
<div class="relative"> <div class="relative">
<cards-book-cover :audiobook="audiobook" :width="208" /> <cards-book-cover :audiobook="audiobook" :width="208" />
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :style="{ width: 240 * progressPercent + 'px' }"></div> <div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div>
</div> </div>
</div> </div>
<div class="flex-grow px-10"> <div class="flex-grow px-10">
@@ -13,33 +13,60 @@
<div class="mb-2"> <div class="mb-2">
<h1 class="text-2xl font-book leading-7">{{ title }}</h1> <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> <h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
<ui-tooltip :text="authorTooltipText" direction="bottom"> <div class="w-min">
<p class="text-sm text-gray-100 leading-7">by {{ author }}</p> <ui-tooltip :text="authorTooltipText" direction="bottom">
</ui-tooltip> <span class="text-sm text-gray-100 leading-7 whitespace-nowrap">by {{ author }}</span>
</ui-tooltip>
</div>
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
</div> </div>
<p class="text-gray-300 text-sm my-1"> <p class="text-gray-300 text-sm my-1">
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span> {{ durationPretty }}<span class="px-4">{{ sizePretty }}</span>
</p> </p>
<div v-if="progressPercent > 0 && progressPercent < 1" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-200 relative max-w-max" :class="resettingProgress ? 'opacity-25' : ''">
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
<span class="material-icons text-sm">close</span>
</div>
</div>
<div class="flex items-center pt-4"> <div class="flex items-center pt-4">
<ui-btn :disabled="streaming" color="success" :padding-x="4" class="flex items-center" @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> <span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
{{ streaming ? 'Streaming' : 'Play' }} {{ streaming ? 'Streaming' : 'Play' }}
</ui-btn> </ui-btn>
<ui-btn :padding-x="4" class="flex items-center ml-4" @click="editClick"><span class="material-icons text-white pr-2" style="font-size: 18px">edit</span>Edit</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" :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">
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="isRead" class="mx-0.5" @click="toggleRead" />
</ui-tooltip>
<ui-btn v-if="isDeveloperMode" class="mx-2" @click="openRssFeed">Open RSS Feed</ui-btn> <ui-btn v-if="isDeveloperMode" class="mx-2" @click="openRssFeed">Open RSS Feed</ui-btn>
<div v-if="progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 ml-4 relative" :class="resettingProgress ? 'opacity-25' : ''"> <div class="flex-grow" />
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p> </div>
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick"> <div class="my-4">
<span class="material-icons text-sm">close</span> <p class="text-sm text-gray-100">{{ description }}</p>
</div>
</div>
</div> </div>
<p class="text-sm my-4 text-gray-100">{{ description }}</p>
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4"> <div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
<p class="text-sm mb-2"> <p class="text-sm mb-2">
@@ -53,7 +80,7 @@
Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span> Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span>
</p> </p>
<div> <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>
</div> </div>
@@ -64,6 +91,8 @@
<tables-other-files-table v-if="otherFiles.length" :audiobook-id="audiobook.id" :files="otherFiles" class="mt-6" /> <tables-other-files-table v-if="otherFiles.length" :audiobook-id="audiobook.id" :files="otherFiles" class="mt-6" />
</div> </div>
</div> </div>
<div id="area"></div>
</div> </div>
</div> </div>
</template> </template>
@@ -88,7 +117,17 @@ export default {
}, },
data() { data() {
return { return {
resettingProgress: false isRead: false,
resettingProgress: false,
isProcessingReadUpdate: false
}
},
watch: {
userIsRead: {
immediate: true,
handler(newVal) {
this.isRead = newVal
}
} }
}, },
computed: { computed: {
@@ -126,6 +165,9 @@ export default {
}) })
return chunks return chunks
}, },
isMissing() {
return this.audiobook.isMissing
},
missingParts() { missingParts() {
return this.audiobook.missingParts || [] return this.audiobook.missingParts || []
}, },
@@ -149,7 +191,7 @@ export default {
}, },
authorTooltipText() { authorTooltipText() {
var txt = ['FL: ' + this.authorFL || 'Not Set', 'LF: ' + this.authorLF || 'Not Set'] var txt = ['FL: ' + this.authorFL || 'Not Set', 'LF: ' + this.authorLF || 'Not Set']
return txt.join('\n') return txt.join('<br>')
}, },
series() { series() {
return this.book.series || null return this.book.series || null
@@ -188,8 +230,11 @@ export default {
audioFiles() { audioFiles() {
return this.audiobook.audioFiles || [] return this.audiobook.audioFiles || []
}, },
ebooks() {
return this.audiobook.ebooks
},
description() { description() {
return this.book.description || 'No Description' return this.book.description || ''
}, },
userAudiobooks() { userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {} return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
@@ -200,6 +245,9 @@ export default {
userCurrentTime() { userCurrentTime() {
return this.userAudiobook ? this.userAudiobook.currentTime : 0 return this.userAudiobook ? this.userAudiobook.currentTime : 0
}, },
userIsRead() {
return this.userAudiobook ? !!this.userAudiobook.isRead : false
},
userTimeRemaining() { userTimeRemaining() {
return this.duration - this.userCurrentTime return this.duration - this.userCurrentTime
}, },
@@ -209,11 +257,49 @@ export default {
streamAudiobook() { streamAudiobook() {
return this.$store.state.streamAudiobook return this.$store.state.streamAudiobook
}, },
isStreaming() { streaming() {
return this.streamAudiobook && this.streamAudiobook.id === this.audiobookId return this.streamAudiobook && this.streamAudiobook.id === this.audiobookId
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
} }
}, },
methods: { 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
}
this.isProcessingReadUpdate = true
this.$axios
.$patch(`/api/user/audiobook/${this.audiobookId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
})
},
openRssFeed() { openRssFeed() {
this.$axios this.$axios
.$post('/api/feed', { audiobookId: this.audiobook.id }) .$post('/api/feed', { audiobookId: this.audiobook.id })
@@ -269,6 +355,9 @@ export default {
this.resettingProgress = false this.resettingProgress = false
}) })
} }
},
downloadClick() {
this.$store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'download' })
} }
}, },
mounted() { mounted() {
+9 -1
View File
@@ -9,6 +9,8 @@
<div class="flex-grow pl-4"> <div class="flex-grow pl-4">
<ui-text-input-with-label v-model="audiobook.book.title" label="Title" /> <ui-text-input-with-label v-model="audiobook.book.title" label="Title" />
<ui-text-input-with-label v-model="audiobook.book.subtitle" label="Subtitle" class="mt-2" />
<div class="flex mt-2 -mx-1"> <div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1"> <div class="w-3/4 px-1">
<ui-text-input-with-label v-model="audiobook.book.author" label="Author" /> <ui-text-input-with-label v-model="audiobook.book.author" label="Author" />
@@ -37,6 +39,12 @@
<ui-multi-select v-model="audiobook.tags" label="Tags" :items="tags" /> <ui-multi-select v-model="audiobook.tags" label="Tags" :items="tags" />
</div> </div>
</div> </div>
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-text-input-with-label v-model="audiobook.book.narrarator" label="Narrarator" />
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -122,7 +130,7 @@ export default {
this.isProcessing = false this.isProcessing = false
if (data.updates) { if (data.updates) {
this.$toast.success(`Successfully updated ${data.updates} audiobooks`) this.$toast.success(`Successfully updated ${data.updates} audiobooks`)
this.$router.replace('/') this.$router.replace('/library')
} else { } else {
this.$toast.warning('No updates were necessary') this.$toast.warning('No updates were necessary')
} }
+59 -11
View File
@@ -1,5 +1,5 @@
<template> <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="w-full max-w-4xl mx-auto">
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
<h1 class="text-2xl">Users</h1> <h1 class="text-2xl">Users</h1>
@@ -17,7 +17,7 @@
<th style="width: 200px">Created At</th> <th style="width: 200px">Created At</th>
<th style="width: 100px"></th> <th style="width: 100px"></th>
</tr> </tr>
<tr v-for="user in users" :key="user.id"> <tr v-for="user in users" :key="user.id" :class="user.isActive ? '' : 'bg-error bg-opacity-20'">
<td> <td>
{{ user.username }} <span class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span> {{ user.username }} <span class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
</td> </td>
@@ -27,6 +27,7 @@
</td> </td>
<td> <td>
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
<span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click="editUser(user)">edit</span>
<span v-show="user.type !== 'root'" class="material-icons text-base hover:text-error cursor-pointer" @click="deleteUserClick(user)">delete</span> <span v-show="user.type !== 'root'" class="material-icons text-base hover:text-error cursor-pointer" @click="deleteUserClick(user)">delete</span>
</div> </div>
</td> </td>
@@ -35,12 +36,25 @@
</div> </div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" /> <div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="py-4 mb-8"> <div class="py-4 mb-8">
<p class="text-2xl">Scanner</p>
<div class="flex items-start py-2"> <div class="flex items-start py-2">
<p class="text-2xl">Scanner</p> <div class="py-2">
<div class="flex items-center">
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" @input="updateScannerParseSubtitle" />
<ui-tooltip :text="parseSubtitleTooltip">
<p class="pl-4 text-lg">Parse Subtitles <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
</div>
<div class="flex-grow" /> <div class="flex-grow" />
<div class="w-40 flex flex-col"> <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="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">
<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>
</div> </div>
</div> </div>
</div> </div>
@@ -68,7 +82,7 @@
</div> </div>
<div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div> <div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
<modals-account-modal v-model="showAccountModal" /> <modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
</div> </div>
</template> </template>
@@ -83,11 +97,26 @@ export default {
return { return {
isResettingAudiobooks: false, isResettingAudiobooks: false,
users: [], users: [],
selectedAccount: null,
showAccountModal: false, showAccountModal: false,
isDeletingUser: false isDeletingUser: false,
newServerSettings: {}
}
},
watch: {
serverSettings(newVal, oldVal) {
if (newVal && !oldVal) {
this.newServerSettings = { ...this.serverSettings }
}
} }
}, },
computed: { computed: {
parseSubtitleTooltip() {
return 'Extract subtitles from audiobook directory names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"'
},
serverSettings() {
return this.$store.state.serverSettings
},
streamAudiobook() { streamAudiobook() {
return this.$store.state.streamAudiobook return this.$store.state.streamAudiobook
}, },
@@ -99,6 +128,19 @@ export default {
} }
}, },
methods: { methods: {
updateScannerParseSubtitle(val) {
var payload = {
scannerParseSubtitle: val
}
this.$store
.dispatch('updateServerSettings', payload)
.then((success) => {
console.log('Updated Server Settings', success)
})
.catch((error) => {
console.error('Failed to update server settings', error)
})
},
setDeveloperMode() { setDeveloperMode() {
var value = !this.$store.state.developerMode var value = !this.$store.state.developerMode
this.$store.commit('setDeveloperMode', value) this.$store.commit('setDeveloperMode', value)
@@ -110,10 +152,6 @@ export default {
scanCovers() { scanCovers() {
this.$root.socket.emit('scan_covers') this.$root.socket.emit('scan_covers')
}, },
clickAddUser() {
this.showAccountModal = true
// this.$toast.info('Under Construction: User management coming soon.')
},
loadUsers() { loadUsers() {
this.$axios this.$axios
.$get('/api/users') .$get('/api/users')
@@ -140,6 +178,14 @@ export default {
}) })
} }
}, },
clickAddUser() {
this.selectedAccount = null
this.showAccountModal = true
},
editUser(user) {
this.selectedAccount = user
this.showAccountModal = true
},
deleteUserClick(user) { deleteUserClick(user) {
if (this.isDeletingUser) return if (this.isDeletingUser) return
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) { if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
@@ -163,7 +209,7 @@ export default {
}, },
addUpdateUser(user) { addUpdateUser(user) {
if (!this.users) return if (!this.users) return
var index = this.users.find((u) => u.id === user.id) var index = this.users.findIndex((u) => u.id === user.id)
if (index >= 0) { if (index >= 0) {
this.users.splice(index, 1, user) this.users.splice(index, 1, user)
} else { } else {
@@ -186,6 +232,8 @@ export default {
this.$root.socket.on('user_added', this.addUpdateUser) this.$root.socket.on('user_added', this.addUpdateUser)
this.$root.socket.on('user_updated', this.addUpdateUser) this.$root.socket.on('user_updated', this.addUpdateUser)
this.$root.socket.on('user_removed', this.userRemoved) this.$root.socket.on('user_removed', this.userRemoved)
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
} }
}, },
mounted() { mounted() {
+21 -2
View File
@@ -1,12 +1,31 @@
<template> <template>
<div class="page" :class="streamAudiobook ? 'streaming' : ''"> <div class="page" :class="streamAudiobook ? 'streaming' : ''">
<app-book-shelf-toolbar /> <!-- <app-book-shelf-toolbar /> -->
<app-book-shelf /> <!-- <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> </div>
</template> </template>
<script> <script>
export default { export default {
// asyncData({ redirect }) {
// redirect('/library')
// },
data() {
return {}
},
computed: { computed: {
streamAudiobook() { streamAudiobook() {
return this.$store.state.streamAudiobook return this.$store.state.streamAudiobook
+70
View 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>
+1 -2
View File
@@ -27,7 +27,7 @@ export default {
return { return {
error: null, error: null,
processing: false, processing: false,
username: 'root', username: '',
password: null password: null
} }
}, },
@@ -77,7 +77,6 @@ export default {
if (token) { if (token) {
this.processing = true this.processing = true
console.log('Authorize', token)
this.$axios this.$axios
.$post('/api/authorize', null, { .$post('/api/authorize', null, {
headers: { headers: {
+254
View File
@@ -0,0 +1,254 @@
<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 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">
<ui-text-input-with-label v-model="title" label="Title" />
</div>
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="author" label="Author" />
</div>
</div>
<div class="flex my-2 px-6">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="series" label="Series" note="(optional)" />
</div>
<div class="w-1/2 px-2">
<div class="w-full">
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 42px" />
</div>
</div>
</div>
<section v-if="showUploader" class="h-full overflow-auto p-8 w-full flex flex-col">
<header class="border-dashed border-2 border-gray-400 py-12 flex flex-col justify-center items-center relative h-40" :class="isDragOver ? 'bg-white bg-opacity-10' : ''">
<p v-show="isDragOver" class="mb-3 font-semibold text-gray-200 flex flex-wrap justify-center">Drop em'</p>
<p v-show="!isDragOver" class="mb-3 font-semibold text-gray-200 flex flex-wrap justify-center">Drop your audio and image files or</p>
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
<ui-btn @click="clickSelectAudioFiles">Select files</ui-btn>
<p class="text-xs text-gray-300 absolute bottom-3 right-3">{{ inputAccept.join(', ') }}</p>
</header>
</section>
<section v-else class="h-full overflow-auto px-8 pb-8 w-full flex flex-col">
<p v-if="!hasValidAudioFiles" class="text-error text-lg pt-4">* No valid audio tracks</p>
<div v-if="validImageFiles.length">
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Cover Image(s)</h1>
<div class="flex">
<template v-for="file in validImageFiles">
<div :key="file.name" class="h-28 w-20 bg-bg">
<img :src="file.src" class="h-full w-full object-contain" />
</div>
</template>
</div>
</div>
<div v-if="validAudioFiles.length">
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Audio Tracks</h1>
<table class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left">Filename</th>
<th class="text-left">Type</th>
<th class="text-left">Size</th>
</tr>
<template v-for="file in validAudioFiles">
<tr :key="file.name">
<td class="font-book">
<p class="truncate">{{ file.name }}</p>
</td>
<td class="font-sm">
{{ file.type }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.size) }}
</td>
</tr>
</template>
</table>
</div>
<div v-if="invalidFiles.length">
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Invalid Files</h1>
<table class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left">Filename</th>
<th class="text-left">Type</th>
<th class="text-left">Size</th>
</tr>
<template v-for="file in invalidFiles">
<tr :key="file.name">
<td class="font-book">
<p class="truncate">{{ file.name }}</p>
</td>
<td class="font-sm">
{{ file.type }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.size) }}
</td>
</tr>
</template>
</table>
</div>
</section>
<footer v-show="!showUploader" class="flex justify-end px-8 pb-8 pt-4">
<ui-btn :disabled="!hasValidAudioFiles" color="success" @click="submit">Upload Audiobook</ui-btn>
<button id="cancel" class="ml-3 rounded-sm px-3 py-1 hover:bg-white hover:bg-opacity-10 focus:shadow-outline focus:outline-none" @click="cancel">Cancel</button>
</footer>
<div v-if="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
<ui-loading-indicator text="Uploading..." />
</div>
</article>
</main>
</div>
</template>
<script>
import Path from 'path'
export default {
data() {
return {
processing: false,
title: null,
author: null,
series: null,
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a'],
acceptedImageFormats: ['image/*'],
inputAccept: ['image/*, .mp3, .m4b, .m4a'],
isDragOver: false,
showUploader: true,
validAudioFiles: [],
validImageFiles: [],
invalidFiles: []
}
},
computed: {
streamAudiobook() {
return this.$store.state.streamAudiobook
},
hasValidAudioFiles() {
return this.validAudioFiles.length
},
directory() {
if (!this.author || !this.title) return ''
if (this.series) {
return Path.join('/audiobooks', this.author, this.series, this.title)
} else {
return Path.join('/audiobooks', this.author, this.title)
}
}
},
methods: {
reset() {
this.title = ''
this.author = ''
this.series = ''
this.cancel()
},
cancel() {
this.validAudioFiles = []
this.validImageFiles = []
this.invalidFiles = []
if (this.$refs.fileInput) {
this.$refs.fileInput.value = ''
}
this.showUploader = true
},
inputChanged(e) {
if (!e.target || !e.target.files) return
var _files = Array.from(e.target.files)
if (_files && _files.length) {
this.filesChanged(_files)
}
},
drop(evt) {
this.isDragOver = false
this.preventDefaults(evt)
const files = [...evt.dataTransfer.files]
this.filesChanged(files)
},
dragover(evt) {
this.isDragOver = true
this.preventDefaults(evt)
},
dragleave(evt) {
this.isDragOver = false
this.preventDefaults(evt)
},
dragenter(evt) {
this.isDragOver = true
this.preventDefaults(evt)
},
preventDefaults(e) {
e.preventDefault()
e.stopPropagation()
},
filesChanged(files) {
this.showUploader = false
for (let i = 0; i < files.length; i++) {
var file = files[i]
var ext = Path.extname(file.name)
if (this.acceptedAudioFormats.includes(ext)) {
this.validAudioFiles.push(file)
} else if (file.type.startsWith('image/')) {
file.src = URL.createObjectURL(file)
this.validImageFiles.push(file)
} else {
this.invalidFiles.push(file)
}
}
},
clickSelectAudioFiles() {
if (this.$refs.fileInput) {
this.$refs.fileInput.click()
}
},
submit() {
if (!this.title || !this.author) {
this.$toast.error('Must enter a title and author')
return
}
this.processing = true
var form = new FormData()
form.set('title', this.title)
form.set('author', this.author)
form.set('series', this.series)
var index = 0
var files = this.validAudioFiles.concat(this.validImageFiles)
files.forEach((file) => {
form.set(`${index++}`, file)
})
this.$axios
.$post('/upload', form)
.then((data) => {
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success('Audiobook Uploaded Successfully')
this.reset()
}
this.processing = false
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Oops, something went wrong...')
this.processing = false
})
}
},
mounted() {}
}
</script>
+1 -2
View File
@@ -1,17 +1,16 @@
export default function ({ $axios, store }) { export default function ({ $axios, store }) {
$axios.onRequest(config => { $axios.onRequest(config => {
console.log('Making request to ' + config.url)
if (config.url.startsWith('http:') || config.url.startsWith('https:')) { if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
return return
} }
var bearerToken = store.state.user.user ? store.state.user.user.token : null var bearerToken = store.state.user.user ? store.state.user.user.token : null
// console.log('Bearer token', bearerToken)
if (bearerToken) { if (bearerToken) {
config.headers.common['Authorization'] = `Bearer ${bearerToken}` config.headers.common['Authorization'] = `Bearer ${bearerToken}`
} }
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
config.url = `/dev${config.url}` config.url = `/dev${config.url}`
console.log('Making request to ' + config.url)
} }
}) })
+14
View File
@@ -0,0 +1,14 @@
const DownloadStatus = {
PENDING: 0,
READY: 1,
EXPIRED: 2,
FAILED: 3
}
const Constants = {
DownloadStatus
}
export default ({ app }, inject) => {
inject('constants', Constants)
}
+30 -106
View File
@@ -38,115 +38,26 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}` return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
} }
Vue.prototype.$snakeToNormal = (snake) => { Vue.prototype.$calculateTextSize = (text, styles = {}) => {
if (!snake) { const el = document.createElement('p')
return ''
}
return String(snake)
.split('_')
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
.join(' ')
}
Vue.prototype.$normalToSnake = (normie) => { let attr = 'margin:0px;opacity:1;position:absolute;top:100px;left:100px;z-index:99;'
if (!normie) return '' for (const key in styles) {
return normie if (styles[key] && String(styles[key]).length > 0) {
.trim() attr += `${key}:${styles[key]};`
.split(' ')
.map((t) => t.toLowerCase())
.join('_')
}
const availableChars = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
const getCharCode = (char) => availableChars.indexOf(char)
const getCharFromCode = (code) => availableChars[Number(code)] || -1
const cleanChar = (char) => getCharCode(char) < 0 ? '?' : char
Vue.prototype.$cleanString = (str) => {
if (!str) return ''
// replace accented characters: https://stackoverflow.com/a/49901740/7431543
str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
var cleaned = ''
for (let i = 0; i < str.length; i++) {
cleaned += cleanChar(str[i])
}
return cleaned
}
Vue.prototype.$stringToCode = (str) => {
if (!str) return ''
var numcode = [...str].map(s => {
return String(getCharCode(s)).padStart(2, '0')
}).join('')
return BigInt(numcode).toString(36)
}
Vue.prototype.$codeToString = (code) => {
if (!code) return ''
var numcode = ''
try {
numcode = [...code].reduce((acc, curr) => {
return BigInt(parseInt(curr, 36)) + BigInt(36) * acc
}, 0n)
} catch (err) {
console.error('numcode fialed', code, err)
}
var numcodestr = String(numcode)
var remainder = numcodestr.length % 2
numcodestr = numcodestr.padStart(numcodestr.length - 1 + remainder, '0')
var finalform = ''
var numChunks = Math.floor(numcodestr.length / 2)
var remaining = numcodestr
for (let i = 0; i < numChunks; i++) {
var chunk = remaining.slice(0, 2)
remaining = remaining.slice(2)
finalform += getCharFromCode(chunk)
}
return finalform
}
function cleanString(str, availableChars) {
var _str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
var cleaned = ''
for (let i = 0; i < _str.length; i++) {
cleaned += availableChars.indexOf(str[i]) < 0 ? '' : str[i]
}
return cleaned
}
export const cleanFilterString = (str) => {
var _str = str.toLowerCase().replace(/ /g, '_')
_str = cleanString(_str, "0123456789abcdefghijklmnopqrstuvwxyz")
return _str
}
function loadImageBlob(uri) {
return new Promise((resolve) => {
const img = document.createElement('img')
const c = document.createElement('canvas')
const ctx = c.getContext('2d')
img.onload = ({ target }) => {
c.width = target.naturalWidth
c.height = target.naturalHeight
ctx.drawImage(target, 0, 0)
c.toBlob((b) => resolve(b), 'image/jpeg', 0.75)
} }
img.crossOrigin = '' }
img.src = uri
})
}
Vue.prototype.$downloadImage = async (uri, name) => { el.setAttribute('style', attr)
var blob = await loadImageBlob(uri) el.innerText = text
const a = document.createElement('a')
a.href = URL.createObjectURL(blob) document.body.appendChild(el)
a.target = '_blank' const boundingBox = el.getBoundingClientRect()
a.download = name || 'fotosho-image' el.remove()
a.click() return {
height: boundingBox.height,
width: boundingBox.width
}
} }
function isClickedOutsideEl(clickEvent, elToCheckOutside, ignoreSelectors = [], ignoreElems = []) { function isClickedOutsideEl(clickEvent, elToCheckOutside, ignoreSelectors = [], ignoreElems = []) {
@@ -204,3 +115,16 @@ Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
.replace(windowsTrailingRe, replacement); .replace(windowsTrailingRe, replacement);
return sanitized return sanitized
} }
const encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64'))
Vue.prototype.$encode = encode
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
Vue.prototype.$decode = decode
export {
encode,
decode
}
export default ({ app }, inject) => {
app.$decode = decode
}
+7 -7
View File
@@ -1,10 +1,10 @@
import Vue from "vue"; import Vue from "vue"
import Toast from "vue-toastification"; import Toast from "vue-toastification"
import "vue-toastification/dist/index.css"; import "vue-toastification/dist/index.css"
const options = { const options = {
hideProgressBar: true hideProgressBar: true,
}; draggable: false
}
Vue.use(Toast, options)
Vue.use(Toast, options);
+59
View 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
}
}
+136 -10
View File
@@ -1,30 +1,72 @@
import { sort } from '@/assets/fastSort' import { sort } from '@/assets/fastSort'
import { cleanFilterString } from '@/plugins/init.client' import { decode } from '@/plugins/init.client'
const STANDARD_GENRES = ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult'] const STANDARD_GENRES = ['Adventure', 'Autobiography', 'Biography', 'Childrens', 'Comedy', 'Crime', 'Dystopian', 'Fantasy', 'Fiction', 'Health', 'History', 'Horror', 'Mystery', 'New Adult', 'Nonfiction', 'Philosophy', 'Politics', 'Religion', 'Romance', 'Sci-Fi', 'Self-Help', 'Short Story', 'Technology', 'Thriller', 'True Crime', 'Western', 'Young Adult']
export const state = () => ({ export const state = () => ({
audiobooks: [], audiobooks: [],
lastLoad: 0,
listeners: [], listeners: [],
genres: [...STANDARD_GENRES], genres: [...STANDARD_GENRES],
tags: [], tags: [],
series: [] series: [],
keywordFilter: null,
selectedSeries: null,
libraryPage: null,
searchResults: []
}) })
export const getters = { export const getters = {
getFiltered: (state, getters, rootState) => () => { getAudiobook: (state) => id => {
return state.audiobooks.find(ab => ab.id === id)
},
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 filtered = state.audiobooks
var settings = rootState.user.settings || {} var settings = rootState.user.settings || {}
var filterBy = settings.filterBy || '' 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 + '.')) var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) { if (group) {
var filter = filterBy.replace(`${group}.`, '') var filter = decode(filterBy.replace(`${group}.`, ''))
if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter)) if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter)) 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 === '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 === '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']
const keyworkFilter = state.keywordFilter.toLowerCase()
return filtered.filter(ab => {
if (!ab.book) return false
return !!keywordFilterKeys.find(key => (ab.book[key] && ab.book[key].toLowerCase().includes(keyworkFilter)))
})
} }
return filtered return filtered
}, },
@@ -33,37 +75,121 @@ export const getters = {
var direction = settings.orderDesc ? 'desc' : 'asc' var direction = settings.orderDesc ? 'desc' : 'asc'
var filtered = getters.getFiltered() var filtered = getters.getFiltered()
var orderByNumber = settings.orderBy === 'book.volumeNumber'
return sort(filtered)[direction]((ab) => { return sort(filtered)[direction]((ab) => {
// Supports dot notation strings i.e. "book.title" // 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) => { getUniqueAuthors: (state) => {
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author) var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
return [...new Set(_authors)] return [...new Set(_authors)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
},
getGenresUsed: (state) => {
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 = { export const actions = {
load({ commit, rootState }) { // Return true if calling load
load({ state, commit, rootState }) {
if (!rootState.user || !rootState.user.user) { if (!rootState.user || !rootState.user.user) {
console.error('audiobooks/load - User not set') 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 this.$axios
.$get(`/api/audiobooks`) .$get(`/api/audiobooks`)
.then((data) => { .then((data) => {
commit('set', data) commit('set', data)
commit('setLastLoad')
}) })
.catch((error) => { .catch((error) => {
console.error('Failed', error) console.error('Failed', error)
commit('set', []) commit('set', [])
}) })
return true
}, },
} }
export const mutations = { 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) { set(state, audiobooks) {
// GENRES // GENRES
var genres = [...state.genres] var genres = [...state.genres]
+3
View File
@@ -6,6 +6,9 @@ export const state = () => ({
export const getters = { export const getters = {
getDownloads: (state) => (audiobookId) => { getDownloads: (state) => (audiobookId) => {
return state.downloads.filter(d => d.audiobookId === audiobookId) return state.downloads.filter(d => d.audiobookId === audiobookId)
},
getDownload: (state) => (id) => {
return state.downloads.find(d => d.id === id)
} }
} }
+73 -4
View File
@@ -1,7 +1,11 @@
import { checkForUpdate } from '@/plugins/version'
import Vue from 'vue' import Vue from 'vue'
export const state = () => ({ export const state = () => ({
versionData: null,
serverSettings: null,
streamAudiobook: null, streamAudiobook: null,
editModalTab: 'details',
showEditModal: false, showEditModal: false,
selectedAudiobook: null, selectedAudiobook: null,
playOnLoad: false, playOnLoad: false,
@@ -11,7 +15,9 @@ export const state = () => ({
coverScanProgress: null, coverScanProgress: null,
developerMode: false, developerMode: false,
selectedAudiobooks: [], selectedAudiobooks: [],
processingBatch: false processingBatch: false,
previousPath: '/',
routeHistory: []
}) })
export const getters = { export const getters = {
@@ -21,9 +27,58 @@ export const getters = {
getNumAudiobooksSelected: state => state.selectedAudiobooks.length getNumAudiobooksSelected: state => state.selectedAudiobooks.length
} }
export const actions = {} export const actions = {
updateServerSettings({ commit }, payload) {
var updatePayload = {
...payload
}
return this.$axios.$patch('/api/serverSettings', updatePayload).then((result) => {
if (result.success) {
commit('setServerSettings', result.settings)
return true
} else {
return false
}
}).catch((error) => {
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 = { 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
},
setStreamAudiobook(state, audiobook) { setStreamAudiobook(state, audiobook) {
state.playOnLoad = true state.playOnLoad = true
state.streamAudiobook = audiobook state.streamAudiobook = audiobook
@@ -42,9 +97,18 @@ export const mutations = {
state.playOnLoad = val state.playOnLoad = val
}, },
showEditModal(state, audiobook) { showEditModal(state, audiobook) {
state.editModalTab = 'details'
state.selectedAudiobook = audiobook state.selectedAudiobook = audiobook
state.showEditModal = true state.showEditModal = true
}, },
showEditModalOnTab(state, { audiobook, tab }) {
state.editModalTab = tab
state.selectedAudiobook = audiobook
state.showEditModal = true
},
setEditModalTab(state, tab) {
state.editModalTab = tab
},
setShowEditModal(state, val) { setShowEditModal(state, val) {
state.showEditModal = val state.showEditModal = val
}, },
@@ -66,13 +130,18 @@ export const mutations = {
state.developerMode = val state.developerMode = val
}, },
setSelectedAudiobooks(state, audiobooks) { setSelectedAudiobooks(state, audiobooks) {
state.selectedAudiobooks = audiobooks Vue.set(state, 'selectedAudiobooks', audiobooks)
// state.selectedAudiobooks = audiobooks
}, },
toggleAudiobookSelected(state, audiobookId) { toggleAudiobookSelected(state, audiobookId) {
if (state.selectedAudiobooks.includes(audiobookId)) { if (state.selectedAudiobooks.includes(audiobookId)) {
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId) state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
} else { } 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) { setProcessingBatch(state, val) {
+14 -3
View File
@@ -24,6 +24,18 @@ export const getters = {
}, },
getFilterOrderKey: (state) => { getFilterOrderKey: (state) => {
return Object.values(state.settings).join('-') return Object.values(state.settings).join('-')
},
getUserCanUpdate: (state) => {
return state.user && state.user.permissions ? !!state.user.permissions.update : false
},
getUserCanDelete: (state) => {
return state.user && state.user.permissions ? !!state.user.permissions.delete : false
},
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
} }
} }
@@ -32,10 +44,11 @@ export const actions = {
var updatePayload = { var updatePayload = {
...payload ...payload
} }
// Immediately update
commit('setSettings', updatePayload)
return this.$axios.$patch('/api/user/settings', updatePayload).then((result) => { return this.$axios.$patch('/api/user/settings', updatePayload).then((result) => {
if (result.success) { if (result.success) {
commit('setSettings', result.settings) commit('setSettings', result.settings)
console.log('Settings updated', result.settings)
return true return true
} else { } else {
return false return false
@@ -52,10 +65,8 @@ export const mutations = {
state.user = user state.user = user
if (user) { if (user) {
if (user.token) localStorage.setItem('token', user.token) if (user.token) localStorage.setItem('token', user.token)
console.log('setUser', user.username)
} else { } else {
localStorage.removeItem('token') localStorage.removeItem('token')
console.warn('setUser cleared')
} }
}, },
setSettings(state, settings) { setSettings(state, settings) {
+8 -1
View File
@@ -5,7 +5,8 @@ module.exports = {
options: { options: {
safelist: [ safelist: [
'bg-success', 'bg-success',
'bg-red-600' 'bg-red-600',
'py-1.5'
] ]
} }
}, },
@@ -15,6 +16,12 @@ module.exports = {
height: { height: {
'7.5': '1.75rem' '7.5': '1.75rem'
}, },
spacing: {
'-54': '-13.5rem'
},
rotate: {
'-60': '-60deg'
},
colors: { colors: {
bg: '#373838', bg: '#373838',
primary: '#232323', primary: '#232323',
+1 -1
View File
@@ -10,7 +10,7 @@
<Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support> <Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support>
<Project>https://github.com/advplyr/audiobookshelf</Project> <Project>https://github.com/advplyr/audiobookshelf</Project>
<Overview>**(Android app in beta, try it out)** Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free &amp; open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview> <Overview>**(Android app in beta, try it out)** Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free &amp; open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview>
<Category>MediaApp:Books MediaServer:Books Status:Beta</Category> <Category>MediaApp:Books MediaServer:Books</Category>
<WebUI>http://[IP]:[PORT:80]</WebUI> <WebUI>http://[IP]:[PORT:80]</WebUI>
<TemplateURL>https://raw.githubusercontent.com/advplyr/docker-templates/master/audiobookshelf.xml</TemplateURL> <TemplateURL>https://raw.githubusercontent.com/advplyr/docker-templates/master/audiobookshelf.xml</TemplateURL>
<Icon>https://github.com/advplyr/audiobookshelf/raw/master/client/static/Logo.png</Icon> <Icon>https://github.com/advplyr/audiobookshelf/raw/master/client/static/Logo.png</Icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+769 -2
View File
File diff suppressed because it is too large Load Diff
+18 -3
View File
@@ -1,19 +1,34 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "1.0.0", "version": "1.2.7",
"description": "Self-hosted audiobook server for managing and playing audiobooks.", "description": "Self-hosted audiobook server for managing and playing audiobooks",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "node index.js", "dev": "node index.js",
"start": "node index.js" "start": "node index.js",
"client": "cd client && npm install && npm run generate",
"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", "author": "advplyr",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"archiver": "^5.3.0",
"axios": "^0.21.1", "axios": "^0.21.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"command-line-args": "^5.2.0",
"cookie-parser": "^1.4.5", "cookie-parser": "^1.4.5",
"express": "^4.17.1", "express": "^4.17.1",
"express-fileupload": "^1.2.1",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"ip": "^1.1.5", "ip": "^1.1.5",
+31
View 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()
+123 -16
View File
@@ -2,6 +2,8 @@
AudioBookshelf is a self-hosted audiobook server for managing and playing your audiobooks. 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) 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** **Free & open source Android/iOS app is in development**
@@ -9,34 +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" /> <img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_streaming.png" />
#### Folder Structures Supported: ## Directory Structure
```bash See [documentation](https://audiobookshelf.org/docs) for directory structure and naming.
/Title/...
/Author/Title/...
/Author/Series/Title/...
Title can start with the publish year like so: Author, Series, Volume Number, Title and Publish Year can all be parsed from your folder structure.
/1989 - Awesome Book/...
``` **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
* Auto add and update audiobooks (currently you need to press scan)
* User permissions & editing users
* Support different views to see more details of each audiobook * 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)) * 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 ## Installation
Built to run in Docker for now (also on Unraid server Community Apps) ### Docker Install
Available in Unraid Community Apps
```bash ```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.3_amd64.deb
sudo apt install ./audiobookshelf_1.2.3_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 ## Contributing
+216 -14
View File
@@ -1,10 +1,13 @@
const express = require('express') const express = require('express')
const Path = require('path')
const fs = require('fs-extra')
const Logger = require('./Logger') const Logger = require('./Logger')
const User = require('./objects/User') const User = require('./objects/User')
const { isObject } = require('./utils/index') const { isObject, isAcceptableCoverMimeType } = require('./utils/index')
const { CoverDestination } = require('./utils/constants')
class ApiController { class ApiController {
constructor(db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter) { constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter, clientEmitter) {
this.db = db this.db = db
this.scanner = scanner this.scanner = scanner
this.auth = auth this.auth = auth
@@ -12,6 +15,8 @@ class ApiController {
this.rssFeeds = rssFeeds this.rssFeeds = rssFeeds
this.downloadManager = downloadManager this.downloadManager = downloadManager
this.emitter = emitter this.emitter = emitter
this.clientEmitter = clientEmitter
this.MetadataPath = MetadataPath
this.router = express() this.router = express()
this.init() this.init()
@@ -29,17 +34,24 @@ class ApiController {
this.router.get('/audiobook/:id', this.getAudiobook.bind(this)) this.router.get('/audiobook/:id', this.getAudiobook.bind(this))
this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this)) this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this))
this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.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.patch('/audiobook/:id', this.updateAudiobook.bind(this))
this.router.get('/metadata/:id/:trackIndex', this.getMetadata.bind(this)) this.router.get('/metadata/:id/:trackIndex', this.getMetadata.bind(this))
this.router.patch('/match/:id', this.match.bind(this)) this.router.patch('/match/:id', this.match.bind(this))
this.router.get('/users', this.getUsers.bind(this))
this.router.post('/user', this.createUser.bind(this))
this.router.delete('/user/:id', this.deleteUser.bind(this))
this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this)) 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/password', this.userChangePassword.bind(this))
this.router.patch('/user/settings', this.userUpdateSettings.bind(this)) this.router.patch('/user/settings', this.userUpdateSettings.bind(this))
this.router.get('/users', this.getUsers.bind(this))
this.router.post('/user', this.createUser.bind(this))
this.router.patch('/user/:id', this.updateUser.bind(this))
this.router.delete('/user/:id', this.deleteUser.bind(this))
this.router.patch('/serverSettings', this.updateServerSettings.bind(this))
this.router.post('/authorize', this.authorize.bind(this)) this.router.post('/authorize', this.authorize.bind(this))
@@ -84,6 +96,10 @@ class ApiController {
} }
async deleteAllAudiobooks(req, res) { async deleteAllAudiobooks(req, res) {
if (!req.user.isRoot) {
Logger.warn('User other than root attempted to delete all audiobooks', req.user)
return res.sendStatus(403)
}
Logger.info('Removing all Audiobooks') Logger.info('Removing all Audiobooks')
var success = await this.db.recreateAudiobookDb() var success = await this.db.recreateAudiobookDb()
if (success) res.sendStatus(200) if (success) res.sendStatus(200)
@@ -100,7 +116,7 @@ class ApiController {
// Remove audiobook from users // Remove audiobook from users
for (let i = 0; i < this.db.users.length; i++) { for (let i = 0; i < this.db.users.length; i++) {
var user = this.db.users[i] var user = this.db.users[i]
var madeUpdates = user.resetAudiobookProgress(audiobook.id) var madeUpdates = user.deleteAudiobookProgress(audiobook.id)
if (madeUpdates) { if (madeUpdates) {
await this.db.updateEntity('user', user) await this.db.updateEntity('user', user)
} }
@@ -125,6 +141,10 @@ class ApiController {
} }
async deleteAudiobook(req, res) { async deleteAudiobook(req, res) {
if (!req.user.canDelete) {
Logger.warn('User attempted to delete without permission', req.user)
return res.sendStatus(403)
}
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id) var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404) if (!audiobook) return res.sendStatus(404)
@@ -133,6 +153,10 @@ class ApiController {
} }
async batchDeleteAudiobooks(req, res) { async batchDeleteAudiobooks(req, res) {
if (!req.user.canDelete) {
Logger.warn('User attempted to delete without permission', req.user)
return res.sendStatus(403)
}
var { audiobookIds } = req.body var { audiobookIds } = req.body
if (!audiobookIds || !audiobookIds.length) { if (!audiobookIds || !audiobookIds.length) {
return res.sendStatus(500) return res.sendStatus(500)
@@ -150,6 +174,10 @@ class ApiController {
} }
async batchUpdateAudiobooks(req, res) { async batchUpdateAudiobooks(req, res) {
if (!req.user.canUpdate) {
Logger.warn('User attempted to batch update without permission', req.user)
return res.sendStatus(403)
}
var audiobooks = req.body var audiobooks = req.body
if (!audiobooks || !audiobooks.length) { if (!audiobooks || !audiobooks.length) {
return res.sendStatus(500) return res.sendStatus(500)
@@ -180,17 +208,104 @@ class ApiController {
} }
async updateAudiobookTracks(req, res) { async updateAudiobookTracks(req, res) {
if (!req.user.canUpdate) {
Logger.warn('User attempted to update audiotracks without permission', req.user)
return res.sendStatus(403)
}
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id) var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404) if (!audiobook) return res.sendStatus(404)
var files = req.body.files var orderedFileData = req.body.orderedFileData
Logger.info(`Updating audiobook tracks called ${audiobook.id}`) Logger.info(`Updating audiobook tracks called ${audiobook.id}`)
audiobook.updateAudioTracks(files) audiobook.updateAudioTracks(orderedFileData)
await this.db.updateAudiobook(audiobook) await this.db.updateAudiobook(audiobook)
this.emitter('audiobook_updated', audiobook.toJSONMinified()) this.emitter('audiobook_updated', audiobook.toJSONMinified())
res.json(audiobook.toJSON()) 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) { async updateAudiobook(req, res) {
if (!req.user.canUpdate) {
Logger.warn('User attempted to update without permission', req.user)
return res.sendStatus(403)
}
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id) var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404) if (!audiobook) return res.sendStatus(404)
var hasUpdates = audiobook.update(req.body) var hasUpdates = audiobook.update(req.body)
@@ -229,7 +344,36 @@ class ApiController {
async resetUserAudiobookProgress(req, res) { async resetUserAudiobookProgress(req, res) {
req.user.resetAudiobookProgress(req.params.id) req.user.resetAudiobookProgress(req.params.id)
await this.db.updateEntity('user', req.user) await this.db.updateEntity('user', req.user)
this.emitter('user_updated', req.user.toJSONForBrowser()) this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.sendStatus(200)
}
async updateUserAudiobookProgress(req, res) {
var wasUpdated = req.user.updateAudiobookProgress(req.params.id, req.body)
if (wasUpdated) {
await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
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) res.sendStatus(200)
} }
@@ -262,6 +406,10 @@ class ApiController {
} }
async createUser(req, res) { async createUser(req, res) {
if (!req.user.isRoot) {
Logger.warn('Non-root user attempted to create user', req.user)
return res.sendStatus(403)
}
var account = req.body var account = req.body
account.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36) account.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
account.pash = await this.auth.hashPass(account.password) account.pash = await this.auth.hashPass(account.password)
@@ -271,7 +419,7 @@ class ApiController {
var newUser = new User(account) var newUser = new User(account)
var success = await this.db.insertUser(newUser) var success = await this.db.insertUser(newUser)
if (success) { if (success) {
this.emitter('user_added', newUser) this.clientEmitter(req.user.id, 'user_added', newUser)
res.json({ res.json({
user: newUser.toJSONForBrowser() user: newUser.toJSONForBrowser()
}) })
@@ -282,7 +430,41 @@ class ApiController {
} }
} }
async updateUser(req, res) {
if (!req.user.isRoot) {
Logger.error('User other than root attempting to update user', req.user)
return res.sendStatus(403)
}
var user = this.db.users.find(u => u.id === req.params.id)
if (!user) {
return res.sendStatus(404)
}
var account = req.body
// Updating password
if (account.password) {
account.pash = await this.auth.hashPass(account.password)
delete account.password
}
var hasUpdated = user.update(account)
if (hasUpdated) {
await this.db.updateEntity('user', user)
}
this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
res.json({
success: true,
user: user.toJSONForBrowser()
})
}
async deleteUser(req, res) { async deleteUser(req, res) {
if (!req.user.isRoot) {
Logger.error('User other than root attempting to delete user', req.user)
return res.sendStatus(403)
}
if (req.params.id === 'root') { if (req.params.id === 'root') {
return res.sendStatus(500) return res.sendStatus(500)
} }
@@ -302,13 +484,36 @@ class ApiController {
var userJson = user.toJSONForBrowser() var userJson = user.toJSONForBrowser()
await this.db.removeEntity('user', user.id) await this.db.removeEntity('user', user.id)
this.emitter('user_removed', userJson) this.clientEmitter(req.user.id, 'user_removed', userJson)
res.json({ res.json({
success: true success: true
}) })
} }
async updateServerSettings(req, res) {
if (!req.user.isRoot) {
Logger.error('User other than root attempting to update server settings', req.user)
return res.sendStatus(403)
}
var settingsUpdate = req.body
if (!settingsUpdate || !isObject(settingsUpdate)) {
return res.sendStatus(500)
}
var madeUpdates = this.db.serverSettings.update(settingsUpdate)
if (madeUpdates) {
await this.db.updateEntity('settings', this.db.serverSettings)
}
return res.json({
success: true,
serverSettings: this.db.serverSettings
})
}
async download(req, res) { async download(req, res) {
if (!req.user.canDownload) {
Logger.error('User attempting to download without permission', req.user)
return res.sendStatus(403)
}
var downloadId = req.params.id var downloadId = req.params.id
Logger.info('Download Request', downloadId) Logger.info('Download Request', downloadId)
var download = this.downloadManager.getDownload(downloadId) var download = this.downloadManager.getDownload(downloadId)
@@ -319,12 +524,9 @@ class ApiController {
var options = { var options = {
headers: { headers: {
// 'Content-Disposition': `attachment; filename=${download.filename}`,
'Content-Type': download.mimeType 'Content-Type': download.mimeType
// 'Content-Length': download.size
} }
} }
Logger.info('Starting Download', options, 'SIZE', download.size)
res.download(download.fullPath, download.filename, options, (err) => { res.download(download.fullPath, download.filename, options, (err) => {
if (err) { if (err) {
Logger.error('Download Error', err) Logger.error('Download Error', err)
+19 -3
View File
@@ -38,8 +38,16 @@ class Auth {
} }
async authMiddleware(req, res, next) { async authMiddleware(req, res, next) {
const authHeader = req.headers['authorization'] var token = null
const token = authHeader && authHeader.split(' ')[1]
// 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) { if (token == null) {
Logger.error('Api called without a token', req.path) Logger.error('Api called without a token', req.path)
return res.sendStatus(401) return res.sendStatus(401)
@@ -48,6 +56,10 @@ class Auth {
var user = await this.verifyToken(token) var user = await this.verifyToken(token)
if (!user) { if (!user) {
Logger.error('Verify Token User Not Found', token) Logger.error('Verify Token User Not Found', token)
return res.sendStatus(404)
}
if (!user.isActive) {
Logger.error('Verify Token User is disabled', token, user.username)
return res.sendStatus(403) return res.sendStatus(403)
} }
req.user = user req.user = user
@@ -68,7 +80,7 @@ class Auth {
} }
generateAccessToken(payload) { generateAccessToken(payload) {
return jwt.sign(payload, process.env.TOKEN_SECRET, { expiresIn: '1800s' }); return jwt.sign(payload, process.env.TOKEN_SECRET);
} }
verifyToken(token) { verifyToken(token) {
@@ -95,6 +107,10 @@ class Auth {
return res.json({ error: 'User not found' }) return res.json({ error: 'User not found' })
} }
if (!user.isActive) {
return res.json({ error: 'User unavailable' })
}
// Check passwordless root user // Check passwordless root user
if (user.id === 'root' && (!user.pash || user.pash === '')) { if (user.id === 'root' && (!user.pash || user.pash === '')) {
if (password) { if (password) {
+26 -12
View File
@@ -4,6 +4,7 @@ const jwt = require('jsonwebtoken')
const Logger = require('./Logger') const Logger = require('./Logger')
const Audiobook = require('./objects/Audiobook') const Audiobook = require('./objects/Audiobook')
const User = require('./objects/User') const User = require('./objects/User')
const ServerSettings = require('./objects/ServerSettings')
class Db { class Db {
constructor(CONFIG_PATH) { constructor(CONFIG_PATH) {
@@ -19,6 +20,8 @@ class Db {
this.users = [] this.users = []
this.audiobooks = [] this.audiobooks = []
this.settings = [] this.settings = []
this.serverSettings = null
} }
getEntityDb(entityName) { getEntityDb(entityName) {
@@ -39,15 +42,6 @@ class Db {
return 'settings' return 'settings'
} }
getDefaultSettings() {
return {
config: {
version: 1,
cardSize: 'md'
}
}
}
getDefaultUser(token) { getDefaultUser(token) {
return new User({ return new User({
id: 'root', id: 'root',
@@ -71,23 +65,43 @@ class Db {
Logger.debug('Generated default token', token) Logger.debug('Generated default token', token)
await this.insertUser(this.getDefaultUser(token)) await this.insertUser(this.getDefaultUser(token))
} }
if (!this.serverSettings) {
this.serverSettings = new ServerSettings()
await this.insertSettings(this.serverSettings)
}
} }
async load() { async load() {
var p1 = this.audiobooksDb.select(() => true).then((results) => { var p1 = this.audiobooksDb.select(() => true).then((results) => {
this.audiobooks = results.data.map(a => new Audiobook(a)) this.audiobooks = results.data.map(a => new Audiobook(a))
Logger.info(`Audiobooks Loaded ${this.audiobooks.length}`) Logger.info(`[DB] Audiobooks Loaded ${this.audiobooks.length}`)
}) })
var p2 = this.usersDb.select(() => true).then((results) => { var p2 = this.usersDb.select(() => true).then((results) => {
this.users = results.data.map(u => new User(u)) this.users = results.data.map(u => new User(u))
Logger.info(`Users Loaded ${this.users.length}`) Logger.info(`[DB] Users Loaded ${this.users.length}`)
}) })
var p3 = this.settingsDb.select(() => true).then((results) => { var p3 = this.settingsDb.select(() => true).then((results) => {
this.settings = results if (results.data && results.data.length) {
this.settings = results.data
var serverSettings = this.settings.find(s => s.id === 'server-settings')
if (serverSettings) {
this.serverSettings = new ServerSettings(serverSettings)
}
}
}) })
await Promise.all([p1, p2, p3]) await Promise.all([p1, p2, p3])
} }
insertSettings(settings) {
return this.settingsDb.insert([settings]).then((results) => {
Logger.debug(`[DB] Inserted ${results.inserted} settings`)
this.settings = this.settings.concat(settings)
}).catch((error) => {
Logger.error(`[DB] Insert settings Failed ${error}`)
})
}
insertAudiobook(audiobook) { insertAudiobook(audiobook) {
return this.insertAudiobooks([audiobook]) return this.insertAudiobooks([audiobook])
} }
+210 -46
View File
@@ -1,16 +1,18 @@
const Path = require('path') const Path = require('path')
const fs = require('fs-extra') const fs = require('fs-extra')
const archiver = require('archiver')
const workerThreads = require('worker_threads') const workerThreads = require('worker_threads')
const Logger = require('./Logger') const Logger = require('./Logger')
const Download = require('./objects/Download') const Download = require('./objects/Download')
const { writeConcatFile } = require('./utils/ffmpegHelpers') const { writeConcatFile, writeMetadataFile } = require('./utils/ffmpegHelpers')
const { getFileSize } = require('./utils/fileUtils') const { getFileSize } = require('./utils/fileUtils')
const TAG = 'DownloadManager'
class DownloadManager { class DownloadManager {
constructor(db, MetadataPath, emitter) { constructor(db, MetadataPath, AudiobookPath, emitter) {
this.db = db this.db = db
this.MetadataPath = MetadataPath this.MetadataPath = MetadataPath
this.AudiobookPath = AudiobookPath
this.emitter = emitter this.emitter = emitter
this.downloadDirPath = Path.join(this.MetadataPath, 'downloads') this.downloadDirPath = Path.join(this.MetadataPath, 'downloads')
@@ -49,12 +51,13 @@ class DownloadManager {
this.prepareDownload(client, audiobook, options) this.prepareDownload(client, audiobook, options)
} }
getBestFileType(tracks) { removeSocketRequest(socket, downloadId) {
if (!tracks || !tracks.length) { var download = this.downloads.find(d => d.id === downloadId)
return null if (!download) {
Logger.error('Remove download request download not found ' + downloadId)
return
} }
var firstTrack = tracks[0] this.removeDownload(download)
return firstTrack.ext.substr(1)
} }
async prepareDownload(client, audiobook, options = {}) { async prepareDownload(client, audiobook, options = {}) {
@@ -67,26 +70,29 @@ class DownloadManager {
var downloadType = options.type || 'singleAudio' var downloadType = options.type || 'singleAudio'
delete options.type delete options.type
var filepath = null
var filename = null
var fileext = null var fileext = null
var audiobookDirname = Path.basename(audiobook.path) var audiobookDirname = Path.basename(audiobook.path)
if (downloadType === 'singleAudio') { if (downloadType === 'singleAudio') {
var audioFileType = options.audioFileType || this.getBestFileType(audiobook.tracks) var audioFileType = options.audioFileType || '.m4b'
delete options.audioFileType delete options.audioFileType
filename = audiobookDirname + '.' + audioFileType if (audioFileType === 'same') {
fileext = '.' + audioFileType var firstTrack = audiobook.tracks[0]
filepath = Path.join(dlpath, filename) audioFileType = firstTrack.ext
}
fileext = audioFileType
} else if (downloadType === 'zip') {
fileext = '.zip'
} }
var filename = audiobookDirname + fileext
var downloadData = { var downloadData = {
id: downloadId, id: downloadId,
audiobookId: audiobook.id, audiobookId: audiobook.id,
type: downloadType, type: downloadType,
options: options, options: options,
dirpath: dlpath, dirpath: dlpath,
fullPath: filepath, fullPath: Path.join(dlpath, filename),
filename, filename,
ext: fileext, ext: fileext,
userId: (client && client.user) ? client.user.id : null, userId: (client && client.user) ? client.user.id : null,
@@ -94,6 +100,7 @@ class DownloadManager {
} }
var download = new Download() var download = new Download()
download.setData(downloadData) download.setData(downloadData)
download.setTimeoutTimer(this.downloadTimedOut.bind(this))
if (downloadData.socket) { if (downloadData.socket) {
downloadData.socket.emit('download_started', download.toJSON()) downloadData.socket.emit('download_started', download.toJSON())
@@ -101,30 +108,179 @@ class DownloadManager {
if (download.type === 'singleAudio') { if (download.type === 'singleAudio') {
this.processSingleAudioDownload(audiobook, download) this.processSingleAudioDownload(audiobook, download)
} else if (download.type === 'zip') {
this.processZipDownload(audiobook, download)
} }
} }
async processZipDownload(audiobook, download) {
this.pendingDownloads.push({
id: download.id,
download
})
Logger.info(`[DownloadManager] Processing Zip download ${download.fullPath}`)
var success = await this.zipAudiobookDir(audiobook.fullPath, download.fullPath).then(() => {
return true
}).catch((error) => {
Logger.error('[DownloadManager] Process Zip Failed', error)
return false
})
this.sendResult(download, { success })
}
zipAudiobookDir(audiobookPath, downloadPath) {
return new Promise((resolve, reject) => {
// create a file to stream archive data to
const output = fs.createWriteStream(downloadPath)
const archive = archiver('zip', {
zlib: { level: 9 } // Sets the compression level.
})
// listen for all archive data to be written
// 'close' event is fired only when a file descriptor is involved
output.on('close', () => {
Logger.info(archive.pointer() + ' total bytes')
Logger.debug('archiver has been finalized and the output file descriptor has closed.')
resolve()
})
// This event is fired when the data source is drained no matter what was the data source.
// It is not part of this library but rather from the NodeJS Stream API.
// @see: https://nodejs.org/api/stream.html#stream_event_end
output.on('end', () => {
Logger.debug('Data has been drained')
})
// good practice to catch warnings (ie stat failures and other non-blocking errors)
archive.on('warning', function (err) {
if (err.code === 'ENOENT') {
// log warning
Logger.warn(`[DownloadManager] Archiver warning: ${err.message}`)
} else {
// throw error
Logger.error(`[DownloadManager] Archiver error: ${err.message}`)
// throw err
reject(err)
}
})
archive.on('error', function (err) {
Logger.error(`[DownloadManager] Archiver error: ${err.message}`)
reject(err)
})
// pipe archive data to the file
archive.pipe(output)
archive.directory(audiobookPath, false)
archive.finalize()
})
}
async processSingleAudioDownload(audiobook, download) { async processSingleAudioDownload(audiobook, download) {
// var ffmpeg = Ffmpeg()
var concatFilePath = Path.join(download.dirpath, 'files.txt') // If changing audio file type then encoding is needed
await writeConcatFile(audiobook.tracks, concatFilePath) var audioRequiresEncode = audiobook.tracks[0].ext !== download.ext
var shouldIncludeCover = download.includeCover && audiobook.book.cover
var firstTrackIsM4b = audiobook.tracks[0].ext.toLowerCase() === '.m4b'
var isOneTrack = audiobook.tracks.length === 1
const ffmpegInputs = []
if (!isOneTrack) {
var concatFilePath = Path.join(download.dirpath, 'files.txt')
await writeConcatFile(audiobook.tracks, concatFilePath)
ffmpegInputs.push({
input: concatFilePath,
options: ['-safe 0', '-f concat']
})
} else {
ffmpegInputs.push({
input: audiobook.tracks[0].fullPath,
options: firstTrackIsM4b ? ['-f mp4'] : []
})
}
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
var ffmpegOptions = [`-loglevel ${logLevel}`]
var ffmpegOutputOptions = []
if (audioRequiresEncode) {
ffmpegOptions = ffmpegOptions.concat([
'-map 0:a',
'-acodec aac',
'-ac 2',
'-b:a 64k',
'-id3v2_version 3'
])
} else {
ffmpegOptions.push('-max_muxing_queue_size 1000')
if (isOneTrack && firstTrackIsM4b && !shouldIncludeCover) {
ffmpegOptions.push('-c copy')
} else {
ffmpegOptions.push('-c:a copy')
}
}
if (download.ext === '.m4b') {
Logger.info('Concat m4b\'s use -f mp4')
ffmpegOutputOptions.push('-f mp4')
}
if (download.includeMetadata) {
var metadataFilePath = Path.join(download.dirpath, 'metadata.txt')
await writeMetadataFile(audiobook, metadataFilePath)
ffmpegInputs.push({
input: metadataFilePath
})
ffmpegOptions.push('-map_metadata 1')
}
if (shouldIncludeCover) {
var _cover = audiobook.book.cover
if (_cover.startsWith(Path.sep + 'local')) {
_cover = Path.join(this.AudiobookPath, _cover.replace(Path.sep + 'local', ''))
Logger.debug('Local cover url', _cover)
}
ffmpegInputs.push({
input: _cover,
options: ['-f image2pipe']
})
ffmpegOptions.push('-vf [2:v]crop=trunc(iw/2)*2:trunc(ih/2)*2')
ffmpegOptions.push('-map 2:v')
}
var workerData = { var workerData = {
input: concatFilePath, inputs: ffmpegInputs,
inputFormat: 'concat', options: ffmpegOptions,
inputOption: '-safe 0', outputOptions: ffmpegOutputOptions,
options: [ output: download.fullPath,
'-loglevel warning',
'-map 0:a',
'-c:a copy'
],
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) => { worker.on('message', (message) => {
if (message != null && typeof message === 'object') { if (message != null && typeof message === 'object') {
if (message.type === 'RESULT') { if (message.type === 'RESULT') {
this.sendResult(download, message) if (!download.isTimedOut) {
this.sendResult(download, message)
}
} }
} else { } else {
Logger.error('Invalid worker message', message) Logger.error('Invalid worker message', message)
@@ -137,6 +293,17 @@ class DownloadManager {
}) })
} }
async downloadTimedOut(download) {
Logger.info(`[DownloadManager] Download ${download.id} timed out (${download.timeoutTimeMs}ms)`)
if (download.socket) {
var downloadJson = download.toJSON()
downloadJson.isTimedOut = true
download.socket.emit('download_failed', downloadJson)
}
this.removeDownload(download)
}
async downloadExpired(download) { async downloadExpired(download) {
Logger.info(`[DownloadManager] Download ${download.id} expired`) Logger.info(`[DownloadManager] Download ${download.id} expired`)
@@ -147,6 +314,8 @@ class DownloadManager {
} }
async sendResult(download, result) { async sendResult(download, result) {
download.clearTimeoutTimer()
// Remove pending download // Remove pending download
this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id) this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id)
@@ -165,18 +334,8 @@ class DownloadManager {
return return
} }
// Remove files.txt if it was used var filesize = await getFileSize(download.fullPath)
if (download.type === 'singleAudio') { download.setComplete(filesize)
var concatFilePath = Path.join(download.dirpath, 'files.txt')
try {
await fs.remove(concatFilePath)
} catch (error) {
Logger.error('[DownloadManager] Failed to remove files.txt')
}
}
result.size = await getFileSize(download.fullPath)
download.setComplete(result)
if (download.socket) { if (download.socket) {
download.socket.emit('download_ready', download.toJSON()) download.socket.emit('download_ready', download.toJSON())
} }
@@ -189,15 +348,20 @@ class DownloadManager {
async removeDownload(download) { async removeDownload(download) {
Logger.info('[DownloadManager] Removing download ' + download.id) Logger.info('[DownloadManager] Removing download ' + download.id)
download.clearTimeoutTimer()
download.clearExpirationTimer()
var pendingDl = this.pendingDownloads.find(d => d.id === download.id) var pendingDl = this.pendingDownloads.find(d => d.id === download.id)
if (pendingDl) { if (pendingDl) {
this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id) this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id)
Logger.warn(`[DownloadManager] Removing download in progress - stopping worker`) Logger.warn(`[DownloadManager] Removing download in progress - stopping worker`)
try { if (pendingDl.worker) {
pendingDl.worker.postMessage('STOP') try {
} catch (error) { pendingDl.worker.postMessage('STOP')
Logger.error('[DownloadManager] Error posting stop message to worker', error) } catch (error) {
Logger.error('[DownloadManager] Error posting stop message to worker', error)
}
} }
} }
+42
View 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
+3 -3
View File
@@ -4,13 +4,13 @@ const fs = require('fs-extra')
const Logger = require('./Logger') const Logger = require('./Logger')
class HlsController { class HlsController {
constructor(db, scanner, auth, streamManager, emitter, MetadataPath) { constructor(db, scanner, auth, streamManager, emitter, StreamsPath) {
this.db = db this.db = db
this.scanner = scanner this.scanner = scanner
this.auth = auth this.auth = auth
this.streamManager = streamManager this.streamManager = streamManager
this.emitter = emitter this.emitter = emitter
this.MetadataPath = MetadataPath this.StreamsPath = StreamsPath
this.router = express() this.router = express()
this.init() this.init()
@@ -28,7 +28,7 @@ class HlsController {
async streamFileRequest(req, res) { async streamFileRequest(req, res) {
var streamId = req.params.stream 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 // development test stream - ignore
if (streamId === 'test') { if (streamId === 'test') {
+236 -126
View File
@@ -1,10 +1,13 @@
const fs = require('fs-extra')
const Path = require('path')
const Logger = require('./Logger') const Logger = require('./Logger')
const BookFinder = require('./BookFinder') const BookFinder = require('./BookFinder')
const Audiobook = require('./objects/Audiobook') const Audiobook = require('./objects/Audiobook')
const audioFileScanner = require('./utils/audioFileScanner') const audioFileScanner = require('./utils/audioFileScanner')
const { getAllAudiobookFiles } = require('./utils/scandir') const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('./utils/scandir')
const { comparePaths, getIno } = require('./utils/index') const { comparePaths, getIno } = require('./utils/index')
const { secondsToTimestamp } = require('./utils/fileUtils') const { secondsToTimestamp } = require('./utils/fileUtils')
const { ScanResult } = require('./utils/constants')
class Scanner { class Scanner {
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) { constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) {
@@ -22,25 +25,6 @@ class Scanner {
return this.db.audiobooks 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) { async setAudioFileInos(audiobookDataAudioFiles, audiobookAudioFiles) {
for (let i = 0; i < audiobookDataAudioFiles.length; i++) { for (let i = 0; i < audiobookDataAudioFiles.length; i++) {
var abdFile = audiobookDataAudioFiles[i] var abdFile = audiobookDataAudioFiles[i]
@@ -60,30 +44,162 @@ class Scanner {
return audiobookDataAudioFiles.filter(abdFile => !!abdFile.ino) return audiobookDataAudioFiles.filter(abdFile => !!abdFile.ino)
} }
async scanAudiobookData(audiobookData) {
var existingAudiobook = this.audiobooks.find(a => a.ino === audiobookData.ino)
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`)
await this.db.removeEntity('audiobook', existingAudiobook.id)
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
return ScanResult.REMOVED
}
// 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)
var removedAudioFiles = existingAudiobook.audioFiles.filter(file => !abdAudioFileInos.includes(file.ino))
if (removedAudioFiles.length) {
Logger.info(`[Scanner] ${removedAudioFiles.length} audio files removed for audiobook "${existingAudiobook.title}"`)
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
audiobookData.audioFiles.forEach((file) => {
var existingAudioFile = existingAudiobook.getAudioFileByIno(file.ino)
if (existingAudioFile) { // Audio file exists, sync paths
if (existingAudiobook.syncAudioFile(existingAudioFile, file)) {
hasUpdatedAudioFiles = true
}
} else {
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) {
Logger.info(`[Scanner] ${newAudioFiles.length} new audio files were found for audiobook "${existingAudiobook.title}"`)
// Scan new audio files found - sets tracks
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`)
await this.db.removeEntity('audiobook', existingAudiobook.id)
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
return ScanResult.REMOVED
}
var hasUpdates = removedAudioFiles.length || removedAudioTracks.length || newAudioFiles.length || hasUpdatedAudioFiles
if (existingAudiobook.checkUpdateMissingParts()) {
Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`)
hasUpdates = true
}
if (existingAudiobook.syncOtherFiles(audiobookData.otherFiles)) {
hasUpdates = true
}
// Syncs path and fullPath
if (existingAudiobook.syncPaths(audiobookData)) {
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()
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`)
existingAudiobook.lastUpdate = Date.now()
await this.db.updateAudiobook(existingAudiobook)
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
return ScanResult.UPDATED
}
return ScanResult.UPTODATE
}
// NEW: Check new audiobook
if (!audiobookData.audioFiles.length) {
Logger.error('[Scanner] No valid audio tracks for Audiobook', audiobookData.path)
return ScanResult.NOTHING
}
var audiobook = new Audiobook()
audiobook.setData(audiobookData)
await audioFileScanner.scanAudioFiles(audiobook, audiobookData.audioFiles)
if (!audiobook.tracks.length) {
Logger.warn('[Scanner] Invalid audiobook, no valid tracks', audiobook.title)
return ScanResult.NOTHING
}
audiobook.checkUpdateMissingParts()
audiobook.setChapters()
Logger.info(`[Scanner] Audiobook "${audiobook.title}" Scanned (${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
await this.db.insertAudiobook(audiobook)
this.emitter('audiobook_added', audiobook.toJSONMinified())
return ScanResult.ADDED
}
async scan() { async scan() {
// TODO: This temporary fix from pre-release should be removed soon, including the "fixRelativePath" and "checkUpdateInos"
// TEMP - fix relative file paths // TEMP - fix relative file paths
// TEMP - update ino for each audiobook // TEMP - update ino for each audiobook
if (this.audiobooks.length) { if (this.audiobooks.length) {
for (let i = 0; i < this.audiobooks.length; i++) { for (let i = 0; i < this.audiobooks.length; i++) {
var ab = this.audiobooks[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 // Update ino if inos are not set
var shouldUpdateIno = !ab.ino || (ab.audioFiles || []).find(abf => abf.ino === ab.ino) var shouldUpdateIno = ab.hasMissingIno
if (shouldUpdateIno) { if (shouldUpdateIno) {
await ab.checkUpdateInos() Logger.debug(`Updating inos for ${ab.title}`)
} var hasUpdates = await ab.checkUpdateInos()
if (shouldUpdate) { if (hasUpdates) {
await this.db.updateAudiobook(ab) await this.db.updateAudiobook(ab)
}
} }
} }
} }
const scanStart = Date.now() const scanStart = Date.now()
var audiobookDataFound = await getAllAudiobookFiles(this.AudiobookPath) var audiobookDataFound = await scanRootDir(this.AudiobookPath, this.db.serverSettings)
// Set ino for each ab data as a string // Remove audiobooks with no inode
audiobookDataFound = await this.setAudiobookDataInos(audiobookDataFound) audiobookDataFound = audiobookDataFound.filter(abd => abd.ino)
if (this.cancelScan) { if (this.cancelScan) {
this.cancelScan = false this.cancelScan = false
@@ -93,18 +209,21 @@ class Scanner {
var scanResults = { var scanResults = {
removed: 0, removed: 0,
updated: 0, updated: 0,
added: 0 added: 0,
missing: 0
} }
// Check for removed audiobooks // Check for removed audiobooks
for (let i = 0; i < this.audiobooks.length; i++) { 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) { if (!dataFound) {
Logger.info(`[Scanner] Removing audiobook "${this.audiobooks[i].title}" - no longer in dir`) Logger.info(`[Scanner] Audiobook "${audiobook.title}" is missing`)
var audiobookJSON = this.audiobooks[i].toJSONMinified() audiobook.isMissing = true
await this.db.removeEntity('audiobook', this.audiobooks[i].id) audiobook.lastUpdate = Date.now()
scanResults.removed++ scanResults.missing++
this.emitter('audiobook_removed', audiobookJSON) await this.db.updateAudiobook(audiobook)
this.emitter('audiobook_updated', audiobook.toJSONMinified())
} }
if (this.cancelScan) { if (this.cancelScan) {
this.cancelScan = false this.cancelScan = false
@@ -112,97 +231,14 @@ class Scanner {
} }
} }
// Check for new and updated audiobooks
for (let i = 0; i < audiobookDataFound.length; i++) { for (let i = 0; i < audiobookDataFound.length; i++) {
var audiobookData = audiobookDataFound[i] var audiobookData = audiobookDataFound[i]
var existingAudiobook = this.audiobooks.find(a => a.ino === audiobookData.ino) var result = await this.scanAudiobookData(audiobookData)
Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`) if (result === ScanResult.ADDED) scanResults.added++
if (result === ScanResult.REMOVED) scanResults.removed++
if (result === ScanResult.UPDATED) scanResults.updated++
if (existingAudiobook) {
if (!audiobookData.audioFiles.length) {
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid audio files found - removing audiobook`)
await this.db.removeEntity('audiobook', existingAudiobook.id)
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
scanResults.removed++
} else {
audiobookData.audioFiles = await this.setAudioFileInos(audiobookData.audioFiles, existingAudiobook.audioFiles)
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
// Check for audio files that were removed
var removedAudioFiles = existingAudiobook.audioFiles.filter(file => !abdAudioFileInos.includes(file.ino))
if (removedAudioFiles.length) {
Logger.info(`[Scanner] ${removedAudioFiles.length} audio files removed for audiobook "${existingAudiobook.title}"`)
removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af))
}
// Check for new audio files and sync existing audio files
var newAudioFiles = []
var hasUpdatedAudioFiles = false
audiobookData.audioFiles.forEach((file) => {
var existingAudioFile = existingAudiobook.getAudioFileByIno(file.ino)
if (existingAudioFile) { // Audio file exists, sync paths
if (existingAudiobook.syncAudioFile(existingAudioFile, file)) {
hasUpdatedAudioFiles = true
}
} else {
newAudioFiles.push(file)
}
})
if (newAudioFiles.length) {
Logger.info(`[Scanner] ${newAudioFiles.length} new audio files were found for audiobook "${existingAudiobook.title}"`)
// Scan new audio files found
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
}
if (!existingAudiobook.tracks.length) {
Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`)
await this.db.removeEntity('audiobook', existingAudiobook.id)
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
} else {
var hasUpdates = removedAudioFiles.length || newAudioFiles.length || hasUpdatedAudioFiles
if (existingAudiobook.checkUpdateMissingParts()) {
Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`)
hasUpdates = true
}
if (existingAudiobook.syncOtherFiles(audiobookData.otherFiles)) {
hasUpdates = true
}
// Syncs path and fullPath
if (existingAudiobook.syncPaths(audiobookData)) {
hasUpdates = true
}
if (hasUpdates) {
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`)
existingAudiobook.lastUpdate = Date.now()
await this.db.updateAudiobook(existingAudiobook)
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
scanResults.updated++
}
}
} // end if update existing
} else {
if (!audiobookData.audioFiles.length) {
Logger.error('[Scanner] No valid audio tracks for Audiobook', audiobookData.path)
} else {
var audiobook = new Audiobook()
audiobook.setData(audiobookData)
await audioFileScanner.scanAudioFiles(audiobook, audiobookData.audioFiles)
if (!audiobook.tracks.length) {
Logger.warn('[Scanner] Invalid audiobook, no valid tracks', audiobook.title)
} else {
audiobook.checkUpdateMissingParts()
Logger.info(`[Scanner] Audiobook "${audiobook.title}" Scanned (${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
await this.db.insertAudiobook(audiobook)
this.emitter('audiobook_added', audiobook.toJSONMinified())
scanResults.added++
}
} // end if add new
}
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length) var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
this.emitter('scan_progress', { this.emitter('scan_progress', {
scanType: 'files', scanType: 'files',
@@ -218,10 +254,84 @@ class Scanner {
} }
} }
const scanElapsed = Math.floor((Date.now() - scanStart) / 1000) 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 return scanResults
} }
async scanAudiobook(audiobookPath) {
Logger.debug('[Scanner] scanAudiobook', audiobookPath)
var audiobookData = await getAudiobookFileData(this.AudiobookPath, audiobookPath, this.db.serverSettings)
if (!audiobookData) {
return ScanResult.NOTHING
}
audiobookData.ino = await getIno(audiobookData.fullPath)
return this.scanAudiobookData(audiobookData)
}
// Files were modified in this directory, check it out
async checkDir(dir) {
var exists = await fs.pathExists(dir)
if (!exists) {
// Audiobook was deleted, TODO: Should confirm this better
var audiobook = this.db.audiobooks.find(ab => ab.fullPath === dir)
if (audiobook) {
var audiobookJSON = audiobook.toJSONMinified()
await this.db.removeEntity('audiobook', audiobook.id)
this.emitter('audiobook_removed', audiobookJSON)
return ScanResult.REMOVED
}
// Path inside audiobook was deleted, scan audiobook
audiobook = this.db.audiobooks.find(ab => dir.startsWith(ab.fullPath))
if (audiobook) {
Logger.info(`[Scanner] Path inside audiobook "${audiobook.title}" was deleted: ${dir}`)
return this.scanAudiobook(audiobook.fullPath)
}
Logger.warn('[Scanner] Path was deleted but no audiobook found', dir)
return ScanResult.NOTHING
}
// Check if this is a subdirectory of an audiobook
var audiobook = this.db.audiobooks.find((ab) => dir.startsWith(ab.fullPath))
if (audiobook) {
Logger.debug(`[Scanner] Check Dir audiobook "${audiobook.title}" found: ${dir}`)
return this.scanAudiobook(audiobook.fullPath)
}
// Check if an audiobook is a subdirectory of this dir
audiobook = this.db.audiobooks.find(ab => ab.fullPath.startsWith(dir))
if (audiobook) {
Logger.warn(`[Scanner] Files were added/updated in a root directory of an existing audiobook, ignore files: ${dir}`)
return ScanResult.NOTHING
}
// Must be a new audiobook
Logger.debug(`[Scanner] Check Dir must be a new audiobook: ${dir}`)
return this.scanAudiobook(dir)
}
// Array of files that may have been renamed, removed or added
async filesChanged(filepaths) {
if (!filepaths.length) return ScanResult.NOTHING
var relfilepaths = filepaths.map(path => path.replace(this.AudiobookPath, ''))
var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths, true)
Logger.debug(`[Scanner] fileGroupings `, filepaths, fileGroupings)
var results = []
for (const dir in fileGroupings) {
Logger.debug(`[Scanner] Check dir ${dir}`)
var fullPath = Path.join(this.AudiobookPath, dir)
var result = await this.checkDir(fullPath)
Logger.debug(`[Scanner] Check dir result ${result}`)
results.push(result)
}
return results
}
async fetchMetadata(id, trackIndex = 0) { async fetchMetadata(id, trackIndex = 0) {
var audiobook = this.audiobooks.find(a => a.id === id) var audiobook = this.audiobooks.find(a => a.id === id)
if (!audiobook) { if (!audiobook) {
+104 -14
View File
@@ -3,6 +3,7 @@ const express = require('express')
const http = require('http') const http = require('http')
const SocketIO = require('socket.io') const SocketIO = require('socket.io')
const fs = require('fs-extra') const fs = require('fs-extra')
const fileUpload = require('express-fileupload')
const Auth = require('./Auth') const Auth = require('./Auth')
const Watcher = require('./Watcher') const Watcher = require('./Watcher')
@@ -13,6 +14,7 @@ const HlsController = require('./HlsController')
const StreamManager = require('./StreamManager') const StreamManager = require('./StreamManager')
const RssFeeds = require('./RssFeeds') const RssFeeds = require('./RssFeeds')
const DownloadManager = require('./DownloadManager') const DownloadManager = require('./DownloadManager')
// const EbookReader = require('./EbookReader')
const Logger = require('./Logger') const Logger = require('./Logger')
class Server { class Server {
@@ -33,9 +35,10 @@ class Server {
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.emitter.bind(this)) this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.emitter.bind(this))
this.streamManager = new StreamManager(this.db, this.MetadataPath) this.streamManager = new StreamManager(this.db, this.MetadataPath)
this.rssFeeds = new RssFeeds(this.Port, this.db) this.rssFeeds = new RssFeeds(this.Port, this.db)
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.emitter.bind(this)) 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.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.MetadataPath) 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.server = null
this.io = null this.io = null
@@ -50,8 +53,12 @@ class Server {
get audiobooks() { get audiobooks() {
return this.db.audiobooks return this.db.audiobooks
} }
get settings() { get serverSettings() {
return this.db.settings return this.db.serverSettings
}
getClientsForUser(userId) {
return Object.values(this.clients).filter(c => c.user && c.user.id === userId)
} }
emitter(ev, data) { emitter(ev, data) {
@@ -59,8 +66,23 @@ class Server {
this.io.emit(ev, data) this.io.emit(ev, data)
} }
async fileAddedUpdated({ path, fullPath }) { } clientEmitter(userId, ev, data) {
async fileRemoved({ path, fullPath }) { } var clients = this.getClientsForUser(userId)
if (!clients.length) {
return Logger.error(`[Server] clientEmitter - no clients found for user ${userId}`)
}
clients.forEach((client) => {
if (client.socket) {
client.socket.emit(ev, data)
}
})
}
async filesChanged(files) {
Logger.info('[Server]', files.length, 'Files Changed')
var result = await this.scanner.filesChanged(files)
Logger.info('[Server] Files changed result', result)
}
async scan() { async scan() {
Logger.info('[Server] Starting Scan') Logger.info('[Server] Starting Scan')
@@ -90,24 +112,68 @@ class Server {
async init() { async init() {
Logger.info('[Server] Init') Logger.info('[Server] Init')
await this.streamManager.ensureStreamsDir()
await this.streamManager.removeOrphanStreams() await this.streamManager.removeOrphanStreams()
await this.downloadManager.removeOrphanDownloads() await this.downloadManager.removeOrphanDownloads()
await this.db.init() await this.db.init()
this.auth.init() this.auth.init()
this.watcher.initWatcher() this.watcher.initWatcher()
this.watcher.on('file_added', this.fileAddedUpdated.bind(this)) this.watcher.on('files', this.filesChanged.bind(this))
this.watcher.on('file_removed', this.fileRemoved.bind(this))
this.watcher.on('file_updated', this.fileAddedUpdated.bind(this))
} }
authMiddleware(req, res, next) { authMiddleware(req, res, next) {
this.auth.authMiddleware(req, res, next) 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)
}
async start() { async start() {
Logger.info('=== Starting Server ===') Logger.info('=== Starting Server ===')
await this.init() await this.init()
const app = express() const app = express()
@@ -115,6 +181,7 @@ class Server {
this.server = http.createServer(app) this.server = http.createServer(app)
app.use(this.auth.cors) app.use(this.auth.cors)
app.use(fileUpload())
// Static path to generated nuxt // Static path to generated nuxt
const distPath = Path.join(global.appRoot, '/client/dist') const distPath = Path.join(global.appRoot, '/client/dist')
@@ -125,6 +192,8 @@ class Server {
app.use(express.static(this.AudiobookPath)) 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(this.MetadataPath))
app.use(express.static(Path.join(global.appRoot, 'static'))) app.use(express.static(Path.join(global.appRoot, 'static')))
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
@@ -132,12 +201,16 @@ class Server {
// Dynamic routes are not generated on client // Dynamic routes are not generated on client
app.get('/audiobook/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) 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('/api', this.authMiddleware.bind(this), this.apiController.router)
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router) app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
// app.use('/hls', this.hlsController.router) // app.use('/ebook', this.ebookReader.router)
app.use('/feeds', this.rssFeeds.router) app.use('/feeds', this.rssFeeds.router)
app.post('/upload', this.authMiddleware.bind(this), this.handleUpload.bind(this))
app.post('/login', (req, res) => this.auth.login(req, res)) app.post('/login', (req, res) => this.auth.login(req, res))
app.post('/logout', this.logout.bind(this)) app.post('/logout', this.logout.bind(this))
app.get('/ping', (req, res) => { app.get('/ping', (req, res) => {
@@ -145,7 +218,6 @@ class Server {
res.json({ success: true }) res.json({ success: true })
}) })
// Used in development to set-up streams without authentication // Used in development to set-up streams without authentication
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
app.use('/test-hls', this.hlsController.router) app.use('/test-hls', this.hlsController.router)
@@ -182,13 +254,23 @@ class Server {
Logger.info('[SOCKET] Socket Connected', socket.id) Logger.info('[SOCKET] Socket Connected', socket.id)
socket.on('auth', (token) => this.authenticateSocket(socket, token)) socket.on('auth', (token) => this.authenticateSocket(socket, token))
// Scanning
socket.on('scan', this.scan.bind(this)) socket.on('scan', this.scan.bind(this))
socket.on('scan_covers', this.scanCovers.bind(this)) socket.on('scan_covers', this.scanCovers.bind(this))
socket.on('cancel_scan', this.cancelScan.bind(this)) socket.on('cancel_scan', this.cancelScan.bind(this))
// Streaming
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId)) socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket)) socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload)) socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload))
socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket.sheepClient, payload))
// Downloading
socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload)) socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload))
socket.on('remove_download', (downloadId) => this.downloadManager.removeSocketRequest(socket, downloadId))
socket.on('test', () => { socket.on('test', () => {
socket.emit('test_received', socket.id) socket.emit('test_received', socket.id)
}) })
@@ -213,6 +295,14 @@ class Server {
res.sendStatus(200) res.sendStatus(200)
} }
audiobookProgressUpdate(client, progressPayload) {
if (!client || !client.user) {
Logger.error('[Server] audiobookProgressUpdate invalid socket client')
return
}
client.user.updateAudiobookProgress(progressPayload.audiobookId, progressPayload)
}
async authenticateSocket(socket, token) { async authenticateSocket(socket, token) {
var user = await this.auth.verifyToken(token) var user = await this.auth.verifyToken(token)
if (!user) { if (!user) {
@@ -239,7 +329,7 @@ class Server {
} }
const initialPayload = { const initialPayload = {
settings: this.settings, serverSettings: this.serverSettings.toJSON(),
isScanning: this.isScanning, isScanning: this.isScanning,
isInitialized: this.isInitialized, isInitialized: this.isInitialized,
audiobookPath: this.AudiobookPath, audiobookPath: this.AudiobookPath,
+50 -24
View File
@@ -1,15 +1,16 @@
const Stream = require('./objects/Stream') const Stream = require('./objects/Stream')
const StreamTest = require('./test/StreamTest') // const StreamTest = require('./test/StreamTest')
const Logger = require('./Logger') const Logger = require('./Logger')
const fs = require('fs-extra') const fs = require('fs-extra')
const Path = require('path') const Path = require('path')
class StreamManager { class StreamManager {
constructor(db, STREAM_PATH) { constructor(db, MetadataPath) {
this.db = db this.db = db
this.MetadataPath = MetadataPath
this.streams = [] this.streams = []
this.streamPath = STREAM_PATH this.StreamsPath = Path.join(this.MetadataPath, 'streams')
} }
get audiobooks() { get audiobooks() {
@@ -25,7 +26,7 @@ class StreamManager {
} }
async openStream(client, audiobook) { async openStream(client, audiobook) {
var stream = new Stream(this.streamPath, client, audiobook) var stream = new Stream(this.StreamsPath, client, audiobook)
stream.on('closed', () => { stream.on('closed', () => {
this.removeStream(stream) this.removeStream(stream)
@@ -44,29 +45,54 @@ class StreamManager {
return stream return stream
} }
ensureStreamsDir() {
return fs.ensureDir(this.StreamsPath)
}
removeOrphanStreamFiles(streamId) { removeOrphanStreamFiles(streamId) {
try { try {
var streamPath = Path.join(this.streamPath, streamId) var StreamsPath = Path.join(this.StreamsPath, streamId)
return fs.remove(streamPath) return fs.remove(StreamsPath)
} catch (error) { } catch (error) {
Logger.debug('No orphan stream', streamId) Logger.debug('No orphan stream', streamId)
return false return false
} }
} }
async removeOrphanStreams() { async tempCheckStrayStreams() {
try { try {
var dirs = await fs.readdir(this.streamPath) var dirs = await fs.readdir(this.MetadataPath)
if (!dirs || !dirs.length) return true if (!dirs || !dirs.length) return true
await Promise.all(dirs.map(async (dirname) => { 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}`) Logger.info(`Removing Orphan Stream ${dirname}`)
return fs.remove(fullPath) return fs.remove(fullPath)
})) }))
return true return true
} catch (error) { } catch (error) {
Logger.debug('No orphan stream', streamId) Logger.debug('No orphan stream', error)
return false return false
} }
} }
@@ -102,27 +128,27 @@ class StreamManager {
this.db.updateUserStream(client.user.id, null) this.db.updateUserStream(client.user.id, null)
} }
async openTestStream(streamPath, audiobookId) { async openTestStream(StreamsPath, audiobookId) {
Logger.info('Open Stream Test Request', audiobookId) Logger.info('Open Stream Test Request', audiobookId)
var audiobook = this.audiobooks.find(ab => ab.id === audiobookId) // var audiobook = this.audiobooks.find(ab => ab.id === audiobookId)
var stream = new StreamTest(streamPath, audiobook) // var stream = new StreamTest(StreamsPath, audiobook)
stream.on('closed', () => { // stream.on('closed', () => {
console.log('Stream closed') // console.log('Stream closed')
}) // })
var playlistUri = await stream.generatePlaylist() // var playlistUri = await stream.generatePlaylist()
stream.start() // stream.start()
Logger.info('Stream Playlist', playlistUri) // Logger.info('Stream Playlist', playlistUri)
Logger.info('Test Stream Opened for audiobook', audiobook.title, 'with streamId', stream.id) // Logger.info('Test Stream Opened for audiobook', audiobook.title, 'with streamId', stream.id)
return playlistUri // return playlistUri
} }
streamUpdate(socket, { currentTime, streamId }) { streamUpdate(socket, { currentTime, streamId }) {
var client = socket.sheepClient var client = socket.sheepClient
if (!client || !client.stream) { if (!client || !client.stream) {
Logger.error('No stream for client', client.user.id) Logger.error('No stream for client', (client && client.user) ? client.user.id : 'No Client')
return return
} }
if (client.stream.id !== streamId) { if (client.stream.id !== streamId) {
@@ -134,11 +160,11 @@ class StreamManager {
Logger.error('No User for client', client) Logger.error('No User for client', client)
return return
} }
if (!client.user.updateAudiobookProgress) { if (!client.user.updateAudiobookProgressFromStream) {
Logger.error('Invalid User for client', client) Logger.error('Invalid User for client', client)
return return
} }
client.user.updateAudiobookProgress(client.stream) client.user.updateAudiobookProgressFromStream(client.stream)
this.db.updateEntity('user', client.user) this.db.updateEntity('user', client.user)
} }
} }
+53 -18
View File
@@ -1,6 +1,7 @@
var EventEmitter = require('events') const Path = require('path')
var Logger = require('./Logger') const EventEmitter = require('events')
var Watcher = require('watcher') const Watcher = require('watcher')
const Logger = require('./Logger')
class FolderWatcher extends EventEmitter { class FolderWatcher extends EventEmitter {
constructor(audiobookPath) { constructor(audiobookPath) {
@@ -8,6 +9,10 @@ class FolderWatcher extends EventEmitter {
this.AudiobookPath = audiobookPath this.AudiobookPath = audiobookPath
this.folderMap = {} this.folderMap = {}
this.watcher = null this.watcher = null
this.pendingFiles = []
this.pendingDelay = 4000
this.pendingTimeout = null
} }
initWatcher() { initWatcher() {
@@ -25,7 +30,8 @@ class FolderWatcher extends EventEmitter {
.on('add', (path) => { .on('add', (path) => {
this.onNewFile(path) this.onNewFile(path)
}).on('change', (path) => { }).on('change', (path) => {
this.onFileUpdated(path) // This is triggered from metadata changes, not what we want
// this.onFileUpdated(path)
}).on('unlink', path => { }).on('unlink', path => {
this.onFileRemoved(path) this.onFileRemoved(path)
}).on('rename', (path, pathNext) => { }).on('rename', (path, pathNext) => {
@@ -38,39 +44,68 @@ class FolderWatcher extends EventEmitter {
} catch (error) { } catch (error) {
Logger.error('Chokidar watcher failed', error) Logger.error('Chokidar watcher failed', error)
} }
} }
close() { close() {
return this.watcher.close() return this.watcher.close()
} }
onNewFile(path) { // After [pendingBatchDelay] seconds emit batch
async onNewFile(path) {
if (this.pendingFiles.includes(path)) return
Logger.debug('FolderWatcher: New File', path) Logger.debug('FolderWatcher: New File', path)
this.emit('file_added', {
path: path.replace(this.AudiobookPath, ''), var dir = Path.dirname(path)
fullPath: path if (dir === this.AudiobookPath) {
}) Logger.debug('New File added to root dir, ignoring it')
return
}
this.pendingFiles.push(path)
clearTimeout(this.pendingTimeout)
this.pendingTimeout = setTimeout(() => {
this.emit('files', this.pendingFiles.map(f => f))
this.pendingFiles = []
}, this.pendingDelay)
} }
onFileRemoved(path) { onFileRemoved(path) {
Logger.debug('[FolderWatcher] File Removed', path) Logger.debug('[FolderWatcher] File Removed', path)
this.emit('file_removed', {
path: path.replace(this.AudiobookPath, ''), var dir = Path.dirname(path)
fullPath: path if (dir === this.AudiobookPath) {
}) Logger.debug('New File added to root dir, ignoring it')
return
}
this.pendingFiles.push(path)
clearTimeout(this.pendingTimeout)
this.pendingTimeout = setTimeout(() => {
this.emit('files', this.pendingFiles.map(f => f))
this.pendingFiles = []
}, this.pendingDelay)
} }
onFileUpdated(path) { onFileUpdated(path) {
Logger.debug('[FolderWatcher] Updated File', path) Logger.debug('[FolderWatcher] Updated File', path)
this.emit('file_updated', {
path: path.replace(this.AudiobookPath, ''),
fullPath: path
})
} }
onRename(pathFrom, pathTo) { onRename(pathFrom, pathTo) {
Logger.debug(`[FolderWatcher] Rename ${pathFrom} => ${pathTo}`) Logger.debug(`[FolderWatcher] Rename ${pathFrom} => ${pathTo}`)
var dir = Path.dirname(pathTo)
if (dir === this.AudiobookPath) {
Logger.debug('New File added to root dir, ignoring it')
return
}
this.pendingFiles.push(pathTo)
clearTimeout(this.pendingTimeout)
this.pendingTimeout = setTimeout(() => {
this.emit('files', this.pendingFiles.map(f => f))
this.pendingFiles = []
}, this.pendingDelay)
} }
} }
module.exports = FolderWatcher module.exports = FolderWatcher
+13 -1
View File
@@ -20,6 +20,7 @@ class AudioFile {
this.timeBase = null this.timeBase = null
this.channels = null this.channels = null
this.channelLayout = null this.channelLayout = null
this.chapters = []
this.tagAlbum = null this.tagAlbum = null
this.tagArtist = null this.tagArtist = null
@@ -29,6 +30,7 @@ class AudioFile {
this.manuallyVerified = false this.manuallyVerified = false
this.invalid = false this.invalid = false
this.exclude = false
this.error = null this.error = null
if (data) { if (data) {
@@ -49,6 +51,7 @@ class AudioFile {
trackNumFromFilename: this.trackNumFromFilename, trackNumFromFilename: this.trackNumFromFilename,
manuallyVerified: !!this.manuallyVerified, manuallyVerified: !!this.manuallyVerified,
invalid: !!this.invalid, invalid: !!this.invalid,
exclude: !!this.exclude,
error: this.error || null, error: this.error || null,
format: this.format, format: this.format,
duration: this.duration, duration: this.duration,
@@ -58,6 +61,7 @@ class AudioFile {
timeBase: this.timeBase, timeBase: this.timeBase,
channels: this.channels, channels: this.channels,
channelLayout: this.channelLayout, channelLayout: this.channelLayout,
chapters: this.chapters,
tagAlbum: this.tagAlbum, tagAlbum: this.tagAlbum,
tagArtist: this.tagArtist, tagArtist: this.tagArtist,
tagGenre: this.tagGenre, tagGenre: this.tagGenre,
@@ -76,6 +80,7 @@ class AudioFile {
this.addedAt = data.addedAt this.addedAt = data.addedAt
this.manuallyVerified = !!data.manuallyVerified this.manuallyVerified = !!data.manuallyVerified
this.invalid = !!data.invalid this.invalid = !!data.invalid
this.exclude = !!data.exclude
this.error = data.error || null this.error = data.error || null
this.trackNumFromMeta = data.trackNumFromMeta || null this.trackNumFromMeta = data.trackNumFromMeta || null
@@ -90,6 +95,7 @@ class AudioFile {
this.timeBase = data.timeBase this.timeBase = data.timeBase
this.channels = data.channels this.channels = data.channels
this.channelLayout = data.channelLayout this.channelLayout = data.channelLayout
this.chapters = data.chapters
this.tagAlbum = data.tagAlbum this.tagAlbum = data.tagAlbum
this.tagArtist = data.tagArtist this.tagArtist = data.tagArtist
@@ -112,17 +118,19 @@ class AudioFile {
this.manuallyVerified = !!data.manuallyVerified this.manuallyVerified = !!data.manuallyVerified
this.invalid = !!data.invalid this.invalid = !!data.invalid
this.exclude = !!data.exclude
this.error = data.error || null this.error = data.error || null
this.format = data.format this.format = data.format
this.duration = data.duration this.duration = data.duration
this.size = data.size this.size = data.size
this.bitRate = data.bit_rate this.bitRate = data.bit_rate || null
this.language = data.language this.language = data.language
this.codec = data.codec this.codec = data.codec
this.timeBase = data.time_base this.timeBase = data.time_base
this.channels = data.channels this.channels = data.channels
this.channelLayout = data.channel_layout this.channelLayout = data.channel_layout
this.chapters = data.chapters || []
this.tagAlbum = data.file_tag_album || null this.tagAlbum = data.file_tag_album || null
this.tagArtist = data.file_tag_artist || null this.tagArtist = data.file_tag_artist || null
@@ -131,6 +139,10 @@ class AudioFile {
this.tagTrack = data.file_tag_track || null this.tagTrack = data.file_tag_track || null
} }
clone() {
return new AudioFile(this.toJSON())
}
syncFile(newFile) { syncFile(newFile) {
var hasUpdates = false var hasUpdates = false
var keysToSync = ['path', 'fullPath', 'ext', 'filename'] var keysToSync = ['path', 'fullPath', 'ext', 'filename']
+3 -3
View File
@@ -97,12 +97,12 @@ class AudioTrack {
this.format = probeData.format this.format = probeData.format
this.duration = probeData.duration this.duration = probeData.duration
this.size = probeData.size this.size = probeData.size
this.bitRate = probeData.bit_rate this.bitRate = probeData.bitRate
this.language = probeData.language this.language = probeData.language
this.codec = probeData.codec this.codec = probeData.codec
this.timeBase = probeData.time_base this.timeBase = probeData.timeBase
this.channels = probeData.channels this.channels = probeData.channels
this.channelLayout = probeData.channel_layout this.channelLayout = probeData.channelLayout
this.tagAlbum = probeData.file_tag_album || null this.tagAlbum = probeData.file_tag_album || null
this.tagArtist = probeData.file_tag_artist || null this.tagArtist = probeData.file_tag_artist || null
+159 -19
View File
@@ -26,6 +26,10 @@ class Audiobook {
this.tags = [] this.tags = []
this.book = null this.book = null
this.chapters = []
// Audiobook was scanned and not found
this.isMissing = false
if (audiobook) { if (audiobook) {
this.construct(audiobook) this.construct(audiobook)
@@ -51,20 +55,25 @@ class Audiobook {
if (audiobook.book) { if (audiobook.book) {
this.book = new Book(audiobook.book) this.book = new Book(audiobook.book)
} }
if (audiobook.chapters) {
this.chapters = audiobook.chapters.map(c => ({ ...c }))
}
this.isMissing = !!audiobook.isMissing
} }
get title() { get title() {
return this.book ? this.book.title : 'No Title' return this.book ? this.book.title : 'No Title'
} }
get cover() {
return this.book ? this.book.cover : ''
}
get author() { get author() {
return this.book ? this.book.author : 'Unknown' return this.book ? this.book.author : 'Unknown'
} }
get cover() {
return this.book ? this.book.cover : ''
}
get authorLF() { get authorLF() {
return this.book ? this.book.authorLF : null return this.book ? this.book.authorLF : null
} }
@@ -97,6 +106,14 @@ class Audiobook {
return (this.audioFiles || []).filter(af => af.invalid).map(af => ({ filename: af.filename, error: af.error || 'Unknown Error' })) 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() { bookToJSON() {
return this.book ? this.book.toJSON() : null return this.book ? this.book.toJSON() : null
} }
@@ -122,7 +139,9 @@ class Audiobook {
book: this.bookToJSON(), book: this.bookToJSON(),
tracks: this.tracksToJSON(), tracks: this.tracksToJSON(),
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()), audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()) otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
chapters: this.chapters || [],
isMissing: !!this.isMissing
} }
} }
@@ -141,7 +160,10 @@ class Audiobook {
hasBookMatch: !!this.book, hasBookMatch: !!this.book,
hasMissingParts: this.missingParts ? this.missingParts.length : 0, hasMissingParts: this.missingParts ? this.missingParts.length : 0,
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0, hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
numTracks: this.tracks.length numEbooks: this.ebooks.length,
numTracks: this.tracks.length,
chapters: this.chapters || [],
isMissing: !!this.isMissing
} }
} }
@@ -160,9 +182,12 @@ class Audiobook {
invalidParts: this.invalidParts, invalidParts: this.invalidParts,
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()), audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()), otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
ebooks: (this.ebooks || []).map(ebook => ebook.toJSON()),
tags: this.tags, tags: this.tags,
book: this.bookToJSON(), book: this.bookToJSON(),
tracks: this.tracksToJSON() tracks: this.tracksToJSON(),
chapters: this.chapters || [],
isMissing: !!this.isMissing
} }
} }
@@ -180,7 +205,8 @@ class Audiobook {
return false 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() { async checkUpdateInos() {
var hasUpdates = false var hasUpdates = false
if (!this.ino) { if (!this.ino) {
@@ -189,15 +215,55 @@ class Audiobook {
} }
for (let i = 0; i < this.audioFiles.length; i++) { for (let i = 0; i < this.audioFiles.length; i++) {
var af = this.audioFiles[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) { if (!af.ino || af.ino === this.ino) {
af.ino = await getIno(af.fullPath) af.ino = await getIno(af.fullPath)
if (!af.ino) { if (!af.ino) {
Logger.error('[Audiobook] checkUpdateInos: Failed to set ino for audio file', af.fullPath) Logger.error('[Audiobook] checkUpdateInos: Failed to set ino for audio file', af.fullPath)
} else { } else {
var track = this.tracks.find(t => comparePaths(t.path, af.path)) Logger.debug(`[Audiobook] Set INO For audio file ${af.path}`)
if (track) { if (at) at.ino = af.ino
track.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 hasUpdates = true
} }
@@ -273,19 +339,38 @@ class Audiobook {
return hasUpdates return hasUpdates
} }
updateAudioTracks(files) { // 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 var index = 1
this.audioFiles = files.map((file) => { this.audioFiles = orderedFileData.map((fileData) => {
file.manuallyVerified = true var audioFile = this.audioFiles.find(af => af.ino === fileData.ino)
file.invalid = false audioFile.manuallyVerified = true
file.error = null audioFile.invalid = false
file.index = index++ audioFile.error = null
return new AudioFile(file) if (fileData.exclude !== undefined) {
audioFile.exclude = !!fileData.exclude
}
if (audioFile.exclude) {
audioFile.index = -1
} else {
audioFile.index = index++
}
return audioFile
}) })
this.audioFiles.sort((a, b) => a.index - b.index)
this.tracks = [] this.tracks = []
this.missingParts = [] this.missingParts = []
this.audioFiles.forEach((file) => { this.audioFiles.forEach((file) => {
this.addTrack(file) if (!file.exclude) {
this.addTrack(file)
}
}) })
this.lastUpdate = Date.now() this.lastUpdate = Date.now()
} }
@@ -295,6 +380,11 @@ class Audiobook {
this.audioFiles = this.audioFiles.filter(f => f.ino !== audioFile.ino) 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() { checkUpdateMissingParts() {
var currMissingParts = (this.missingParts || []).join(',') || '' var currMissingParts = (this.missingParts || []).join(',') || ''
@@ -329,6 +419,7 @@ class Audiobook {
var newOtherFilePaths = newOtherFiles.map(f => f.path) var newOtherFilePaths = newOtherFiles.map(f => f.path)
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path)) this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
// TODO: Should use inode
newOtherFiles.forEach((file) => { newOtherFiles.forEach((file) => {
var existingOtherFile = this.otherFiles.find(f => f.path === file.path) var existingOtherFile = this.otherFiles.find(f => f.path === file.path)
if (!existingOtherFile) { if (!existingOtherFile) {
@@ -390,5 +481,54 @@ class Audiobook {
getAudioFileByIno(ino) { getAudioFileByIno(ino) {
return this.audioFiles.find(af => af.ino === ino) 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
if (includedAudioFiles[0].chapters) {
this.chapters = includedAudioFiles[0].chapters.map(c => ({ ...c }))
}
} else {
this.chapters = []
var currChapterId = 0
var currStartTime = 0
includedAudioFiles.forEach((file) => {
// If audio file has chapters use chapters
if (file.chapters && file.chapters.length) {
file.chapters.forEach((chapter) => {
var chapterDuration = chapter.end - chapter.start
if (chapterDuration > 0) {
var title = `Chapter ${currChapterId}`
if (chapter.title) {
title += ` (${chapter.title})`
}
this.chapters.push({
id: currChapterId++,
start: currStartTime,
end: currStartTime + chapterDuration,
title
})
currStartTime += chapterDuration
}
})
} else if (file.duration) {
// Otherwise just use track has chapter
this.chapters.push({
id: currChapterId++,
start: currStartTime,
end: currStartTime + file.duration,
title: file.filename ? Path.basename(file.filename, Path.extname(file.filename)) : `Chapter ${currChapterId}`
})
currStartTime += file.duration
}
})
}
}
} }
module.exports = Audiobook module.exports = Audiobook
+91
View File
@@ -0,0 +1,91 @@
class AudiobookProgress {
constructor(progress) {
this.audiobookId = null
this.totalDuration = null // seconds
this.progress = null // 0 to 1
this.currentTime = null // seconds
this.isRead = false
this.lastUpdate = null
this.startedAt = null
this.finishedAt = null
if (progress) {
this.construct(progress)
}
}
toJSON() {
return {
audiobookId: this.audiobookId,
totalDuration: this.totalDuration,
progress: this.progress,
currentTime: this.currentTime,
isRead: this.isRead,
lastUpdate: this.lastUpdate,
startedAt: this.startedAt,
finishedAt: this.finishedAt
}
}
construct(progress) {
this.audiobookId = progress.audiobookId
this.totalDuration = progress.totalDuration
this.progress = progress.progress
this.currentTime = progress.currentTime
this.isRead = !!progress.isRead
this.lastUpdate = progress.lastUpdate
this.startedAt = progress.startedAt
this.finishedAt = progress.finishedAt || null
}
updateFromStream(stream) {
this.audiobookId = stream.audiobookId
this.totalDuration = stream.totalDuration
this.progress = stream.clientProgress
this.currentTime = stream.clientCurrentTime
this.lastUpdate = Date.now()
if (!this.startedAt) {
this.startedAt = Date.now()
}
// If has < 10 seconds remaining mark as read
var timeRemaining = this.totalDuration - this.currentTime
if (timeRemaining < 10) {
if (!this.isRead) {
this.isRead = true
this.progress = 1
this.finishedAt = Date.now()
}
} else {
this.isRead = false
this.finishedAt = null
}
}
update(payload) {
var hasUpdates = false
for (const key in payload) {
if (payload[key] !== this[key]) {
if (key === 'isRead') {
if (!payload[key]) { // Updating to Not Read - Reset progress and current time
this.finishedAt = null
this.progress = 0
this.currentTime = 0
} else { // Updating to Read
if (!this.finishedAt) this.finishedAt = Date.now()
this.progress = 1
}
}
this[key] = payload[key]
hasUpdates = true
}
}
if (!this.startedAt) {
this.startedAt = Date.now()
}
return hasUpdates
}
}
module.exports = AudiobookProgress

Some files were not shown because too many files have changed in this diff Show More