Compare commits

...

109 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
Mark Cooper 197012e662 Version 1.0.0, updating readme 2021-09-04 14:35:25 -05:00
Mark Cooper e4dac5dd05 Adding download tab and download manager, ffmpeg in worker thread 2021-09-04 14:17:26 -05:00
Mark Cooper a86bda59f6 Fix server client.user undefined, update logo 2021-09-03 06:40:59 -05:00
Mark Cooper 5b1269cbe8 fix variable typo 2021-09-01 19:50:18 -05:00
Mark Cooper c81a0260e2 Emit stream_closed even if stream is not found 2021-09-01 19:39:38 -05:00
Mark Cooper 2b1e6b0c3b Update ver 2021-09-01 16:01:53 -05:00
Mark Cooper bff42962ba Logo update, fix book card shadow 2021-09-01 16:01:15 -05:00
Mark Cooper 73e2f184cf Remove test fonts 2021-09-01 13:50:37 -05:00
Mark Cooper 0c017c4227 Fix multi-select, add new book flag 2021-09-01 13:47:18 -05:00
Mark Cooper 234653b549 Add m4a filetype 2021-08-27 14:35:16 -05:00
Mark Cooper 23f343f1df Adding and deleting users 2021-08-27 07:01:47 -05:00
Mark Cooper 88c7c1632e Batch updating and deleting, multi-select 2021-08-26 18:32:05 -05:00
Mark Cooper 8c9fb0d45e Fix set card size index on mount 2021-08-26 10:43:46 -05:00
Mark Cooper e54535f465 Bookshelf cover size setting and widget 2021-08-26 09:47:51 -05:00
Mark Cooper 46d7c45ca5 Cover image aspect ratio solution 2021-08-26 08:04:52 -05:00
Mark Cooper 9c32e4cbda Fix update tracklist and invalid parts alert, update readme screenshots 2021-08-26 07:09:23 -05:00
Mark Cooper dc0f25aca3 Sync tracks always 2021-08-25 19:32:17 -05:00
Mark Cooper 93c78a672c Fix listener for audiobook updates in edit modal, add remove cover button 2021-08-25 19:15:00 -05:00
Mark Cooper 63cae5b0ed Adding inode to files and audiobooks to support renaming, setting up watcher and removing chokidar 2021-08-25 17:36:54 -05:00
Mark Cooper 81487d1dba Parse and update author name on each update 2021-08-25 06:38:32 -05:00
Mark Cooper 6ca7e9e6a6 Emit update when cover is updated 2021-08-24 20:32:13 -05:00
Mark Cooper e230cb47e8 Clean and parse author name from directory, sort by author last name, scan for covers 2021-08-24 20:24:40 -05:00
Mark Cooper 9300a0bfb6 Fix ab undefined 2021-08-24 08:04:32 -05:00
Mark Cooper 3d64115051 Fix incorrect audiobook file paths before a scan 2021-08-24 07:50:36 -05:00
Mark Cooper 2f215ed2e5 package lock sync 2021-08-24 07:36:20 -05:00
Mark Cooper bda0c0c804 Scanner update - remove and update audiobooks on scans 2021-08-24 07:15:56 -05:00
Mark Cooper bf38004b5e Fix dynamic route requests, add auth middleware 2021-08-23 19:37:40 -05:00
Mark Cooper f83c5dd440 Moving settings to be user specific, adding playbackRate setting, update playbackRate picker to go up to 3x 2021-08-23 18:31:04 -05:00
Mark Cooper 2548aba840 Update readme 2021-08-23 14:20:33 -05:00
Mark Cooper 5803559183 Increment beta verison 2021-08-23 14:14:19 -05:00
Mark Cooper 73a786879e Fix scan for audiobook directories in root dir 2021-08-23 14:08:54 -05:00
Mark Cooper f4cb5d101e Reset password and users table on settings page 2021-08-22 10:46:04 -05:00
Mark Cooper 9331b5870f remove comment 2021-08-22 09:26:03 -05:00
Mark Cooper dd213ddfd1 Series as a dropdown and filter, fix genre list in details modal 2021-08-22 08:52:37 -05:00
Mark Cooper 0990c61c93 Add global search, add reset all audiobooks 2021-08-21 16:23:35 -05:00
Mark Cooper 5b64453101 Removing release-it 2021-08-21 14:02:10 -05:00
Mark Cooper cbf2938c9c Release 0.9.61-beta.0 2021-08-21 13:28:32 -05:00
Mark Cooper 04643ad686 update release-update command 2021-08-21 13:27:46 -05:00
Mark Cooper 13cd5a4041 release-update command 2021-08-21 13:26:43 -05:00
135 changed files with 10547 additions and 3369 deletions
+4 -2
View File
@@ -8,5 +8,7 @@ npm-debug.log
/audiobooks2
/metadata
dev.js
/test/
/client/.nuxt/
test/
/client/.nuxt/
/client/dist/
/dist/
+4 -2
View File
@@ -5,5 +5,7 @@ node_modules/
/audiobooks/
/audiobooks2/
/metadata/
/test/
/client/.nuxt/
test/
/client/.nuxt/
/client/dist/
/dist/
-8
View File
@@ -1,8 +0,0 @@
{
"github": {
"release": true
},
"npm": {
"publish": false
}
}
+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"
+67 -1
View File
@@ -9,20 +9,38 @@
height: calc(100% - 64px - 165px);
max-height: calc(100% - 64px - 165px);
}
#bookshelf {
height: calc(100% - 40px);
}
/* width */
::-webkit-scrollbar {
width: 8px;
}
/* ::-webkit-scrollbar:horizontal { */
/* height: 16px; */
/* height: 24px;
} */
/* Track */
::-webkit-scrollbar-track {
background-color: rgba(0,0,0,0);
}
/* ::-webkit-scrollbar-track:horizontal { */
/* background: rgb(149, 119, 90); */
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
/* background: linear-gradient(180deg, rgb(117, 88, 60) 0%, rgb(65, 41, 17) 17%, rgb(71, 43, 15) 88%, rgb(3, 2, 1) 100%);
box-shadow: 2px 14px 8px #111111aa;
} */
/* Handle */
::-webkit-scrollbar-thumb {
background: #855620;
border-radius: 4px;
}
/* ::-webkit-scrollbar-thumb:horizontal { */
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
/* box-shadow: 2px 14px 8px #111111aa;
border-radius: 4px;
} */
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #704922;
@@ -61,4 +79,52 @@
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid white;
}
}
.triangle-right {
width: 0;
height: 0;
border-left: 8px solid transparent;
border-bottom: 8px solid transparent;
border-top: 8px solid rgb(34,127,35);
border-right: 8px solid rgb(34,127,35);
}
.icon-text {
font-size: 1.1rem;
}
#page-wrapper {
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
}
.box-shadow-md {
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: 0px -12px 8px #111111ee;
}
.box-shadow-xl {
box-shadow: 2px 14px 8px #111111aa;
}
.box-shadow-book {
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
}
.box-shadow-book3d {
box-shadow: 4px 1px 8px #11111166, 1px -4px 8px #11111166;
}
.box-shadow-side {
box-shadow: 5px 0px 5px #11111166;
}
+101 -18
View File
@@ -8,7 +8,13 @@
<p class="font-mono text-sm">{{ totalDurationPretty }}</p>
</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" />
</div>
<div class="flex my-2">
@@ -27,7 +33,7 @@
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="forward10">
<span class="material-icons text-3xl">forward_10</span>
</div>
<controls-playback-speed-control v-model="playbackRate" @change="updatePlaybackRate" />
<controls-playback-speed-control v-model="playbackRate" @change="playbackRateChanged" />
</template>
<template v-else>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
@@ -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 v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
</div>
<div ref="track" class="w-full h-2 relative overflow-hidden">
<template v-for="(tick, index) in chapterTicks">
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-50 h-1 pointer-events-none" />
</template>
</div>
<!-- Hover timestamp -->
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5">00:00</p>
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
</div>
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
<div class="absolute -bottom-1.5 left-0 right-0 w-full flex justify-center">
<div class="arrow-down" />
</div>
@@ -58,6 +71,8 @@
</div>
<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>
</template>
@@ -66,7 +81,11 @@ import Hls from 'hls.js'
export default {
props: {
loading: Boolean
loading: Boolean,
chapters: {
type: Array,
default: () => []
}
},
data() {
return {
@@ -84,18 +103,37 @@ export default {
audioEl: null,
totalDuration: 0,
seekedTime: 0,
seekLoading: false
seekLoading: false,
showChaptersModal: false,
currentTime: 0,
trackOffsetLeft: 16 // Track is 16px from edge
}
},
computed: {
token() {
return this.$store.getters.getToken
return this.$store.getters['user/getToken']
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
},
chapterTicks() {
return this.chapters.map((chap) => {
var perc = chap.start / this.totalDuration
return {
title: chap.title,
left: perc * this.trackWidth
}
})
},
currentChapter() {
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
}
},
methods: {
selectChapter(chapter) {
this.seek(chapter.start)
this.showChaptersModal = false
},
seek(time) {
if (this.loading) {
return
@@ -110,7 +148,7 @@ export default {
}
this.seekedTime = time
this.seekLoading = true
console.warn('SEEK TO', this.$secondsToTimestamp(time))
this.audioEl.currentTime = time
if (this.$refs.playedTrack) {
@@ -130,22 +168,51 @@ export default {
},
updatePlaybackRate(playbackRate) {
if (this.audioEl) {
console.log('UpdatePlaybackRate', playbackRate)
this.audioEl.playbackRate = playbackRate
try {
this.audioEl.playbackRate = playbackRate
this.audioEl.defaultPlaybackRate = playbackRate
} catch (error) {
console.error('Update playback rate failed', error)
}
} else {
console.error('No Audio El updatePlaybackRate')
}
},
playbackRateChanged(playbackRate) {
this.updatePlaybackRate(playbackRate)
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
console.error('Failed to update settings', err)
})
},
mousemoveTrack(e) {
var offsetX = e.offsetX
var time = (offsetX / this.trackWidth) * this.totalDuration
if (this.$refs.hoverTimestamp) {
var width = this.$refs.hoverTimestamp.clientWidth
this.$refs.hoverTimestamp.style.opacity = 1
this.$refs.hoverTimestamp.style.left = offsetX - width / 2 + 'px'
var posLeft = offsetX - width / 2
if (posLeft + width + this.trackOffsetLeft > window.innerWidth) {
posLeft = window.innerWidth - width - this.trackOffsetLeft
} else if (posLeft < -this.trackOffsetLeft) {
posLeft = -this.trackOffsetLeft
}
this.$refs.hoverTimestamp.style.left = posLeft + 'px'
}
if (this.$refs.hoverTimestampArrow) {
var width = this.$refs.hoverTimestampArrow.clientWidth
var posLeft = offsetX - width / 2
this.$refs.hoverTimestampArrow.style.opacity = 1
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
}
if (this.$refs.hoverTimestampText) {
this.$refs.hoverTimestampText.innerText = this.$secondsToTimestamp(time)
var hoverText = this.$secondsToTimestamp(time)
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
if (chapter && chapter.title) {
hoverText += ` - ${chapter.title}`
}
this.$refs.hoverTimestampText.innerText = hoverText
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 1
@@ -156,6 +223,9 @@ export default {
if (this.$refs.hoverTimestamp) {
this.$refs.hoverTimestamp.style.opacity = 0
}
if (this.$refs.hoverTimestampArrow) {
this.$refs.hoverTimestampArrow.style.opacity = 0
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 0
}
@@ -259,7 +329,6 @@ export default {
end: end + offset
})
}
return ranges
},
getLastBufferedTime() {
@@ -285,7 +354,6 @@ export default {
this.bufferTrackWidth = bufferlen
},
timeupdate() {
// console.log('Time update', this.audioEl.currentTime)
if (!this.$refs.playedTrack) {
console.error('Invalid no played track ref')
return
@@ -305,6 +373,8 @@ export default {
this.updateTimestamp()
this.currentTime = this.audioEl.currentTime
var perc = this.audioEl.currentTime / this.audioEl.duration
var ptWidth = Math.round(perc * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
@@ -351,11 +421,12 @@ export default {
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)
var audio = this.$refs.audio
audio.volume = this.volume
audio.playbackRate = this.playbackRate
audio.defaultPlaybackRate = this.playbackRate
this.hlsInstance.attachMedia(audio)
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
// console.log('[HLS] MEDIA ATTACHED')
@@ -375,10 +446,13 @@ export default {
}
})
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
console.warn('[HLS] Destroying HLS Instance')
console.log('[HLS] Destroying HLS Instance')
})
})
},
showChapters() {
this.showChaptersModal = true
},
play() {
if (!this.$refs.audio) {
console.error('No Audio ref')
@@ -399,7 +473,6 @@ export default {
this.staleHlsInstance = this.hlsInstance
this.staleHlsInstance.destroy()
this.hlsInstance = null
console.log('Terminated HLS Instance', this.staleHlsInstance)
}
},
async resetStream(startTime) {
@@ -410,17 +483,27 @@ export default {
this.set(this.url, startTime, true)
},
init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
this.audioEl = this.$refs.audio
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
console.error('Track not loaded', this.$refs)
}
},
settingsUpdated(settings) {
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
this.updatePlaybackRate(settings.playbackRate)
}
}
},
mounted() {
// this.$nextTick(this.init)
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
this.init()
},
beforeDestroy() {
this.$store.commit('user/removeSettingsListener', 'audioplayer')
}
}
</script>
+149 -40
View File
@@ -1,21 +1,56 @@
<template>
<div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-30">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-40">
<div class="flex h-full items-center">
<img v-if="!showBack" src="/LogoTransparent.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">
<span class="material-icons text-4xl text-white">arrow_back</span>
</a>
<h1 class="text-2xl font-book">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 />
<div class="flex-grow" />
<!-- <button class="px-4 py-2 bg-blue-500 rounded-xs" @click="scan">Scan</button> -->
<nuxt-link 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>
</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 v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
<ui-btn v-show="!isHome" small class="text-sm mx-2" @click="toggleSelectAll"
>{{ isAllSelected ? 'Select None' : 'Select All' }}<span class="pl-2">({{ audiobooksShowing.length }})</span></ui-btn
>
<div class="flex-grow" />
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
</ui-tooltip>
<template v-if="userCanUpdate">
<ui-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>
</div>
</div>
</div>
@@ -25,56 +60,131 @@
export default {
data() {
return {
menuItems: [
// {
// value: 'settings',
// text: 'Settings'
// },
{
value: 'logout',
text: 'Logout'
}
]
processingBatchDelete: false
}
},
computed: {
isHome() {
return this.$route.name === 'index'
},
showBack() {
return this.$route.name !== 'index'
return this.$route.name !== 'library-id' && !this.isHome
},
user() {
return this.$store.state.user
return this.$store.state.user.user
},
isRootUser() {
return this.$store.getters['user/getIsRoot']
},
username() {
return this.user ? this.user.username : 'err'
},
numAudiobooksSelected() {
return this.selectedAudiobooks.length
},
selectedAudiobooks() {
return this.$store.state.selectedAudiobooks
},
isAllSelected() {
return this.audiobooksShowing.length === this.selectedAudiobooks.length
},
userAudiobooks() {
return this.$store.state.user.user.audiobooks || {}
},
audiobooksShowing() {
// return this.$store.getters['audiobooks/getFiltered']()
return this.$store.getters['audiobooks/getEntitiesShowing']()
},
selectedSeries() {
return this.$store.state.audiobooks.selectedSeries
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
},
selectedIsRead() {
// Find an audiobook that is not read, if none then all audiobooks read
return !this.selectedAudiobooks.find((ab) => {
var userAb = this.userAudiobooks[ab]
return !userAb || !userAb.isRead
})
},
processingBatch() {
return this.$store.state.processingBatch
}
},
methods: {
back() {
if (this.$route.name === 'audiobook-id-edit') {
this.$router.push(`/audiobook/${this.$route.params.id}`)
async back() {
var popped = await this.$store.dispatch('popRoute')
var backTo = popped || '/'
this.$router.push(backTo)
},
cancelSelectionMode() {
if (this.processingBatchDelete) return
this.$store.commit('setSelectedAudiobooks', [])
},
toggleSelectAll() {
if (this.isAllSelected) {
this.cancelSelectionMode()
} else {
this.$router.push('/')
var audiobookIds = this.audiobooksShowing.map((a) => a.id)
this.$store.commit('setSelectedAudiobooks', audiobookIds)
}
},
scan() {
console.log('Call Start Init')
this.$root.socket.emit('scan')
},
logout() {
this.$axios.$post('/logout').catch((error) => {
console.error(error)
toggleBatchRead() {
this.$store.commit('setProcessingBatch', true)
var newIsRead = !this.selectedIsRead
var updateProgressPayloads = this.selectedAudiobooks.map((ab) => {
return {
audiobookId: ab,
isRead: newIsRead
}
})
if (localStorage.getItem('token')) {
localStorage.removeItem('token')
}
this.$router.push('/login')
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)
})
},
menuAction(action) {
if (action === 'logout') {
this.logout()
} else if (action === 'settings') {
// Show settings modal
batchDeleteClick() {
var audiobookText = this.numAudiobooksSelected > 1 ? `these ${this.numAudiobooksSelected} audiobooks` : 'this audiobook'
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the audiobooks from AudioBookshelf`
if (confirm(confirmMsg)) {
this.processingBatchDelete = true
this.$store.commit('setProcessingBatch', true)
this.$axios
.$post(`/api/audiobooks/delete`, {
audiobookIds: this.selectedAudiobooks
})
.then(() => {
this.$toast.success('Batch delete success!')
this.processingBatchDelete = false
this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedAudiobooks', [])
})
.catch((error) => {
this.$toast.error('Batch delete failed')
console.error('Failed to batch delete', error)
this.processingBatchDelete = false
this.$store.commit('setProcessingBatch', false)
})
}
},
batchEditClick() {
this.$router.push('/batch')
}
},
mounted() {}
@@ -83,7 +193,6 @@ export default {
<style>
#appbar {
/* box-shadow: 0px 8px 8px #111111aa; */
box-shadow: 0px 5px 5px #11111155;
}
</style>
+175 -50
View File
@@ -1,115 +1,240 @@
<template>
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-auto">
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4">Your Audiobookshelf is empty!</p>
<ui-btn color="success" @click="scan">Scan your Audiobooks</ui-btn>
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
<!-- Cover size widget -->
<div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-20">
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
<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 class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in groupedBooks">
<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">
<div :key="index" class="w-full bookshelfRow relative">
<div class="flex justify-center items-center">
<template v-for="audiobook in shelf">
<cards-book-card :ref="`audiobookCard-${audiobook.id}`" :key="audiobook.id" :user-progress="userAudiobooks[audiobook.id]" :audiobook="audiobook" />
<template v-for="entity in shelf">
<cards-group-card v-if="showGroups" :key="entity.id" :width="bookCoverWidth" :group="entity" @click="clickGroup" />
<!-- <cards-book-3d :key="entity.id" v-else :width="100" :src="$store.getters['audiobooks/getBookCoverSrc'](entity.book)" /> -->
<cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" />
</template>
</div>
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
</div>
</template>
<div v-show="!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>
</template>
<script>
export default {
props: {
page: String,
selectedSeries: String,
searchResults: {
type: Array,
default: () => []
},
searchQuery: String
},
data() {
return {
width: 0,
bookWidth: 176,
booksPerRow: 0,
groupedBooks: [],
currFilterOrderKey: null
shelves: [],
currFilterOrderKey: null,
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
selectedSizeIndex: 3,
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: {
keywordFilter() {
return this.$store.state.audiobooks.keywordFilter
},
userAudiobooks() {
return this.$store.state.user ? this.$store.state.user.audiobooks || {} : {}
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
},
audiobooks() {
return this.$store.state.audiobooks.audiobooks
},
filterOrderKey() {
return this.$store.getters['settings/getFilterOrderKey']
return this.$store.getters['user/getFilterOrderKey']
},
bookCoverWidth() {
return this.availableSizes[this.selectedSizeIndex]
},
sizeMultiplier() {
return this.bookCoverWidth / 120
},
paddingX() {
return 16 * this.sizeMultiplier
},
bookWidth() {
return this.bookCoverWidth + this.paddingX * 2
},
isSelectionMode() {
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: {
setGroupedBooks() {
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() {
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 })
},
setBookshelfEntities() {
this.wrapperClientWidth = this.$refs.wrapper.clientWidth
var width = Math.max(0, this.wrapperClientWidth - this.rowPaddingX * 2)
var booksPerRow = Math.floor(width / this.bookWidth)
var entities = this.entities
var groups = []
var currentRow = 0
var currentGroup = []
var audiobooksSorted = this.$store.getters['audiobooks/getFilteredAndSorted']()
this.currFilterOrderKey = this.filterOrderKey
for (let i = 0; i < audiobooksSorted.length; i++) {
var row = Math.floor(i / this.booksPerRow)
for (let i = 0; i < entities.length; i++) {
var row = Math.floor(i / booksPerRow)
if (row > currentRow) {
groups.push([...currentGroup])
currentRow = row
currentGroup = []
}
currentGroup.push(audiobooksSorted[i])
currentGroup.push(entities[i])
}
if (currentGroup.length) {
groups.push([...currentGroup])
}
this.groupedBooks = groups
this.shelves = groups
},
calculateBookshelf() {
this.width = this.$refs.wrapper.clientWidth
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]
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
var isLoading = await this.$store.dispatch('audiobooks/load')
if (!isLoading) {
this.setBookshelfEntities()
}
return null
},
init() {
this.calculateBookshelf()
},
resize() {
this.$nextTick(() => {
this.calculateBookshelf()
this.setGroupedBooks()
})
this.$nextTick(this.setBookshelfEntities)
},
audiobooksUpdated() {
console.log('[AudioBookshelf] Audiobooks Updated')
this.setGroupedBooks()
this.setBookshelfEntities()
},
settingsUpdated() {
// var newSortKey = `${this.orderBy}-${this.orderDesc}`
settingsUpdated(settings) {
if (this.currFilterOrderKey !== this.filterOrderKey) {
this.setGroupedBooks()
this.setBookshelfEntities()
}
if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) {
var index = this.availableSizes.indexOf(settings.bookshelfCoverSize)
if (index >= 0) {
this.selectedSizeIndex = index
this.resize()
}
}
},
scan() {
this.$root.socket.emit('scan')
}
},
updated() {
if (this.$refs.wrapper) {
if (this.wrapperClientWidth !== this.$refs.wrapper.clientWidth) {
this.$nextTick(this.setBookshelfEntities)
}
}
},
mounted() {
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
this.$store.commit('settings/addListener', { id: 'bookshelf', meth: this.settingsUpdated })
this.$store.dispatch('audiobooks/load')
this.init()
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() {
this.$store.commit('audiobooks/removeListener', 'bookshelf')
this.$store.commit('settings/removeListener', 'bookshelf')
window.removeEventListener('resize', this.resize)
this.$store.commit('audiobooks/removeListener', 'bookshelf')
this.$store.commit('user/removeSettingsListener', 'bookshelf')
}
}
</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>
+88 -12
View File
@@ -1,28 +1,96 @@
<template>
<div class="w-full h-10 relative">
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8">
<p class="font-book">{{ numShowing }} Audiobooks</p>
<div class="flex-grow" />
<controls-filter-select v-model="settings.filterBy" class="w-40 h-7.5" @change="updateFilter" />
<span class="px-4 text-sm">by</span>
<controls-order-select v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-40 h-7.5" @change="updateOrder" />
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-40 flex items-center px-8">
<template v-if="page !== 'search' && !isHome">
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
<div v-else class="flex items-center">
<div @click="seriesBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
<span class="material-icons text-3xl text-white">west</span>
</div>
<!-- <span class="material-icons text-2xl cursor-pointer" @click="seriesBackArrow">west</span> -->
<p class="pl-4 font-book text-lg">
{{ selectedSeries }} <span class="ml-3 font-mono text-lg bg-black bg-opacity-30 rounded-lg px-1 py-0.5">{{ numShowing }}</span>
</p>
</div>
<div class="flex-grow" />
<ui-text-input v-show="!selectedSeries" v-model="_keywordFilter" placeholder="Keyword Filter" :padding-y="1.5" class="text-xs w-40" />
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-48 h-7.5 ml-4" @change="updateFilter" />
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5 ml-4" @change="updateOrder" />
</template>
<template v-else-if="!isHome">
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
<span class="material-icons text-3xl text-white">west</span>
</div>
<!-- <p class="font-book pl-4">{{ numShowing }} showing</p> -->
<div class="flex-grow" />
<p>Search results for "{{ searchQuery }}"</p>
<div class="flex-grow" />
</template>
</div>
</div>
</template>
<script>
export default {
props: {
page: String,
isHome: Boolean,
selectedSeries: String,
searchResults: {
type: Array,
default: () => []
},
searchQuery: String
},
data() {
return {
settings: {}
settings: {},
hasInit: false
}
},
computed: {
showSortFilters() {
return this.page === ''
},
numShowing() {
return this.$store.getters['audiobooks/getFiltered']().length
if (this.page === '') {
return this.$store.getters['audiobooks/getFiltered']().length
} else if (this.page === 'search') {
return (this.searchResults || []).length
} else {
var groups = this.$store.getters['audiobooks/getSeriesGroups']()
if (this.selectedSeries) {
var group = groups.find((g) => g.name === this.selectedSeries)
if (group) return group.books.length
return 0
}
return groups.length
}
},
entityName() {
if (!this.page) return 'Audiobooks'
if (this.page === 'series') return 'Series'
if (this.page === 'collections') return 'Collections'
return ''
},
_keywordFilter: {
get() {
return this.$store.state.audiobooks.keywordFilter
},
set(val) {
this.$store.commit('audiobooks/setKeywordFilter', val)
}
}
},
methods: {
searchBackArrow() {
this.$router.replace('/library')
},
seriesBackArrow() {
this.$router.replace('/library/series')
this.$emit('update:selectedSeries', null)
},
updateOrder() {
this.saveSettings()
},
@@ -30,15 +98,23 @@ export default {
this.saveSettings()
},
saveSettings() {
// Send to server
this.$store.commit('settings/setSettings', this.settings)
this.$store.dispatch('user/updateUserSettings', this.settings)
},
init() {
this.settings = { ...this.$store.state.settings.settings }
this.settings = { ...this.$store.state.user.settings }
},
settingsUpdated(settings) {
for (const key in settings) {
this.settings[key] = settings[key]
}
}
},
mounted() {
this.init()
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated })
},
beforeDestroy() {
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
}
}
</script>
@@ -46,6 +122,6 @@ export default {
<style>
#toolbar {
box-shadow: 0px 8px 8px #111111aa;
box-shadow: 0px 8px 6px #111111aa;
}
</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>
+27 -13
View File
@@ -1,20 +1,20 @@
<template>
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-20 bg-primary p-4">
<div class="absolute -top-16 left-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">
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute -top-16 left-4 cursor-pointer">
<cards-book-cover :audiobook="streamAudiobook" :width="88" />
</div>
</nuxt-link>
<div class="flex items-center pl-24">
<div>
<h1>
{{ title }} <span v-if="stream" class="text-xs text-gray-400">({{ stream.id }})</span>
</h1>
<p class="text-gray-400 text-sm">by {{ author }}</p>
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer">
{{ title }} <span v-if="stream && $isDev" class="text-xs text-gray-400">({{ stream.id }})</span>
</nuxt-link>
<p class="text-gray-400 text-sm hover:underline cursor-pointer" @click="filterByAuthor">by {{ author }}</p>
</div>
<div class="flex-grow" />
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
</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>
</template>
@@ -22,6 +22,7 @@
export default {
data() {
return {
audioPlayerReady: false,
lastServerUpdateSentSeconds: 0,
stream: null
}
@@ -32,7 +33,7 @@ export default {
return 'Logo.png'
},
user() {
return this.$store.state.user
return this.$store.state.user.user
},
isLoading() {
if (!this.streamAudiobook) return false
@@ -48,6 +49,9 @@ export default {
book() {
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
},
chapters() {
return this.streamAudiobook ? this.streamAudiobook.chapters || [] : []
},
title() {
return this.book.title || 'No Title'
},
@@ -62,7 +66,17 @@ export default {
}
},
methods: {
filterByAuthor() {
if (this.$route.name !== 'index') {
this.$router.push('/library')
}
var settingsUpdate = {
filterBy: `authors.${this.$encode(this.author)}`
}
this.$store.dispatch('user/updateUserSettings', settingsUpdate)
},
audioPlayerMounted() {
this.audioPlayerReady = true
if (this.stream) {
console.log('[STREAM-CONTAINER] audioPlayerMounted w/ Stream', this.stream)
this.openStream()
@@ -92,7 +106,7 @@ export default {
streamProgress(data) {
if (!data.numSegments) return
var chunks = data.chunks
console.log(`[STREAM-CONTAINER] Stream Progress ${data.percent}`)
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
} else {
@@ -102,14 +116,14 @@ export default {
streamOpen(stream) {
this.stream = stream
if (this.$refs.audioPlayer) {
console.log('[STREAM-CONTAINER] streamOpen', stream)
console.log('[StreamContainer] streamOpen', stream)
this.openStream()
} else {
} else if (this.audioPlayerReady) {
console.error('No Audio Ref')
}
},
streamClosed(streamId) {
if (this.stream && this.stream.id === streamId) {
if (this.stream && (this.stream.id === streamId || streamId === 'n/a')) {
this.terminateStream()
this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id)
this.stream = null
@@ -0,0 +1,46 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<cards-book-cover :audiobook="audiobook" :width="40" />
<div class="flex-grow px-2 searchCardContent h-full">
<p class="truncate text-sm">{{ title }}</p>
<p class="text-xs text-gray-200 truncate">by {{ author }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
title() {
return this.book ? this.book.title : 'No Title'
},
author() {
return this.book ? this.book.author : 'Unknown'
}
},
methods: {},
mounted() {}
}
</script>
<style>
.searchCardContent {
width: calc(100% - 80px);
height: calc(40px * 1.5);
display: flex;
flex-direction: column;
justify-content: center;
}
</style>
+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>
+134 -32
View File
@@ -1,29 +1,53 @@
<template>
<nuxt-link :to="`/audiobook/${audiobookId}`" :style="{ height: height + 32 + 'px', width: width + 32 + 'px' }" class="cursor-pointer p-4">
<div class="rounded-sm h-full overflow-hidden relative bookCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<div class="w-full relative" :style="{ height: width * 1.6 + 'px' }">
<cards-book-cover :audiobook="audiobook" />
<div class="relative">
<!-- New Book Flag -->
<div v-show="isNew" class="absolute top-4 left-0 w-4 h-10 pr-2 bg-darkgreen box-shadow-xl z-20">
<div class="absolute top-0 left-0 w-full h-full transform -rotate-90 flex items-center justify-center">
<p class="text-center text-sm">New</p>
</div>
<div class="absolute -bottom-4 left-0 triangle-right" />
</div>
<div v-show="isHovering" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
<div class="h-full flex items-center justify-center">
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
<span class="material-icons text-5xl">play_circle_filled</span>
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @click.stop>
<nuxt-link :to="isSelectionMode ? '' : `/audiobook/${audiobookId}`" class="cursor-pointer">
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }" @click="clickCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" />
<div v-show="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black rounded" :class="overlayWrapperClasslist">
<div v-show="!isSelectionMode && !isMissing" class="h-full flex items-center justify-center">
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
</div>
</div>
<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>
</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">
<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 class="absolute top-1.5 right-1.5 cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" @click.stop.prevent="editClick">
<span class="material-icons" style="font-size: 16px">edit</span>
</div>
</div>
<div class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div>
</div>
<ui-tooltip v-if="showError" :text="errorText" class="absolute top-4 left-0">
<div class="h-6 w-10 bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
<span class="material-icons text-sm text-red-100 pr-1">priority_high</span>
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
</div>
<!-- <div v-if="true && hasEbook" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">EBook</p>
</div> -->
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0">
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
</div>
</ui-tooltip>
</div>
</ui-tooltip>
</nuxt-link>
</div>
</nuxt-link>
</div>
</template>
<script>
@@ -36,7 +60,12 @@ export default {
userProgress: {
type: Object,
default: () => null
}
},
width: {
type: Number,
default: 120
},
showVolumeNumber: Boolean
},
data() {
return {
@@ -44,29 +73,82 @@ export default {
}
},
computed: {
isNew() {
return this.tags.includes('New')
},
tags() {
return this.audiobook.tags || []
},
audiobookId() {
return this.audiobook.id
},
hasEbook() {
return this.audiobook.numEbooks
},
isSelectionMode() {
// return this.$store.getters['getNumAudiobooksSelected']
return !!this.selectedAudiobooks.length
},
selectedAudiobooks() {
return this.$store.state.selectedAudiobooks
},
selected() {
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
},
processingBatch() {
return this.$store.state.processingBatch
},
book() {
return this.audiobook.book || {}
},
width() {
return 120
},
height() {
return this.width * 1.6
},
sizeMultiplier() {
return this.width / 120
},
paddingX() {
return 16 * this.sizeMultiplier
},
title() {
return this.book.title
},
playIconFontSize() {
return Math.max(2, 3 * this.sizeMultiplier)
},
author() {
return this.book.author
},
authorFL() {
return this.book.authorFL || this.author
},
authorLF() {
return this.book.authorLF || this.author
},
authorFormat() {
if (!this.orderBy || !this.orderBy.startsWith('book.author')) return null
return this.orderBy === 'book.authorLF' ? this.authorLF : this.authorFL
},
volumeNumber() {
return this.book.volumeNumber || null
},
orderBy() {
return this.$store.getters['user/getUserSetting']('orderBy')
},
filterBy() {
return this.$store.getters['user/getUserSetting']('filterBy')
},
userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0
},
userIsRead() {
return this.userProgress ? !!this.userProgress.isRead : false
},
showError() {
return this.hasMissingParts || this.hasInvalidParts
return this.hasMissingParts || this.hasInvalidParts || this.isMissing
},
isMissing() {
return this.audiobook.isMissing
},
hasMissingParts() {
return this.audiobook.hasMissingParts
@@ -75,6 +157,7 @@ export default {
return this.audiobook.hasInvalidParts
},
errorText() {
if (this.isMissing) return 'Audiobook directory is missing!'
var txt = ''
if (this.hasMissingParts) {
txt = `${this.hasMissingParts} missing parts.`
@@ -84,9 +167,28 @@ export default {
txt += `${this.hasInvalidParts} invalid parts.`
}
return txt || 'Unknown Error'
},
overlayWrapperClasslist() {
var classes = []
if (this.isSelectionMode) classes.push('bg-opacity-60')
else classes.push('bg-opacity-40')
if (this.selected) {
classes.push('border-2 border-yellow-400')
}
return classes
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
}
},
methods: {
selectBtnClick() {
if (this.processingBatch) return
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
},
clickError(e) {
e.stopPropagation()
this.$router.push(`/audiobook/${this.audiobookId}`)
@@ -97,14 +199,14 @@ export default {
},
editClick() {
this.$store.commit('showEditModal', this.audiobook)
},
clickCard(e) {
if (this.isSelectionMode) {
e.stopPropagation()
e.preventDefault()
this.selectBtnClick()
}
}
},
mounted() {}
}
}
</script>
<style>
.bookCard {
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
}
</style>
+55 -4
View File
@@ -1,13 +1,19 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
<img ref="cover" :src="cover" @error="imageError" class="w-full h-full object-cover" />
<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="fullCoverUrl" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
</div>
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img src="/LogoTransparent.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
</div>
</div>
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div>
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
@@ -26,6 +32,7 @@ export default {
type: Object,
default: () => {}
},
authorOverride: String,
width: {
type: Number,
default: 120
@@ -33,13 +40,22 @@ export default {
},
data() {
return {
imageFailed: false
imageFailed: false,
showCoverBg: false
}
},
watch: {
cover() {
this.imageFailed = false
}
},
computed: {
book() {
return this.audiobook.book || {}
},
bookLastUpdate() {
return this.book.lastUpdate || Date.now()
},
title() {
return this.book.title || 'No Title'
},
@@ -50,6 +66,7 @@ export default {
return this.title
},
author() {
if (this.authorOverride) return this.authorOverride
return this.book.author || 'Unknown'
},
authorCleaned() {
@@ -58,8 +75,14 @@ export default {
}
return this.author
},
placeholderUrl() {
return '/book_placeholder.jpg'
},
fullCoverUrl() {
return this.$store.getters['audiobooks/getBookCoverSrc'](this.book, this.placeholderUrl)
},
cover() {
return this.book.cover || '/book_placeholder.jpg'
return this.book.cover || this.placeholderUrl
},
hasCover() {
return !!this.book.cover
@@ -78,9 +101,37 @@ export default {
},
authorBottom() {
return 0.75 * this.sizeMultiplier
},
userToken() {
return this.$store.getters['user/getToken']
}
},
methods: {
setCoverBg() {
if (this.$refs.coverBg) {
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
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)'
}
},
hideCoverBg() {},
imageLoaded() {
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
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
+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>
+43 -15
View File
@@ -1,14 +1,17 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-300 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</span>
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<span class="material-icons" style="font-size: 1.1rem">close</span>
</div>
</button>
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
@@ -39,9 +42,9 @@
</div>
</li>
<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">
<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>
</li>
</template>
@@ -73,6 +76,21 @@ export default {
text: 'Tag',
value: 'tags',
sublist: true
},
{
text: 'Series',
value: 'series',
sublist: true
},
{
text: 'Authors',
value: 'authors',
sublist: true
},
{
text: 'Progress',
value: 'progress',
sublist: true
}
]
}
@@ -101,31 +119,41 @@ export default {
if (!this.selected) return ''
var parts = this.selected.split('.')
if (parts.length > 1) {
return this.snakeToNormal(parts[1])
return this.$decode(parts[1])
}
var _sel = this.items.find((i) => i.value === this.selected)
if (!_sel) return ''
return _sel.text
},
genres() {
return this.$store.state.audiobooks.genres
return this.$store.getters['audiobooks/getGenresUsed']
},
tags() {
return this.$store.state.audiobooks.tags
},
series() {
return this.$store.state.audiobooks.series
},
authors() {
return this.$store.getters['audiobooks/getUniqueAuthors']
},
progress() {
return ['Read', 'Unread', 'In Progress']
},
sublistItems() {
return this[this.sublist] || []
return (this[this.sublist] || []).map((item) => {
return {
text: item,
value: this.$encode(item)
}
})
}
},
methods: {
snakeToNormal(kebab) {
if (!kebab) {
return 'err'
}
return String(kebab)
.split('_')
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
.join(' ')
clearSelected() {
this.selected = 'all'
this.showMenu = false
this.$nextTick(() => this.$emit('change', 'all'))
},
clickOutside() {
if (!this.selectedItemSublist) this.sublist = null
+131
View File
@@ -0,0 +1,131 @@
<template>
<div class="w-64 ml-8 relative">
<form @submit.prevent="submitSearch">
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
</form>
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
</div>
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-10 -mt-px w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li v-if="isTyping" class="py-2 px-2">
<p>Typing...</p>
</li>
<li v-else-if="isFetching" class="py-2 px-2">
<p>Fetching...</p>
</li>
<li v-else-if="!items.length" class="py-2 px-2">
<p>No Results</p>
</li>
<template v-else>
<template v-for="item in items">
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickedOption(item)">
<template v-if="item.type === 'audiobook'">
<cards-audiobook-search-card :audiobook="item.data" />
</template>
</li>
</template>
</template>
</ul>
</div>
</div>
</template>
<script>
export default {
data() {
return {
showMenu: false,
isFocused: false,
focusTimeout: null,
isTyping: false,
isFetching: false,
search: null,
items: [],
searchTimeout: null,
lastSearch: null
}
},
computed: {
audiobooks() {
return this.$store.state.audiobooks.audiobooks
}
},
methods: {
submitSearch() {
if (!this.search) return
this.$router.push(`/library/search?query=${this.search}`)
this.search = null
this.items = []
this.showMenu = false
this.$nextTick(() => {
if (this.$refs.input) {
this.$refs.input.blur()
}
})
},
focussed() {
this.isFocused = true
this.showMenu = true
},
blurred() {
this.isFocused = false
clearTimeout(this.focusTimeout)
this.focusTimeout = setTimeout(() => {
this.showMenu = false
}, 200)
},
async runSearch(value) {
this.lastSearch = value
if (!this.lastSearch) {
return
}
this.isFetching = true
var results = await this.$axios.$get(`/api/audiobooks?q=${value}`).catch((error) => {
console.error('Search error', error)
return []
})
this.isFetching = false
if (!this.showMenu) {
return
}
this.items = results.map((res) => {
return {
id: res.id,
data: res,
type: 'audiobook'
}
})
},
inputUpdate(val) {
clearTimeout(this.searchTimeout)
if (!val) {
this.lastSearch = ''
this.isTyping = false
return
}
this.isTyping = true
this.searchTimeout = setTimeout(() => {
this.isTyping = false
this.runSearch(val)
}, 1000)
},
clickedOption(option) {
if (option.type === 'audiobook') {
this.$router.push(`/audiobook/${option.data.id}`)
}
},
clickClear() {
if (this.search) {
this.search = null
this.items = []
this.showMenu = false
}
}
},
mounted() {}
}
</script>
+14 -10
View File
@@ -1,22 +1,17 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-300 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span>
<!-- <span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</span> -->
</button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
</div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
@@ -42,13 +37,21 @@ export default {
value: 'book.title'
},
{
text: 'Author',
value: 'book.author'
text: 'Author (First Last)',
value: 'book.authorFL'
},
{
text: 'Author (Last, First)',
value: 'book.authorLF'
},
{
text: 'Added At',
value: 'addedAt'
},
{
text: 'Volume #',
value: 'book.volumeNumber'
},
{
text: 'Duration',
value: 'duration'
@@ -78,7 +81,8 @@ export default {
}
},
selectedText() {
var _sel = this.items.find((i) => i.value === this.selected)
var _selected = this.selected === 'book.author' ? 'book.authorFL' : this.selected
var _sel = this.items.find((i) => i.value === _selected)
if (!_sel) return ''
return _sel.text
}
@@ -8,12 +8,24 @@
<div class="arrow-down" />
</div>
<div class="w-full h-full no-scroll flex">
<template v-for="(rate, index) in rates">
<div :key="rate" class="flex items-center justify-center border-black-300 w-11 hover:bg-black hover:bg-opacity-10 cursor-pointer" :class="index < rates.length - 1 ? 'border-r' : ''" style="min-width: 44px; max-width: 44px" @click="set(rate)">
<p class="text-xs text-center font-mono">{{ rate.toFixed(1) }}<span class="text-sm"></span></p>
<div class="w-full h-full no-scroll flex px-7 relative overflow-hidden">
<div class="absolute left-0 top-0 h-full w-7 border-r border-black-300 bg-black-300 rounded-l-lg flex items-center justify-center cursor-pointer" :class="rateIndex === 0 ? 'bg-black-400 text-gray-400' : 'hover:bg-black-200'" @mousedown.prevent @mouseup.prevent @click="leftArrowClick">
<span class="material-icons" style="font-size: 1.2rem">chevron_left</span>
</div>
<div class="overflow-hidden relative" style="width: 220px">
<div class="flex items-center h-full absolute top-0 left-0 transition-transform duration-100" :style="{ transform: `translateX(${xPos}px)` }">
<template v-for="rate in rates">
<div :key="rate" class="h-full border-black-300 w-11 cursor-pointer border-r" :class="value === rate ? 'bg-black-100' : 'hover:bg-black hover:bg-opacity-10'" style="min-width: 44px; max-width: 44px" @click="set(rate)">
<div class="w-full h-full flex justify-center items-center">
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm"></span></p>
</div>
</div>
</template>
</div>
</template>
</div>
<div class="absolute top-0 right-0 h-full w-7 bg-black-300 rounded-r-lg flex items-center justify-center cursor-pointer" :class="rateIndex === rates.length - numVisible ? 'bg-black-400 text-gray-400' : 'hover:bg-black-200'" @mousedown.prevent @mouseup.prevent @click="rightArrowClick">
<span class="material-icons" style="font-size: 1.2rem">chevron_right</span>
</div>
</div>
</div>
</div>
@@ -29,7 +41,9 @@ export default {
},
data() {
return {
showMenu: false
showMenu: false,
rateIndex: 1,
numVisible: 5
}
},
computed: {
@@ -42,7 +56,10 @@ export default {
}
},
rates() {
return [0.5, 0.8, 1.0, 1.3, 1.5, 2.0]
return [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
},
xPos() {
return -1 * this.rateIndex * 44
}
},
methods: {
@@ -55,6 +72,12 @@ export default {
this.playbackRate = newPlaybackRate
if (hasChanged) this.$emit('change', newPlaybackRate)
this.showMenu = false
},
leftArrowClick() {
this.rateIndex = Math.max(0, this.rateIndex - 1)
},
rightArrowClick() {
this.rateIndex = Math.min(this.rates.length - this.numVisible, this.rateIndex + 1)
}
},
mounted() {}
+223
View File
@@ -0,0 +1,223 @@
<template>
<modals-modal v-model="show" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<form @submit.prevent="submitForm">
<div class="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="flex py-2 -mx-2">
<div class="w-1/2 px-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 class="flex py-2">
<div class="px-2">
<ui-input-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :editable="false" :items="accountTypes" @input="userTypeUpdated" />
</div>
<div class="flex-grow" />
<div v-show="!isEditingRoot" class="flex items-center pt-4 px-2">
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p>
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
</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-grow" />
<ui-btn color="success" type="submit">Submit</ui-btn>
</div>
</div>
</div>
</form>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
account: {
type: Object,
default: () => null
}
},
data() {
return {
processing: false,
newUser: {},
isNew: true,
accountTypes: ['guest', 'user', 'admin']
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
this.init()
}
}
}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
title() {
return this.isNew ? 'Add New Account' : `Update Account: ${(this.account || {}).username}`
},
isEditingRoot() {
return this.account && this.account.type === 'root'
}
},
methods: {
submitForm() {
if (!this.newUser.username) {
this.$toast.error('Enter a username')
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) {
this.$toast.error('Must have a password, only root user can have an empty password')
return
}
var account = { ...this.newUser }
this.processing = true
this.$axios
.$post('/api/user', account)
.then((data) => {
this.processing = false
if (data.error) {
this.$toast.error(`Failed to create account: ${data.error}`)
} else {
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() {
this.newUser.isActive = !this.newUser.isActive
},
userTypeUpdated(type) {
this.newUser.permissions = {
download: type !== 'guest',
update: type === 'admin',
delete: type === 'admin',
upload: type === 'admin'
}
},
init() {
this.isNew = !this.account
if (this.account) {
this.newUser = {
username: this.account.username,
password: this.account.password,
type: this.account.type,
isActive: this.account.isActive,
permissions: { ...this.account.permissions }
}
} else {
this.newUser = {
username: null,
password: null,
type: 'user',
isActive: true,
permissions: {
download: true,
update: false,
delete: false,
upload: false
}
}
}
}
},
mounted() {}
}
</script>
@@ -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>
+89 -17
View File
@@ -1,17 +1,16 @@
<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>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="absolute -top-10 left-0 w-full flex">
<div 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 === 'details' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('details')">Details</div>
<div 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 === 'cover' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('cover')">Cover</div>
<div 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 === 'match' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('match')">Match</div>
<div class="w-28 rounded-t-lg flex items-center justify-center cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'tracks' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('tracks')">Tracks</div>
<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>
</template>
</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>
<component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" />
</keep-alive>
@@ -23,20 +22,63 @@
export default {
data() {
return {
selectedTab: 'details',
processing: false,
audiobook: null
audiobook: null,
fetchOnShow: false,
tabs: [
{
id: 'details',
title: 'Details',
component: 'modals-edit-tabs-details'
},
{
id: 'cover',
title: 'Cover',
component: 'modals-edit-tabs-cover'
},
{
id: 'match',
title: 'Match',
component: 'modals-edit-tabs-match'
},
{
id: 'tracks',
title: 'Tracks',
component: 'modals-edit-tabs-tracks'
},
{
id: 'chapters',
title: 'Chapters',
component: 'modals-edit-tabs-chapters'
},
{
id: 'download',
title: 'Download',
component: 'modals-edit-tabs-download'
}
]
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) return
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.fetchOnShow) this.fetchFull()
return
}
this.fetchOnShow = false
this.audiobook = null
this.init()
} else {
this.$store.commit('audiobooks/removeListener', 'edit-modal')
}
}
}
@@ -50,12 +92,39 @@ export default {
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() {
if (this.selectedTab === 'details') return 'modals-edit-tabs-details'
else if (this.selectedTab === 'cover') return 'modals-edit-tabs-cover'
else if (this.selectedTab === 'match') return 'modals-edit-tabs-match'
else if (this.selectedTab === 'tracks') return 'modals-edit-tabs-tracks'
return ''
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
return _tab ? _tab.component : ''
},
isMissing() {
return this.selectedAudiobook.isMissing
},
selectedAudiobook() {
return this.$store.state.selectedAudiobook || {}
@@ -75,7 +144,10 @@ export default {
this.selectedTab = tab
},
audiobookUpdated() {
this.fetchFull()
if (!this.show) this.fetchOnShow = true
else {
this.fetchFull()
}
},
init() {
this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedAudiobookId })
+6 -2
View File
@@ -1,12 +1,12 @@
<template>
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-50 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-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>
</div>
<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 />
<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 />
@@ -31,6 +31,10 @@ export default {
height: {
type: [String, Number],
default: 'unset'
},
contentMarginTop: {
type: Number,
default: 50
}
},
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>
+124 -11
View File
@@ -1,14 +1,44 @@
<template>
<div class="w-full h-full">
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
<div class="flex">
<cards-book-cover :audiobook="audiobook" />
<div class="relative">
<cards-book-cover :audiobook="audiobook" />
<!-- book cover overlay -->
<div v-if="book.cover" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
<span class="material-icons">delete</span>
</div>
</div>
</div>
<div class="flex-grow pl-6 pr-2">
<form @submit.prevent="submitForm">
<div class="flex items-center">
<div class="flex items-center">
<div v-if="userCanUpload" class="w-40 pr-2" style="min-width: 160px">
<ui-file-input ref="fileInput" @change="fileUploadSelected" />
</div>
<form @submit.prevent="submitForm" class="flex flex-grow">
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-3 w-24">Update</ui-btn>
</form>
</div>
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
<div class="flex items-center justify-center py-2">
<p>{{ localCovers.length }} local image(s)</p>
<div class="flex-grow" />
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? 'Hide' : 'Show' }}</ui-btn>
</div>
</form>
<div v-if="showLocalCovers" class="flex items-center justify-center">
<template v-for="cover in localCovers">
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover.localPath)">
<div class="h-24 bg-primary" style="width: 60px">
<img :src="cover.localPath" class="h-full w-full object-contain" />
</div>
</div>
</template>
</div>
</div>
<form @submit.prevent="submitSearchForm">
<div class="flex items-center justify-start -mx-1 py-2 mt-2">
@@ -23,20 +53,37 @@
</div>
</div>
</form>
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-72 overflow-y-scroll mt-2 max-w-full">
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-60 overflow-y-scroll mt-2 max-w-full">
<p v-if="!coversFound.length">No Covers Found</p>
<template v-for="cover in coversFound">
<div :key="cover" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
<img :src="cover" class="h-24 object-cover" style="width: 60px" />
<div class="h-24 bg-primary" style="width: 60px">
<img :src="cover" class="h-full w-full object-contain" />
</div>
<!-- <img :src="cover" class="h-24 object-cover" style="width: 60px" /> -->
</div>
</template>
</div>
</div>
</div>
<div v-if="previewUpload" class="absolute top-0 left-0 w-full h-full z-10 bg-bg p-8">
<p class="text-lg">Preview Cover</p>
<span class="absolute top-4 right-4 material-icons text-2xl cursor-pointer" @click="resetCoverPreview">close</span>
<div class="flex justify-center py-4">
<cards-preview-cover :src="previewUpload" :width="240" />
</div>
<div class="absolute bottom-0 right-0 flex py-4 px-5">
<ui-btn :disabled="processingUpload" class="mx-2" @click="resetCoverPreview">Clear</ui-btn>
<ui-btn :loading="processingUpload" color="success" @click="submitCoverUpload">Upload</ui-btn>
</div>
</div>
</div>
</template>
<script>
import Path from 'path'
export default {
props: {
processing: Boolean,
@@ -47,18 +94,24 @@ export default {
},
data() {
return {
processingUpload: false,
searchTitle: null,
searchAuthor: null,
imageUrl: null,
coversFound: [],
hasSearched: false
hasSearched: false,
showLocalCovers: false,
previewUpload: null,
selectedFile: null
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
if (newVal) {
this.init()
}
}
}
},
@@ -73,10 +126,59 @@ export default {
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
otherFiles() {
return this.audiobook ? this.audiobook.otherFiles || [] : []
},
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
},
localCovers() {
return this.otherFiles
.filter((f) => f.filetype === 'image')
.map((file) => {
var _file = { ...file }
_file.localPath = Path.join('local', _file.path)
return _file
})
}
},
methods: {
submitCoverUpload() {
this.processingUpload = true
var form = new FormData()
form.set('cover', this.selectedFile)
this.$axios
.$post(`/api/audiobook/${this.audiobook.id}/cover`, form)
.then((data) => {
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success('Cover Uploaded')
this.resetCoverPreview()
}
this.processingUpload = false
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Oops, something went wrong...')
this.processingUpload = false
})
},
resetCoverPreview() {
if (this.$refs.fileInput) {
this.$refs.fileInput.reset()
}
this.previewUpload = null
this.selectedFile = null
},
fileUploadSelected(file) {
this.previewUpload = URL.createObjectURL(file)
this.selectedFile = file
},
init() {
this.showLocalCovers = false
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.author)) {
this.coversFound = []
this.hasSearched = false
@@ -85,10 +187,22 @@ export default {
this.searchTitle = this.book.title || ''
this.searchAuthor = this.book.author || ''
},
removeCover() {
if (!this.book.cover) {
this.imageUrl = ''
return
}
this.updateCover('')
},
submitForm() {
this.updateCover(this.imageUrl)
},
async updateCover(cover) {
if (cover === this.book.cover) {
console.warn('Cover has not changed..', cover)
return
}
this.isProcessing = true
const updatePayload = {
book: {
@@ -101,13 +215,12 @@ export default {
})
this.isProcessing = false
if (updatedAudiobook) {
console.log('Update Successful', updatedAudiobook)
this.$toast.success('Update Successful')
this.$emit('close')
}
},
getSearchQuery() {
var searchQuery = `provider=best&title=${this.searchTitle}`
var searchQuery = `provider=openlibrary&title=${this.searchTitle}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
return searchQuery
},
+117 -44
View File
@@ -1,36 +1,63 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-1">
<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">
<div class="w-full flex items-center">
<p>
Your progress: <span class="font-mono text-lg">{{ (userProgress * 100).toFixed(0) }}%</span>
</p>
<div class="flex-grow" />
<ui-btn v-if="!resettingProgress" small :padding-x="2" class="-mr-3" @click="resetProgress">Reset</ui-btn>
</div>
</div>
<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 class="w-full h-full relative">
<form class="w-full h-full" @submit.prevent="submitForm">
<div ref="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
<!-- <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">
<div class="w-full flex items-center">
<p>
Your progress: <span class="font-mono text-lg">{{ (userProgress * 100).toFixed(0) }}%</span>
</p>
<div class="flex-grow" />
<ui-btn v-if="!resettingProgress" small :padding-x="2" class="-mr-3" @click="resetProgress">Reset</ui-btn>
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
</div> -->
<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>
<ui-text-input-with-label v-model="details.series" label="Series" class="mt-2" />
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
<ui-multi-select v-model="details.genres" label="Genre" :items="genres" class="mt-2" @addOption="addGenre" />
<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 class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
<div class="flex px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
<div class="flex-grow" />
<ui-btn type="submit">Submit</ui-btn>
</div>
</div>
</form>
</div>
@@ -49,14 +76,18 @@ export default {
return {
details: {
title: null,
subtitle: null,
description: null,
author: null,
narrarator: null,
series: null,
volumeNumber: null,
publishYear: null,
genres: []
},
newTags: [],
resettingProgress: false,
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']
isScrollable: false
}
},
watch: {
@@ -82,44 +113,52 @@ export default {
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
userAudiobook() {
return this.$store.getters['getUserAudiobook'](this.audiobookId)
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userProgress() {
return this.userAudiobook ? this.userAudiobook.progress : 0
genres() {
return this.$store.state.audiobooks.genres
},
tags() {
return this.$store.state.audiobooks.tags
},
series() {
return this.$store.state.audiobooks.series
}
},
methods: {
addGenre(genre) {
this.genres.push({
text: genre,
value: genre
})
},
async submitForm() {
console.log('Submit form', this.details)
if (this.isProcessing) {
return
}
this.isProcessing = true
const updatePayload = {
book: this.details
book: this.details,
tags: this.newTags
}
var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
this.isProcessing = false
if (updatedAudiobook) {
console.log('Update Successful', updatedAudiobook)
this.$toast.success('Update Successful')
this.$emit('close')
}
},
init() {
this.details.title = this.book.title
this.details.subtitle = this.book.subtitle
this.details.description = this.book.description
this.details.author = this.book.author
this.details.narrarator = this.book.narrarator
this.details.genres = this.book.genres || []
this.details.series = this.book.series
this.details.volumeNumber = this.book.volumeNumber
this.details.publishYear = this.book.publishYear
this.newTags = this.audiobook.tags || []
},
resetProgress() {
if (confirm(`Are you sure you want to reset your progress?`)) {
@@ -138,7 +177,7 @@ export default {
}
},
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.$axios
.$delete(`/api/audiobook/${this.audiobookId}`)
@@ -153,7 +192,41 @@ export default {
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>
@@ -0,0 +1,211 @@
<template>
<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="flex items-center">
<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>
<p v-if="singleDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
<p v-if="singleDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="singleDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
<!-- <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-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 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 class="w-80 border border-black-400 bg-bg rounded-xl h-20">
<div class="w-full h-full flex items-center justify-center">
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
<p class="w-24 font-mono pl-8 text-right">
{{ downloadAmount }}
</p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
tempDisable: false,
isDownloading: false,
downloadPercent: '0',
downloadAmount: '0 KB'
}
},
watch: {
singleDownloadStatus(newVal) {
if (newVal) {
this.tempDisable = false
}
}
},
computed: {
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
downloads() {
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
},
singleAudioDownload() {
return this.downloads.find((d) => d.type === 'singleAudio')
},
singleDownloadStatus() {
return this.singleAudioDownload ? this.singleAudioDownload.status : false
},
zipDownload() {
return this.downloads.find((d) => d.type === 'zip')
},
zipDownloadStatus() {
return this.zipDownload ? this.zipDownload.status : false
},
isSingleTrack() {
if (!this.audiobook.tracks) return false
return this.audiobook.tracks.length === 1
},
singleTrackPath() {
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: {
startZipDownload() {
// console.log('Download request received', this.audiobook)
this.tempDisable = true
setTimeout(() => {
this.tempDisable = false
}, 1000)
var downloadPayload = {
audiobookId: this.audiobook.id,
type: 'zip'
}
this.$root.socket.emit('download', downloadPayload)
},
startSingleAudioDownload() {
// 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 filename = download.filename
this.isDownloading = true
var request = new XMLHttpRequest()
request.responseType = 'blob'
request.open('get', downloadUrl, true)
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
request.send()
request.onreadystatechange = () => {
if (request.readyState === 4) {
this.isDownloading = false
}
if (request.readyState == 4 && request.status == 200) {
const url = window.URL.createObjectURL(request.response)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
setTimeout(() => {
if (anchor) anchor.remove()
}, 1000)
}
}
request.onerror = (err) => {
console.error('Download error', err)
this.isDownloading = false
}
request.onprogress = (e) => {
const percent_complete = Math.floor((e.loaded / e.total) * 100)
this.downloadAmount = this.$bytesPretty(e.loaded)
this.downloadPercent = percent_complete
// const duration = (new Date().getTime() - startTime) / 1000
// const bps = e.loaded / duration
// const kbps = Math.floor(bps / 1024)
// const time = (e.total - e.loaded) / bps
// const seconds = Math.floor(time % 60)
// const minutes = Math.floor(time / 60)
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
}
}
},
mounted() {}
}
</script>
+14 -6
View File
@@ -1,5 +1,5 @@
<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">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-72 px-1">
@@ -15,7 +15,7 @@
<div v-show="processing" class="flex h-full items-center justify-center">
<p>Loading...</p>
</div>
<div v-show="!processing && !searchResults.length" class="flex h-full items-center justify-center">
<div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
<p>No Results</p>
</div>
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper">
@@ -37,11 +37,13 @@ export default {
},
data() {
return {
audiobookId: null,
searchTitle: null,
searchAuthor: null,
lastSearch: null,
provider: 'best',
searchResults: []
searchResults: [],
hasSearched: false
}
},
watch: {
@@ -64,7 +66,7 @@ export default {
},
methods: {
getSearchQuery() {
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
return searchQuery
},
@@ -90,15 +92,22 @@ export default {
})
this.searchResults = results
this.isProcessing = false
this.hasSearched = true
},
init() {
if (this.audiobook.id !== this.audiobookId) {
this.searchResults = []
this.hasSearched = false
this.audiobookId = this.audiobook.id
}
if (!this.audiobook.book || !this.audiobook.book.title) {
this.searchTitle = null
this.searchAuthor = null
return
}
this.searchTitle = this.audiobook.book.title
this.searchAuthor = this.audiobook.book.author || ''
this.runSearch()
},
async selectMatch(match) {
this.isProcessing = true
@@ -120,7 +129,6 @@ export default {
})
this.isProcessing = false
if (updatedAudiobook) {
console.log('Update Successful', updatedAudiobook)
this.$toast.success('Update Successful')
this.$emit('close')
}
+20 -4
View File
@@ -1,7 +1,7 @@
<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">
<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>
</nuxt-link>
</div>
@@ -11,6 +11,7 @@
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
<th v-if="showDownload" class="text-center">Download</th>
</tr>
<template v-for="track in tracks">
<tr :key="track.index">
@@ -26,6 +27,9 @@
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</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>
</template>
</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: {
init() {
this.audioFiles = this.audiobook.audioFiles
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>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ files.length }}</span>
<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>
</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' : ''">
@@ -56,7 +56,11 @@ export default {
showTracks: false
}
},
computed: {},
computed: {
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
}
},
methods: {
clickBar() {
this.showTracks = !this.showTracks
+13 -2
View File
@@ -4,7 +4,7 @@
<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>
<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>
</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' : ''">
@@ -19,6 +19,7 @@
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
<th v-if="userCanDownload" class="text-center">Download</th>
</tr>
<template v-for="track in tracks">
<tr :key="track.index">
@@ -34,6 +35,9 @@
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</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>
</template>
</table>
@@ -56,7 +60,14 @@ export default {
showTracks: false
}
},
computed: {},
computed: {
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
}
},
methods: {
clickBar() {
this.showTracks = !this.showTracks
+26 -4
View File
@@ -1,12 +1,28 @@
<template>
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :type="type" :class="classList" @click="click">
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :class="classList">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> -->
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
</nuxt-link>
<button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @click="click">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> -->
<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>
</button>
</template>
<script>
export default {
props: {
to: String,
color: {
type: String,
default: 'primary'
@@ -16,7 +32,9 @@ export default {
default: ''
},
paddingX: Number,
small: Boolean
small: Boolean,
loading: Boolean,
disabled: Boolean
},
data() {
return {}
@@ -24,6 +42,7 @@ export default {
computed: {
classList() {
var list = []
if (this.loading) list.push('text-opacity-0')
list.push('text-white')
list.push(`bg-${this.color}`)
if (this.small) {
@@ -50,7 +69,7 @@ export default {
</script>
<style>
button.btn::before {
.btn::before {
content: '';
position: absolute;
border-radius: 6px;
@@ -61,7 +80,10 @@ button.btn::before {
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
button.btn:hover::before {
.btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style>
+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>
+127
View File
@@ -0,0 +1,127 @@
<template>
<div class="w-full" :class="disabled ? 'cursor-not-allowed' : ''">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<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" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
</div>
</form>
<ul ref="menu" v-show="isFocused && items.length && (itemsToShow.length || currentSearch)" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in itemsToShow">
<li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item }}</span>
</div>
<span v-if="input === 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>
</li>
</template>
<li v-if="!itemsToShow.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
<div class="flex items-center justify-center">
<span class="font-normal">No items</span>
</div>
</li>
</ul>
</div>
</div>
</template>
<script>
export default {
props: {
value: [String, Number],
disabled: Boolean,
label: String,
items: {
type: Array,
default: () => []
},
editable: {
type: Boolean,
default: true
}
},
data() {
return {
isFocused: false,
currentSearch: null,
typingTimeout: null,
textInput: null
}
},
watch: {
value: {
immediate: true,
handler(newVal) {
this.textInput = newVal
}
}
},
computed: {
input: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
itemsToShow() {
if (!this.currentSearch || !this.textInput || this.textInput === this.input) {
return this.items
}
return this.items.filter((i) => {
var iValue = String(i).toLowerCase()
return iValue.includes(this.currentSearch.toLowerCase())
})
}
},
methods: {
keydownInput() {
clearTimeout(this.typingTimeout)
this.typingTimeout = setTimeout(() => {
this.currentSearch = this.textInput
}, 100)
},
inputFocus() {
this.isFocused = true
},
inputBlur() {
setTimeout(() => {
if (document.activeElement === this.$refs.input) {
return
}
this.isFocused = false
if (this.input !== this.textInput) {
var val = this.textInput ? this.textInput.trim() : null
this.input = val
if (val && !this.items.includes(val)) {
this.$emit('newItem', val)
}
}
}, 50)
},
submitForm() {
var val = this.textInput ? this.textInput.trim() : null
this.input = val
if (val && !this.items.includes(val)) {
this.$emit('newItem', val)
}
this.currentSearch = null
},
clickedOption(e, item) {
this.textInput = null
this.currentSearch = 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()
}
},
mounted() {}
}
</script>
+2 -2
View File
@@ -1,13 +1,13 @@
<template>
<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="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 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>
</template>
+8 -1
View File
@@ -12,7 +12,14 @@
<transition name="menu">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3">
<template v-for="item in items">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<nuxt-link :key="item.value" v-if="item.to" :to="item.to">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
</div>
</li>
</nuxt-link>
<li v-else :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
</div>
+26 -37
View File
@@ -4,7 +4,12 @@
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-text" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center">{{ snakeToNormal(item) }}</div>
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
</div>
{{ item }}
</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" />
</div>
</form>
@@ -13,7 +18,7 @@
<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>
<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>
<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>
@@ -47,7 +52,6 @@ export default {
return {
textInput: null,
currentSearch: null,
isTyping: false,
typingTimeout: null,
isFocused: false,
menu: null
@@ -71,38 +75,14 @@ export default {
}
return this.items.filter((i) => {
var normie = this.snakeToNormal(i)
var iValue = String(normie).toLowerCase()
var iValue = String(i).toLowerCase()
return iValue.includes(this.currentSearch.toLowerCase())
})
}
},
methods: {
snakeToNormal(kebab) {
if (!kebab) {
return 'err'
}
return String(kebab)
.split('_')
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
.join(' ')
},
normalToSnake(normie) {
return normie
.trim()
.split(' ')
.map((t) => t.toLowerCase())
.join('_')
},
setMatchingItems() {
if (!this.textInput) {
return
}
this.currentSearch = this.textInput
},
keydownInput() {
clearTimeout(this.typingTimeout)
this.isTyping = true
this.typingTimeout = setTimeout(() => {
this.currentSearch = this.textInput
}, 100)
@@ -156,8 +136,10 @@ export default {
if (this.$refs.input) this.$refs.input.blur()
},
clickedOption(e, itemValue) {
e.stopPropagation()
e.preventDefault()
if (e) {
e.stopPropagation()
e.preventDefault()
}
if (this.$refs.input) this.$refs.input.focus()
var newSelected = null
@@ -169,6 +151,9 @@ export default {
this.textInput = null
this.currentSearch = null
this.$emit('input', newSelected)
this.$nextTick(() => {
this.recalcMenuPos()
})
},
clickWrapper() {
if (this.showMenu) {
@@ -176,10 +161,15 @@ export default {
}
this.focus()
},
removeItem(item) {
var remaining = this.selected.filter((i) => i !== item)
this.$emit('input', remaining)
this.$nextTick(() => {
this.recalcMenuPos()
})
},
insertNewItem(item) {
var kebabItem = this.normalToSnake(item)
this.selected.push(kebabItem)
this.$emit('addOption', kebabItem)
this.selected.push(item)
this.$emit('input', this.selected)
this.textInput = null
this.currentSearch = null
@@ -190,13 +180,12 @@ export default {
submitForm() {
if (!this.textInput) return
var cleaned = this.textInput.toLowerCase().trim()
var cleanedKebab = this.normalToSnake(cleaned)
var cleaned = this.textInput.trim()
var matchesItem = this.items.find((i) => {
return i === cleaned || cleanedKebab === i
return i === cleaned
})
if (matchesItem) {
this.clickedOption(matchesItem.value)
this.clickedOption(null, matchesItem)
} else {
this.insertNewItem(this.textInput)
}
+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>
+28 -3
View File
@@ -1,5 +1,5 @@
<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'" @change="change" />
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none border border-gray-600" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
</template>
<script>
@@ -12,8 +12,15 @@ export default {
type: String,
default: 'text'
},
transparent: Boolean,
disabled: Boolean
disabled: Boolean,
paddingY: {
type: Number,
default: 2
},
paddingX: {
type: Number,
default: 3
}
},
data() {
return {}
@@ -26,11 +33,29 @@ export default {
set(val) {
this.$emit('input', val)
}
},
classList() {
var _list = []
_list.push(`px-${this.paddingX}`)
_list.push(`py-${this.paddingY}`)
return _list.join(' ')
}
},
methods: {
focused() {
this.$emit('focus')
},
blurred() {
this.$emit('blur')
},
change(e) {
this.$emit('change', e.target.value)
},
keyup(e) {
this.$emit('keyup', e)
},
blur() {
if (this.$refs.input) this.$refs.input.blur()
}
},
mounted() {}
+4 -1
View File
@@ -1,6 +1,8 @@
<template>
<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" />
</div>
</template>
@@ -10,6 +12,7 @@ export default {
props: {
value: [String, Number],
label: String,
note: String,
type: {
type: String,
default: 'text'
+48
View File
@@ -0,0 +1,48 @@
<template>
<div>
<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 shadow transform transition-transform duration-100" :class="switchClassName"></span>
</div>
</div>
</template>
<script>
export default {
props: {
value: Boolean,
onColor: {
type: String,
default: 'success'
},
offColor: {
type: String,
default: 'primary'
},
disabled: Boolean
},
computed: {
toggleValue: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
className() {
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: {
clickToggle() {
if (this.disabled) return
this.toggleValue = !this.toggleValue
}
}
}
</script>
+66 -10
View File
@@ -1,5 +1,5 @@
<template>
<div ref="box" class="tooltip-box" @mouseover="mouseover" @mouseleave="mouseleave">
<div ref="box" @mouseover="mouseover" @mouseleave="mouseleave">
<slot />
</div>
</template>
@@ -10,7 +10,12 @@ export default {
text: {
type: String,
required: true
}
},
direction: {
type: String,
default: 'right'
},
disabled: Boolean
},
data() {
return {
@@ -18,21 +23,72 @@ export default {
isShowing: false
}
},
watch: {
text() {
this.updateText()
},
disabled(newVal) {
if (newVal && this.isShowing) {
this.hideTooltip()
}
}
},
methods: {
updateText() {
if (this.tooltip) {
this.tooltip.innerHTML = this.text
this.setTooltipPosition(this.tooltip)
}
},
getTextWidth() {
var styles = {
'font-size': '0.75rem'
}
var size = this.$calculateTextSize(this.text, styles)
console.log('Text Size', size.width, size.height)
return size.width
},
createTooltip() {
var boxChow = this.$refs.box.getBoundingClientRect()
var top = boxChow.top
var left = boxChow.left + boxChow.width + 4
if (!this.$refs.box) return
var tooltip = document.createElement('div')
tooltip.className = 'absolute px-2 bg-black bg-opacity-60 py-1 text-white pointer-events-none text-xs'
tooltip.style.top = top + 'px'
tooltip.style.left = left + 'px'
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.innerText = this.text
tooltip.style.backgroundColor = 'rgba(0,0,0,0.85)'
tooltip.innerHTML = this.text
this.setTooltipPosition(tooltip)
this.tooltip = tooltip
},
setTooltipPosition(tooltip) {
var boxChow = this.$refs.box.getBoundingClientRect()
var shouldMount = !tooltip.isConnected
// Calculate size of tooltip
if (shouldMount) document.body.appendChild(tooltip)
var { width, height } = tooltip.getBoundingClientRect()
if (shouldMount) tooltip.remove()
var top = 0
var left = 0
if (this.direction === 'right') {
top = boxChow.top - height / 2 + boxChow.height / 2
left = boxChow.left + boxChow.width + 4
} else if (this.direction === 'bottom') {
top = boxChow.top + boxChow.height + 4
left = boxChow.left - 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
}
tooltip.style.top = top + 'px'
tooltip.style.left = left + 'px'
},
showTooltip() {
if (this.disabled) return
if (!this.tooltip) {
this.createTooltip()
}
+32 -5
View File
@@ -1,25 +1,47 @@
<template>
<div v-show="isScanning" class="fixed bottom-0 left-0 right-0 mx-auto z-20 max-w-lg">
<div v-show="isScanning" class="fixed bottom-4 left-0 right-0 mx-auto z-20 max-w-lg">
<div class="w-full my-1 rounded-lg drop-shadow-lg px-4 py-2 flex items-center justify-center text-center transition-all border border-white border-opacity-40 shadow-md bg-warning">
<p class="text-lg font-sans" v-html="text" />
</div>
<div v-show="!hasCanceled" class="absolute right-0 top-3 bottom-0 px-2">
<ui-btn color="red-600" small :padding-x="1" @click="cancelScan">Cancel</ui-btn>
</div>
</div>
</template>
<script>
export default {
data() {
return {}
return {
hasCanceled: false
}
},
watch: {
isScanning(newVal) {
if (newVal) {
this.hasCanceled = false
}
}
},
computed: {
text() {
return `Scanning... <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>`
var scanText = this.isScanningFiles ? 'Scanning...' : 'Scanning Covers...'
return `${scanText} <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>`
},
isScanning() {
return this.isScanningFiles || this.isScanningCovers
},
isScanningFiles() {
return this.$store.state.isScanning
},
isScanningCovers() {
return this.$store.state.isScanningCovers
},
scanProgressKey() {
return this.isScanningFiles ? 'scanProgress' : 'coverScanProgress'
},
scanProgress() {
return this.$store.state.scanProgress
return this.$store.state[this.scanProgressKey]
},
scanPercent() {
return this.scanProgress ? this.scanProgress.progress + '%' : '0%'
@@ -31,7 +53,12 @@ export default {
return this.scanProgress ? this.scanProgress.total : 0
}
},
methods: {},
methods: {
cancelScan() {
this.hasCanceled = true
this.$root.socket.emit('cancel_scan')
}
},
mounted() {}
}
</script>
+155 -24
View File
@@ -1,7 +1,9 @@
<template>
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
<app-appbar />
<Nuxt />
<app-stream-container ref="streamContainer" />
<modals-edit-modal />
<widgets-scan-alert />
@@ -10,6 +12,7 @@
<script>
export default {
middleware: 'authenticated',
data() {
return {
socket: null
@@ -20,17 +23,23 @@ export default {
if (this.$store.state.showEditModal) {
this.$store.commit('setShowEditModal', false)
}
if (this.$store.state.selectedAudiobooks) {
this.$store.commit('setSelectedAudiobooks', [])
}
if (this.$store.state.audiobooks.keywordFilter) {
this.$store.commit('audiobooks/setKeywordFilter', '')
}
}
},
computed: {
user() {
return this.$store.state.user
return this.$store.state.user.user
}
},
methods: {
connect() {
console.log('[SOCKET] Connected')
var token = this.$store.getters.getToken
var token = this.$store.getters['user/getToken']
this.socket.emit('auth', token)
},
connectError() {},
@@ -49,7 +58,11 @@ export default {
}
}
if (payload.user) {
this.$store.commit('setUser', payload.user)
this.$store.commit('user/setUser', payload.user)
this.$store.commit('user/setSettings', payload.user.settings)
}
if (payload.serverSettings) {
this.$store.commit('setServerSettings', payload.serverSettings)
}
},
streamOpen(stream) {
@@ -76,26 +89,107 @@ export default {
audiobookRemoved(audiobook) {
if (this.$route.name.startsWith('audiobook')) {
if (this.$route.params.id === audiobook.id) {
this.$router.replace('/')
this.$router.replace('/library')
}
}
this.$store.commit('audiobooks/remove', audiobook)
},
scanComplete() {
this.$store.commit('setIsScanning', false)
this.$toast.success('Scan Finished')
scanComplete({ scanType, results }) {
if (scanType === 'covers') {
this.$store.commit('setIsScanningCovers', false)
if (results) {
this.$toast.success(`Scan Finished\nUpdated ${results.found} covers`)
}
} else {
this.$store.commit('setIsScanning', false)
if (results) {
var scanResultMsgs = []
if (results.added) scanResultMsgs.push(`${results.added} added`)
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
if (results.missing) scanResultMsgs.push(`${results.missing} missing`)
if (!scanResultMsgs.length) this.$toast.success('Scan Finished\nEverything was up to date')
else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n'))
}
}
},
scanStart() {
this.$store.commit('setIsScanning', true)
scanStart(scanType) {
if (scanType === 'covers') {
this.$store.commit('setIsScanningCovers', true)
} else {
this.$store.commit('setIsScanning', true)
}
},
scanProgress(progress) {
this.$store.commit('setScanProgress', progress)
scanProgress({ scanType, progress }) {
if (scanType === 'covers') {
this.$store.commit('setCoverScanProgress', progress)
} else {
this.$store.commit('setScanProgress', progress)
}
},
userUpdated(user) {
if (this.$store.state.user.id === user.id) {
this.$store.commit('setUser', user)
if (this.$store.state.user.user.id === user.id) {
this.$store.commit('user/setUser', user)
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) {
download.status = this.$constants.DownloadStatus.PENDING
download.toastId = this.$toast(`Preparing download "${download.filename}"`, { timeout: false, draggable: false, closeOnClick: false, onClick: () => this.downloadToastClick(download) })
this.$store.commit('downloads/addUpdateDownload', download)
},
downloadReady(download) {
download.status = this.$constants.DownloadStatus.READY
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
if (existingDownload && existingDownload.toastId !== undefined) {
download.toastId = existingDownload.toastId
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" is ready!`, options: { timeout: 5000, type: 'success', onClick: () => this.downloadToastClick(download) } }, true)
} else {
this.$toast.success(`Download "${download.filename}" is ready!`)
}
this.$store.commit('downloads/addUpdateDownload', download)
},
downloadFailed(download) {
download.status = this.$constants.DownloadStatus.FAILED
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
var failedMsg = download.isTimedOut ? 'timed out' : 'failed'
if (existingDownload && existingDownload.toastId !== undefined) {
download.toastId = existingDownload.toastId
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" ${failedMsg}`, options: { timeout: 5000, type: 'error', 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)
},
downloadKilled(download) {
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
if (existingDownload && existingDownload.toastId !== undefined) {
download.toastId = existingDownload.toastId
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" was terminated`, options: { timeout: 5000, type: 'error', 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)
},
downloadExpired(download) {
download.status = this.$constants.DownloadStatus.EXPIRED
this.$store.commit('downloads/addUpdateDownload', download)
},
initializeSocket() {
this.socket = this.$nuxtSocket({
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
@@ -136,21 +230,58 @@ export default {
this.socket.on('scan_start', this.scanStart)
this.socket.on('scan_complete', this.scanComplete)
this.socket.on('scan_progress', this.scanProgress)
// Download Listeners
this.socket.on('download_started', this.downloadStarted)
this.socket.on('download_ready', this.downloadReady)
this.socket.on('download_failed', this.downloadFailed)
this.socket.on('download_killed', this.downloadKilled)
this.socket.on('download_expired', this.downloadExpired)
},
checkVersion() {
this.$axios.$get('http://github.com/advplyr/audiobookshelf/raw/master/package.json').then((data) => {
console.log('GOT DATA', data)
})
}
},
beforeMount() {
if (!this.$store.state.user) {
this.$router.replace(`/login?redirect=${this.$route.path}`)
showUpdateToast(versionData) {
var ignoreVersion = localStorage.getItem('ignoreVersion')
var latestVersion = versionData.latestVersion
if (!ignoreVersion || ignoreVersion !== latestVersion) {
this.$toast.info(`Update is available!\nCheck release notes for v${versionData.latestVersion}`, {
position: 'top-center',
toastClassName: 'cursor-pointer',
bodyClassName: 'custom-class-1',
timeout: 20000,
closeOnClick: false,
draggable: false,
hideProgressBar: false,
onClick: () => {
window.open(versionData.githubTagUrl, '_blank')
},
onClose: () => {
localStorage.setItem('ignoreVersion', versionData.latestVersion)
}
})
} else {
console.warn(`Update is available but user chose to dismiss it! v${versionData.latestVersion}`)
}
}
},
mounted() {
this.initializeSocket()
this.checkVersion()
this.$store
.dispatch('checkForUpdate')
.then((res) => {
if (res && res.hasUpdate) this.showUpdateToast(res)
})
.catch((err) => console.error(err))
if (this.$route.query.error) {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
}
}
</script>
</script>
<style>
.Vue-Toastification__toast-body.custom-class-1 {
font-size: 14px;
}
</style>
+7
View File
@@ -0,0 +1,7 @@
export default function ({ store, redirect, route, app }) {
// If the user is not authenticated
if (!store.state.user.user) {
if (route.name === 'batch' || route.name === 'index') return redirect('/login')
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
css: [
'@/assets/app.css'
@@ -47,6 +51,7 @@ module.exports = {
// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
plugins: [
'@/plugins/constants.js',
'@/plugins/init.client.js',
'@/plugins/axios.js',
'@/plugins/toast.js'
@@ -70,7 +75,8 @@ module.exports = {
proxy: {
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/', pathRewrite: { '^/local/': '' } }
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/', pathRewrite: { '^/local/': '' } },
'/metadata/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }
},
io: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "1.0.0",
"version": "1.2.4",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "0.9.6-beta",
"version": "1.2.7",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {
@@ -9,7 +9,7 @@
"start": "nuxt start",
"generate": "nuxt generate"
},
"author": "",
"author": "advplyr",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
+108
View File
@@ -0,0 +1,108 @@
<template>
<div class="w-full h-full p-8">
<div class="w-full max-w-xl mx-auto">
<h1 class="text-2xl">Account</h1>
<div class="my-4">
<div class="flex -mx-2">
<div class="w-2/3 px-2">
<ui-text-input-with-label disabled :value="username" label="Username" />
</div>
<div class="w-1/3 px-2">
<ui-text-input-with-label disabled :value="usertype" label="Account Type" />
</div>
</div>
<div class="w-full h-px bg-primary my-4" />
<p class="mb-4 text-lg">Change Password</p>
<form @submit.prevent="submitChangePassword">
<ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" label="Password" class="my-2" />
<ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" label="New Password" class="my-2" />
<ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" label="Confirm Password" class="my-2" />
<div class="flex items-center py-2">
<p v-if="isRoot" class="text-error py-2 text-xs">* Root user is the only user that can have an empty password</p>
<div class="flex-grow" />
<ui-btn type="submit" :loading="changingPassword" color="success">Submit</ui-btn>
</div>
</form>
</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>
</template>
<script>
export default {
data() {
return {
password: null,
newPassword: null,
confirmPassword: null,
changingPassword: false
}
},
computed: {
user() {
return this.$store.state.user.user || null
},
username() {
return this.user.username
},
usertype() {
return this.user.type
},
isRoot() {
return this.usertype === 'root'
}
},
methods: {
logout() {
this.$axios.$post('/logout').catch((error) => {
console.error(error)
})
if (localStorage.getItem('token')) {
localStorage.removeItem('token')
}
this.$router.push('/login')
},
resetForm() {
this.password = null
this.newPassword = null
this.confirmPassword = null
},
submitChangePassword() {
if (this.newPassword !== this.confirmPassword) {
return this.$toast.error('New password and confirm password do not match')
}
if (this.password === this.newPassword) {
return this.$toast.error('Password and New Password cannot be the same')
}
this.changingPassword = true
this.$axios
.$patch('/api/user/password', {
password: this.password,
newPassword: this.newPassword
})
.then((res) => {
if (res.success) {
this.$toast.success('Password Changed Successfully')
this.resetForm()
} else {
this.$toast.error(res.error || 'Unknown Error')
}
this.changingPassword = false
})
.catch((error) => {
console.error(error)
this.$toast.error('Api call failed')
this.changingPassword = false
})
}
},
mounted() {}
}
</script>
+58 -16
View File
@@ -1,5 +1,5 @@
<template>
<div class="bg-bg page overflow-hidden relative" :class="streamAudiobook ? 'streaming' : ''">
<div id="page-wrapper" class="bg-bg page overflow-hidden relative" :class="streamAudiobook ? 'streaming' : ''">
<div v-show="saving" class="absolute z-20 w-full h-full flex items-center justify-center">
<ui-loading-indicator />
</div>
@@ -19,16 +19,15 @@
<div class="font-mono w-20 text-center">Duration</div>
<div class="font-mono text-center w-20">Status</div>
<div class="font-mono w-56">Notes</div>
<div class="font-book w-40">Include in Tracklist</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">
<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">
{{ index + 1 }}
</div>
<div class="font-book text-center px-4 w-12">
{{ audio.index }}
{{ audio.include ? index - numExcluded + 1 : -1 }}
</div>
<div class="font-book text-center px-4 w-12">{{ audio.index }}</div>
<div class="font-book text-center px-2 w-32">
{{ audio.trackNumFromFilename }}
</div>
@@ -51,6 +50,9 @@
<div class="font-sans text-xs font-normal w-56">
{{ audio.error }}
</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>
</transition-group>
</draggable>
@@ -66,9 +68,12 @@ export default {
draggable
},
async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user) {
if (!store.state.user.user) {
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) => {
console.error('Failed', error)
return false
@@ -77,10 +82,9 @@ export default {
console.error('No audiobook...', params.id)
return redirect('/')
}
let index = 0
return {
audiobook,
files: audiobook.audioFiles ? audiobook.audioFiles.map((af) => ({ ...af, index: ++index })) : []
files: audiobook.audioFiles ? audiobook.audioFiles.map((af) => ({ ...af, include: !af.exclude })) : []
}
},
data() {
@@ -98,6 +102,13 @@ export default {
audioFiles() {
return this.audiobook.audioFiles || []
},
numExcluded() {
var count = 0
this.files.forEach((file) => {
if (!file.include) count++
})
return count
},
missingPartChunks() {
if (this.missingParts === 1) return this.missingParts[0]
var chunks = []
@@ -164,15 +175,36 @@ export default {
}
},
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() {
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.$axios
.$patch(`/api/audiobook/${this.audiobook.id}/tracks`, { files: this.files })
.$patch(`/api/audiobook/${this.audiobook.id}/tracks`, { orderedFileData })
.then((data) => {
console.log('Finished patching files', data)
this.saving = false
// this.$router.go()
this.$toast.success('Tracks Updated')
this.$router.push(`/audiobook/${this.audiobookId}`)
})
@@ -207,16 +239,26 @@ export default {
.list-group {
min-height: 30px;
}
.list-group-item {
.list-group-item:not(.exclude) {
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);
}
.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);
}
.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);
}
.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>
+158 -23
View File
@@ -1,37 +1,72 @@
<template>
<div class="bg-bg page overflow-hidden" :class="streamAudiobook ? 'streaming' : ''">
<div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamAudiobook ? 'streaming' : ''">
<div class="w-full h-full overflow-y-auto p-8">
<div class="flex max-w-6xl mx-auto">
<div class="w-52" style="min-width: 208px">
<div class="relative">
<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 class="flex-grow px-10">
<div class="flex">
<h1 class="text-2xl">{{ title }}</h1>
<div class="mb-2">
<h1 class="text-2xl font-book leading-7">{{ title }}</h1>
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
<div class="w-min">
<ui-tooltip :text="authorTooltipText" direction="bottom">
<span class="text-sm text-gray-100 leading-7 whitespace-nowrap">by {{ author }}</span>
</ui-tooltip>
</div>
</div>
<div class="flex-grow" />
</div>
<p class="text-gray-300 text-sm my-1">
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span>
</p>
<div class="flex items-center pt-4">
<ui-btn color="success" :padding-x="4" class="flex items-center" @click="startStream">
<span class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
Play
</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>
<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' : ''">
<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 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>
<p class="text-sm my-4 text-gray-100">{{ description }}</p>
<div class="flex items-center pt-4">
<ui-btn v-if="!isMissing" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
{{ streaming ? 'Streaming' : 'Play' }}
</ui-btn>
<ui-btn v-else color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">error</span>
Missing
</ui-btn>
<!-- <ui-btn v-if="ebooks.length" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
Read
</ui-btn> -->
<ui-tooltip v-if="userCanUpdate" text="Edit" direction="top">
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
</ui-tooltip>
<ui-tooltip v-if="userCanDownload" :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>
<div class="flex-grow" />
</div>
<div class="my-4">
<p class="text-sm text-gray-100">{{ description }}</p>
</div>
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
<p class="text-sm mb-2">
@@ -44,7 +79,9 @@
<p class="text-sm mb-2">
Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span>
</p>
<p class="text-sm font-mono">{{ invalidParts.join(', ') }}</p>
<div>
<p v-for="part in invalidParts" :key="part.filename" class="text-sm font-mono">{{ part.filename }}: {{ part.error }}</p>
</div>
</div>
<tables-tracks-table :tracks="tracks" :audiobook-id="audiobook.id" class="mt-6" />
@@ -54,6 +91,8 @@
<tables-other-files-table v-if="otherFiles.length" :audiobook-id="audiobook.id" :files="otherFiles" class="mt-6" />
</div>
</div>
<div id="area"></div>
</div>
</div>
</template>
@@ -61,7 +100,7 @@
<script>
export default {
async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user) {
if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`)
}
var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => {
@@ -78,10 +117,23 @@ export default {
},
data() {
return {
resettingProgress: false
isRead: false,
resettingProgress: false,
isProcessingReadUpdate: false
}
},
watch: {
userIsRead: {
immediate: true,
handler(newVal) {
this.isRead = newVal
}
}
},
computed: {
isDeveloperMode() {
return this.$store.state.developerMode
},
missingPartChunks() {
if (this.missingParts === 1) return this.missingParts[0]
var chunks = []
@@ -113,6 +165,9 @@ export default {
})
return chunks
},
isMissing() {
return this.audiobook.isMissing
},
missingParts() {
return this.audiobook.missingParts || []
},
@@ -128,6 +183,27 @@ export default {
author() {
return this.book.author || 'Unknown'
},
authorFL() {
return this.book.authorFL
},
authorLF() {
return this.book.authorLF
},
authorTooltipText() {
var txt = ['FL: ' + this.authorFL || 'Not Set', 'LF: ' + this.authorLF || 'Not Set']
return txt.join('<br>')
},
series() {
return this.book.series || null
},
volumeNumber() {
return this.book.volumeNumber || null
},
seriesText() {
if (!this.series) return ''
if (!this.volumeNumber) return this.series
return `${this.series} #${this.volumeNumber}`
},
durationPretty() {
return this.audiobook.durationPretty
},
@@ -154,11 +230,14 @@ export default {
audioFiles() {
return this.audiobook.audioFiles || []
},
ebooks() {
return this.audiobook.ebooks
},
description() {
return this.book.description || 'No Description'
return this.book.description || ''
},
userAudiobooks() {
return this.$store.state.user ? this.$store.state.user.audiobooks || {} : {}
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
},
userAudiobook() {
return this.userAudiobooks[this.audiobookId] || null
@@ -166,6 +245,9 @@ export default {
userCurrentTime() {
return this.userAudiobook ? this.userAudiobook.currentTime : 0
},
userIsRead() {
return this.userAudiobook ? !!this.userAudiobook.isRead : false
},
userTimeRemaining() {
return this.duration - this.userCurrentTime
},
@@ -175,11 +257,61 @@ export default {
streamAudiobook() {
return this.$store.state.streamAudiobook
},
isStreaming() {
streaming() {
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: {
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() {
this.$axios
.$post('/api/feed', { audiobookId: this.audiobook.id })
.then((res) => {
console.log('Feed open', res)
this.$toast.success('RSS Feed Open')
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Failed to open feed')
})
},
startStream() {
this.$store.commit('setStreamAudiobook', this.audiobook)
this.$root.socket.emit('open_stream', this.audiobook.id)
@@ -223,6 +355,9 @@ export default {
this.resettingProgress = false
})
}
},
downloadClick() {
this.$store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'download' })
}
},
mounted() {
+150
View File
@@ -0,0 +1,150 @@
<template>
<div ref="page" id="page-wrapper" class="page px-6 pt-6 pb-52 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
<div class="flex justify-center flex-wrap">
<template v-for="audiobook in audiobookCopies">
<div :key="audiobook.id" class="w-full max-w-3xl border border-black-300 p-6 -ml-px -mt-px flex">
<div class="w-32">
<cards-book-cover :audiobook="audiobook.originalAudiobook" :width="120" />
</div>
<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.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="audiobook.book.author" label="Author" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="audiobook.book.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="audiobook.book.series" label="Series" :items="seriesItems" @input="seriesChanged" @newItem="newSeriesItem" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="audiobook.book.volumeNumber" label="Volume #" />
</div>
</div>
<ui-textarea-with-label v-model="audiobook.book.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="audiobook.book.genres" label="Genres" :items="genres" />
</div>
<div class="flex-grow px-1">
<ui-multi-select v-model="audiobook.tags" 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="audiobook.book.narrarator" label="Narrarator" />
</div>
</div>
</div>
</div>
</template>
</div>
<div v-show="isProcessing" class="fixed top-0 left-0 z-50 w-full h-full flex items-center justify-center bg-black bg-opacity-60">
<ui-loading-indicator />
</div>
<div :class="isScrollable ? 'fixed left-0 box-shadow-lg-up bg-primary' : ''" class="w-full h-20 px-4 flex items-center border-t border-bg z-40" :style="{ bottom: streamAudiobook ? '165px' : '0px' }">
<div class="flex-grow" />
<ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" @click="saveClick">Save</ui-btn>
</div>
</div>
</template>
<script>
export default {
asyncData({ store, redirect }) {
if (!store.state.selectedAudiobooks.length) {
return redirect('/')
}
var audiobooks = store.state.audiobooks.audiobooks.filter((ab) => store.state.selectedAudiobooks.includes(ab.id))
return {
audiobooks
}
},
data() {
return {
isProcessing: false,
audiobookCopies: [],
isScrollable: false,
newSeriesItems: []
}
},
computed: {
streamAudiobook() {
return this.$store.state.streamAudiobook
},
genres() {
return this.$store.state.audiobooks.genres
},
tags() {
return this.$store.state.audiobooks.tags
},
series() {
return this.$store.state.audiobooks.series
},
seriesItems() {
return [...this.series, ...this.newSeriesItems]
}
},
methods: {
newSeriesItem(item) {
if (!item) return
this.newSeriesItems.push(item)
},
seriesChanged() {
this.newSeriesItems = this.newSeriesItems.filter((item) => {
return this.audiobookCopies.find((ab) => ab.book.series === item)
})
},
init() {
this.audiobookCopies = this.audiobooks.map((ab) => {
var copy = { ...ab }
copy.tags = [...ab.tags]
copy.book = { ...ab.book }
copy.book.genres = [...ab.book.genres]
copy.originalAudiobook = ab
return copy
})
this.$nextTick(() => {
if (this.$refs.page.scrollHeight > this.$refs.page.clientHeight) {
this.isScrollable = true
}
})
},
saveClick() {
this.isProcessing = true
this.$axios
.$post('/api/audiobooks/update', this.audiobookCopies)
.then((data) => {
this.isProcessing = false
if (data.updates) {
this.$toast.success(`Successfully updated ${data.updates} audiobooks`)
this.$router.replace('/library')
} else {
this.$toast.warning('No updates were necessary')
}
})
.catch((error) => {
console.error('failed to batch update', error)
this.$toast.error('Failed to batch update')
this.isProcessing = false
})
}
},
mounted() {
this.init()
}
}
</script>
+243 -11
View File
@@ -1,18 +1,72 @@
<template>
<div 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">
<h1 class="text-2xl mb-2">Config</h1>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="p-4 text-center h-20">
<p>Nothing much here yet...</p>
<div class="flex items-center mb-2">
<h1 class="text-2xl">Users</h1>
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddUser">
<span class="material-icons" style="font-size: 1.4rem">add</span>
</div>
<!-- <ui-btn small :padding-x="4" class="h-8">Create User</ui-btn> -->
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="flex items-center py-4 mb-8">
<div class="p-4 text-center">
<table id="accounts" class="mb-8">
<tr>
<th>Username</th>
<th>Account Type</th>
<th style="width: 200px">Created At</th>
<th style="width: 100px"></th>
</tr>
<tr v-for="user in users" :key="user.id" :class="user.isActive ? '' : 'bg-error bg-opacity-20'">
<td>
{{ user.username }} <span class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
</td>
<td>{{ user.type }}</td>
<td class="text-sm font-mono">
{{ new Date(user.createdAt).toISOString() }}
</td>
<td>
<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>
</div>
</td>
</tr>
</table>
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="py-4 mb-8">
<p class="text-2xl">Scanner</p>
<div class="flex-grow" />
<ui-btn color="success" @click="scan">Scan</ui-btn>
<div class="flex items-start py-2">
<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="w-40 flex flex-col">
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</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 class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="flex items-center py-4">
<ui-btn color="bg" small :padding-x="4" :loading="isResettingAudiobooks" @click="resetAudiobooks">Reset All Audiobooks</ui-btn>
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="flex items-center py-4">
<p class="font-mono">v{{ $config.version }}</p>
<div class="flex-grow" />
@@ -26,24 +80,202 @@
</a>
</div>
</div>
<div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
</div>
</template>
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsRoot']) {
redirect('/?error=unauthorized')
}
},
data() {
return {}
return {
isResettingAudiobooks: false,
users: [],
selectedAccount: null,
showAccountModal: false,
isDeletingUser: false,
newServerSettings: {}
}
},
watch: {
serverSettings(newVal, oldVal) {
if (newVal && !oldVal) {
this.newServerSettings = { ...this.serverSettings }
}
}
},
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() {
return this.$store.state.streamAudiobook
},
isScanning() {
return this.$store.state.isScanning
},
isScanningCovers() {
return this.$store.state.isScanningCovers
}
},
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() {
var value = !this.$store.state.developerMode
this.$store.commit('setDeveloperMode', value)
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
},
scan() {
this.$root.socket.emit('scan')
},
scanCovers() {
this.$root.socket.emit('scan_covers')
},
loadUsers() {
this.$axios
.$get('/api/users')
.then((users) => {
this.users = users
})
.catch((error) => {
console.error('Failed', error)
})
},
resetAudiobooks() {
if (confirm('WARNING! This action will remove all audiobooks from the database including any updates or matches you have made. This does not do anything to your actual files. Shall we continue?')) {
this.isResettingAudiobooks = true
this.$axios
.$delete('/api/audiobooks')
.then(() => {
this.isResettingAudiobooks = false
this.$toast.success('Successfully reset audiobooks')
})
.catch((error) => {
console.error('failed to reset audiobooks', error)
this.isResettingAudiobooks = false
this.$toast.error('Failed to reset audiobooks - stop docker and manually remove appdata')
})
}
},
clickAddUser() {
this.selectedAccount = null
this.showAccountModal = true
},
editUser(user) {
this.selectedAccount = user
this.showAccountModal = true
},
deleteUserClick(user) {
if (this.isDeletingUser) return
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
this.isDeletingUser = true
this.$axios
.$delete(`/api/user/${user.id}`)
.then((data) => {
this.isDeletingUser = false
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success('User deleted')
}
})
.catch((error) => {
console.error('Failed to delete user', error)
this.$toast.error('Failed to delete user')
this.isDeletingUser = false
})
}
},
addUpdateUser(user) {
if (!this.users) return
var index = this.users.findIndex((u) => u.id === user.id)
if (index >= 0) {
this.users.splice(index, 1, user)
} else {
this.users.push(user)
}
},
userRemoved(user) {
this.users = this.users.filter((u) => u.id !== user.id)
},
init(attempts = 0) {
if (!this.$root.socket) {
if (attempts > 10) {
return console.error('Failed to setup socket listeners')
}
setTimeout(() => {
this.init(++attempts)
}, 250)
return
}
this.$root.socket.on('user_added', this.addUpdateUser)
this.$root.socket.on('user_updated', this.addUpdateUser)
this.$root.socket.on('user_removed', this.userRemoved)
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
}
},
mounted() {}
mounted() {
this.loadUsers()
this.init()
},
beforeDestroy() {
if (this.$root.socket) {
this.$root.socket.off('user_added', this.newUserAdded)
this.$root.socket.off('user_updated', this.userUpdated)
}
}
}
</script>
</script>
<style>
#accounts {
table-layout: fixed;
border-collapse: collapse;
width: 100%;
}
#accounts td,
#accounts th {
border: 1px solid #2e2e2e;
padding: 8px 8px;
text-align: left;
}
#accounts tr:nth-child(even) {
background-color: #3a3a3a;
}
#accounts tr:hover {
background-color: #444;
}
#accounts th {
font-size: 0.8rem;
font-weight: 600;
padding-top: 5px;
padding-bottom: 5px;
background-color: #333;
}
</style>
+18 -2
View File
@@ -1,12 +1,28 @@
<template>
<div class="page" :class="streamAudiobook ? 'streaming' : ''">
<app-book-shelf-toolbar />
<app-book-shelf />
<!-- <app-book-shelf-toolbar /> -->
<!-- <div class="flex h-full">
<app-side-rail />
<div class="flex-grow"> -->
<!-- <app-book-shelf /> -->
<!-- </div> -->
<!-- </div> -->
<div class="flex h-full">
<app-side-rail />
<div class="flex-grow">
<app-book-shelf-toolbar is-home />
<app-book-shelf-categorized />
</div>
</div>
</div>
</template>
<script>
export default {
// asyncData({ redirect }) {
// redirect('/library')
// },
data() {
return {}
},
+70
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>
+5 -11
View File
@@ -27,36 +27,31 @@ export default {
return {
error: null,
processing: false,
username: 'root',
username: '',
password: null
}
},
watch: {
user(newVal) {
if (newVal) {
// if (process.env.NODE_ENV !== 'production') {
if (this.$route.query.redirect) {
this.$router.replace(this.$route.query.redirect)
} else {
this.$router.replace('/')
}
// } else {
// window.location.reload()
// }
}
}
},
computed: {
user() {
return this.$store.state.user
return this.$store.state.user.user
}
},
methods: {
async submitForm() {
this.error = null
this.processing = true
// var uri = `${process.env.serverUrl}/auth`
var payload = {
username: this.username,
password: this.password || ''
@@ -71,7 +66,7 @@ export default {
} else if (authRes.error) {
this.error = authRes.error
} else {
this.$store.commit('setUser', authRes.user)
this.$store.commit('user/setUser', authRes.user)
}
this.processing = false
},
@@ -82,7 +77,6 @@ export default {
if (token) {
this.processing = true
console.log('Authorize', token)
this.$axios
.$post('/api/authorize', null, {
headers: {
@@ -90,7 +84,7 @@ export default {
}
})
.then((res) => {
this.$store.commit('setUser', res.user)
this.$store.commit('user/setUser', res.user)
this.processing = false
})
.catch((error) => {
+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>
+2 -3
View File
@@ -1,17 +1,16 @@
export default function ({ $axios, store }) {
$axios.onRequest(config => {
console.log('Making request to ' + config.url)
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
return
}
var bearerToken = store.state.user ? store.state.user.token : null
// console.log('Bearer token', bearerToken)
var bearerToken = store.state.user.user ? store.state.user.user.token : null
if (bearerToken) {
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
}
if (process.env.NODE_ENV === 'development') {
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)
}
+32 -22
View File
@@ -38,29 +38,26 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
}
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.$calculateTextSize = (text, styles = {}) => {
const el = document.createElement('p')
Vue.prototype.$downloadImage = async (uri, name) => {
var blob = await loadImageBlob(uri)
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.target = '_blank'
a.download = name || 'fotosho-image'
a.click()
let attr = 'margin:0px;opacity:1;position:absolute;top:100px;left:100px;z-index:99;'
for (const key in styles) {
if (styles[key] && String(styles[key]).length > 0) {
attr += `${key}:${styles[key]};`
}
}
el.setAttribute('style', attr)
el.innerText = text
document.body.appendChild(el)
const boundingBox = el.getBoundingClientRect()
el.remove()
return {
height: boundingBox.height,
width: boundingBox.width
}
}
function isClickedOutsideEl(clickEvent, elToCheckOutside, ignoreSelectors = [], ignoreElems = []) {
@@ -118,3 +115,16 @@ Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
.replace(windowsTrailingRe, replacement);
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 -8
View File
@@ -1,11 +1,10 @@
import Vue from "vue";
import Toast from "vue-toastification";
// Import the CSS or use your own!
import "vue-toastification/dist/index.css";
import Vue from "vue"
import Toast from "vue-toastification"
import "vue-toastification/dist/index.css"
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
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

+191 -22
View File
@@ -1,62 +1,195 @@
import { sort } from '@/assets/fastSort'
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 = () => ({
audiobooks: [],
lastLoad: 0,
listeners: [],
genres: [...STANDARD_GENRES],
tags: []
tags: [],
series: [],
keywordFilter: null,
selectedSeries: null,
libraryPage: null,
searchResults: []
})
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 settings = rootState.settings.settings || {}
var settings = rootState.user.settings || {}
var filterBy = settings.filterBy || ''
var filterByParts = filterBy.split('.')
if (filterByParts.length > 1) {
var primary = filterByParts[0]
var secondary = filterByParts[1]
if (primary === 'genres') {
var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) {
var filter = decode(filterBy.replace(`${group}.`, ''))
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 === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
else if (group === 'progress') {
filtered = filtered.filter(ab => {
return ab.book && ab.book.genres.includes(secondary)
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
})
} else if (primary === 'tags') {
filtered = filtered.filter(ab => ab.tags.includes(secondary))
}
}
// TODO: Add filters
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
},
getFilteredAndSorted: (state, getters, rootState) => () => {
var settings = rootState.settings.settings
var settings = rootState.user.settings
var direction = settings.orderDesc ? 'desc' : 'asc'
var filtered = getters.getFiltered()
var orderByNumber = settings.orderBy === 'book.volumeNumber'
return sort(filtered)[direction]((ab) => {
// Supports dot notation strings i.e. "book.title"
return settings.orderBy.split('.').reduce((a, b) => a[b], ab)
var value = settings.orderBy.split('.').reduce((a, b) => a[b], ab)
if (orderByNumber && !isNaN(value)) return Number(value)
return value
})
},
getSeriesGroups: (state, getters, rootState) => () => {
var series = {}
state.audiobooks.forEach((audiobook) => {
if (audiobook.book && audiobook.book.series) {
if (series[audiobook.book.series]) {
var bookLastUpdate = audiobook.book.lastUpdate
if (bookLastUpdate > series[audiobook.book.series].lastUpdate) series[audiobook.book.series].lastUpdate = bookLastUpdate
series[audiobook.book.series].books.push(audiobook)
} else {
series[audiobook.book.series] = {
type: 'series',
name: audiobook.book.series || '',
books: [audiobook],
lastUpdate: audiobook.book.lastUpdate
}
}
}
})
var seriesArray = Object.values(series).map((_series) => {
_series.books = sort(_series.books)['asc']((ab) => {
return ab.book && ab.book.volumeNumber && !isNaN(ab.book.volumeNumber) ? Number(ab.book.volumeNumber) : null
})
return _series
})
if (state.keywordFilter) {
const keywordFilter = state.keywordFilter.toLowerCase()
return seriesArray.filter((_series) => _series.name.toLowerCase().includes(keywordFilter))
}
return seriesArray
},
getUniqueAuthors: (state) => {
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
return [...new Set(_authors)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
},
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 = {
load({ commit }) {
// Return true if calling load
load({ state, commit, rootState }) {
if (!rootState.user || !rootState.user.user) {
console.error('audiobooks/load - User not set')
return false
}
// Don't load again if already loaded in the last 5 minutes
var lastLoadDiff = Date.now() - state.lastLoad
if (lastLoadDiff < 5 * 60 * 1000) {
// Already up to date
return false
}
this.$axios
.$get(`/api/audiobooks`)
.then((data) => {
commit('set', data)
commit('setLastLoad')
})
.catch((error) => {
console.error('Failed', error)
commit('set', [])
})
return true
},
}
export const mutations = {
setLastLoad(state) {
state.lastLoad = Date.now()
},
setKeywordFilter(state, val) {
state.keywordFilter = val
},
setSelectedSeries(state, val) {
state.selectedSeries = val
},
setLibraryPage(state, val) {
state.libraryPage = val
},
setSearchResults(state, val) {
state.searchResults = val
},
set(state, audiobooks) {
// GENRES
var genres = [...state.genres]
@@ -65,6 +198,7 @@ export const mutations = {
genres = genres.concat(ab.book.genres)
})
state.genres = [...new Set(genres)] // Remove Duplicates
state.genres.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
// TAGS
var tags = []
@@ -72,6 +206,16 @@ export const mutations = {
tags = tags.concat(ab.tags)
})
state.tags = [...new Set(tags)] // Remove Duplicates
state.tags.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
// SERIES
var series = []
audiobooks.forEach((ab) => {
if (!ab.book || !ab.book.series || series.includes(ab.book.series)) return
series.push(ab.book.series)
})
state.series = series
state.series.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
state.audiobooks = audiobooks
state.listeners.forEach((listener) => {
@@ -80,19 +224,34 @@ export const mutations = {
},
addUpdate(state, audiobook) {
var index = state.audiobooks.findIndex(a => a.id === audiobook.id)
var origAudiobook = null
if (index >= 0) {
origAudiobook = { ...state.audiobooks[index] }
state.audiobooks.splice(index, 1, audiobook)
} else {
state.audiobooks.push(audiobook)
}
// GENRES
if (audiobook.book) {
// GENRES
var newGenres = []
audiobook.book.genres.forEach((genre) => {
if (!state.genres.includes(genre)) newGenres.push(genre)
})
if (newGenres.length) state.genres = state.genres.concat(newGenres)
if (newGenres.length) {
state.genres = state.genres.concat(newGenres)
state.genres.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
}
// SERIES
if (audiobook.book.series && !state.series.includes(audiobook.book.series)) {
state.series.push(audiobook.book.series)
state.series.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
}
if (origAudiobook && origAudiobook.book && origAudiobook.book.series) {
var isInAB = state.audiobooks.find(ab => ab.book && ab.book.series === origAudiobook.book.series)
if (!isInAB) state.series = state.series.filter(series => series !== origAudiobook.book.series)
}
}
// TAGS
@@ -100,8 +259,10 @@ export const mutations = {
audiobook.tags.forEach((tag) => {
if (!state.tags.includes(tag)) newTags.push(tag)
})
if (newTags.length) state.tags = state.tags.concat(newTags)
if (newTags.length) {
state.tags = state.tags.concat(newTags)
state.tags.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
}
state.listeners.forEach((listener) => {
if (!listener.audiobookId || listener.audiobookId === audiobook.id) {
@@ -112,8 +273,8 @@ export const mutations = {
remove(state, audiobook) {
state.audiobooks = state.audiobooks.filter(a => a.id !== audiobook.id)
// GENRES
if (audiobook.book) {
// GENRES
audiobook.book.genres.forEach((genre) => {
if (!STANDARD_GENRES.includes(genre)) {
var isInOtherAB = state.audiobooks.find(ab => {
@@ -125,6 +286,15 @@ export const mutations = {
}
}
})
// SERIES
if (audiobook.book.series) {
var isInOtherAB = state.audiobooks.find(ab => ab.book && ab.book.series === audiobook.book.series)
if (!isInOtherAB) {
// Series not used in any other audiobook - remove it
state.series = state.series.filter(s => s !== audiobook.book.series)
}
}
}
// TAGS
@@ -138,7 +308,6 @@ export const mutations = {
}
})
state.listeners.forEach((listener) => {
if (!listener.audiobookId || listener.audiobookId === audiobook.id) {
listener.meth()
+39
View File
@@ -0,0 +1,39 @@
export const state = () => ({
downloads: []
})
export const getters = {
getDownloads: (state) => (audiobookId) => {
return state.downloads.filter(d => d.audiobookId === audiobookId)
},
getDownload: (state) => (id) => {
return state.downloads.find(d => d.id === id)
}
}
export const actions = {
}
export const mutations = {
addUpdateDownload(state, download) {
// Remove older downloads of matching type
state.downloads = state.downloads.filter(d => {
if (d.id !== download.id && d.type === download.type) {
return false
}
return true
})
var index = state.downloads.findIndex(d => d.id === download.id)
if (index >= 0) {
state.downloads.splice(index, 1, download)
} else {
state.downloads.push(download)
}
},
removeDownload(state, download) {
state.downloads = state.downloads.filter(d => d.id !== download.id)
}
}
+103 -16
View File
@@ -1,33 +1,83 @@
import { checkForUpdate } from '@/plugins/version'
import Vue from 'vue'
export const state = () => ({
user: null,
versionData: null,
serverSettings: null,
streamAudiobook: null,
editModalTab: 'details',
showEditModal: false,
selectedAudiobook: null,
playOnLoad: false,
isScanning: false,
scanProgress: null
isScanningCovers: false,
scanProgress: null,
coverScanProgress: null,
developerMode: false,
selectedAudiobooks: [],
processingBatch: false,
previousPath: '/',
routeHistory: []
})
export const getters = {
getToken: (state) => {
return state.user ? state.user.token : null
getIsAudiobookSelected: state => audiobookId => {
return !!state.selectedAudiobooks.includes(audiobookId)
},
getUserAudiobook: (state) => (audiobookId) => {
return state.user && state.user.audiobooks ? state.user.audiobooks[audiobookId] || null : null
}
getNumAudiobooksSelected: state => state.selectedAudiobooks.length
}
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 = {
setUser(state, user) {
state.user = user
if (user.token) {
localStorage.setItem('token', user.token)
}
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) {
state.playOnLoad = true
@@ -47,17 +97,54 @@ export const mutations = {
state.playOnLoad = val
},
showEditModal(state, audiobook) {
state.editModalTab = 'details'
state.selectedAudiobook = audiobook
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) {
state.showEditModal = val
},
setIsScanning(state, isScanning) {
state.isScanning = isScanning
},
setScanProgress(state, progress) {
if (progress > 0) state.isScanning = true
state.scanProgress = progress
setScanProgress(state, scanProgress) {
if (scanProgress && scanProgress.progress > 0) state.isScanning = true
state.scanProgress = scanProgress
},
setIsScanningCovers(state, isScanningCovers) {
state.isScanningCovers = isScanningCovers
},
setCoverScanProgress(state, coverScanProgress) {
if (coverScanProgress && coverScanProgress.progress > 0) state.isScanningCovers = true
state.coverScanProgress = coverScanProgress
},
setDeveloperMode(state, val) {
state.developerMode = val
},
setSelectedAudiobooks(state, audiobooks) {
Vue.set(state, 'selectedAudiobooks', audiobooks)
// state.selectedAudiobooks = audiobooks
},
toggleAudiobookSelected(state, audiobookId) {
if (state.selectedAudiobooks.includes(audiobookId)) {
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
} else {
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) {
state.processingBatch = val
}
}
-39
View File
@@ -1,39 +0,0 @@
export const state = () => ({
settings: {
orderBy: 'book.title',
orderDesc: false,
filterBy: 'all'
},
listeners: []
})
export const getters = {
getFilterOrderKey: (state) => {
return Object.values(state.settings).join('-')
}
}
export const actions = {
}
export const mutations = {
setSettings(state, settings) {
state.settings = {
...settings
}
state.listeners.forEach((listener) => {
listener.meth()
})
},
addListener(state, listener) {
var index = state.listeners.findIndex(l => l.id === listener.id)
if (index >= 0) state.listeners.splice(index, 1, listener)
else state.listeners.push(listener)
},
removeListener(state, listenerId) {
state.listeners = state.listeners.filter(l => l.id !== listenerId)
}
}
+96
View File
@@ -0,0 +1,96 @@
export const state = () => ({
user: null,
settings: {
orderBy: 'book.title',
orderDesc: false,
filterBy: 'all',
playbackRate: 1,
bookshelfCoverSize: 120
},
settingsListeners: []
})
export const getters = {
getIsRoot: (state) => state.user && state.user.type === 'root',
getToken: (state) => {
return state.user ? state.user.token : null
},
getUserAudiobook: (state) => (audiobookId) => {
return state.user && state.user.audiobooks ? state.user.audiobooks[audiobookId] || null : null
},
getUserSetting: (state) => (key) => {
return state.settings ? state.settings[key] || null : null
},
getFilterOrderKey: (state) => {
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
}
}
export const actions = {
updateUserSettings({ commit }, payload) {
var updatePayload = {
...payload
}
// Immediately update
commit('setSettings', updatePayload)
return this.$axios.$patch('/api/user/settings', updatePayload).then((result) => {
if (result.success) {
commit('setSettings', result.settings)
return true
} else {
return false
}
}).catch((error) => {
console.error('Failed to update settings', error)
return false
})
}
}
export const mutations = {
setUser(state, user) {
state.user = user
if (user) {
if (user.token) localStorage.setItem('token', user.token)
} else {
localStorage.removeItem('token')
}
},
setSettings(state, settings) {
if (!settings) return
var hasChanges = false
for (const key in settings) {
if (state.settings[key] !== settings[key]) {
hasChanges = true
state.settings[key] = settings[key]
}
}
if (hasChanges) {
state.settingsListeners.forEach((listener) => {
listener.meth(state.settings)
})
}
},
addSettingsListener(state, listener) {
var index = state.settingsListeners.findIndex(l => l.id === listener.id)
if (index >= 0) state.settingsListeners.splice(index, 1, listener)
else state.settingsListeners.push(listener)
},
removeSettingsListener(state, listenerId) {
state.settingsListeners = state.settingsListeners.filter(l => l.id !== listenerId)
}
}
+11 -3
View File
@@ -4,7 +4,9 @@ module.exports = {
purge: {
options: {
safelist: [
'bg-success'
'bg-success',
'bg-red-600',
'py-1.5'
]
}
},
@@ -14,15 +16,21 @@ module.exports = {
height: {
'7.5': '1.75rem'
},
spacing: {
'-54': '-13.5rem'
},
rotate: {
'-60': '-60deg'
},
colors: {
bg: '#373838',
primary: '#262626',
primary: '#232323',
accent: '#1ad691',
error: '#FF5252',
info: '#2196F3',
success: '#4CAF50',
successDark: '#3b8a3e',
warning: '#FB8C00',
darkgreen: 'rgb(34,127,35)',
'black-50': '#bbbbbb',
'black-100': '#666666',
'black-200': '#555555',
+5 -5
View File
@@ -7,12 +7,12 @@
<MyIP/>
<Shell>sh</Shell>
<Privileged>false</Privileged>
<Support>https://hub.docker.com/r/advplyr/audiobookshelf/</Support>
<Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support>
<Project>https://github.com/advplyr/audiobookshelf</Project>
<Overview>Audiobook manager and player</Overview>
<Category>MediaApp:Books MediaServer:Books Status:Beta<</Category>
<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</Category>
<WebUI>http://[IP]:[PORT:80]</WebUI>
<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>
<ExtraParams/>
<PostArgs/>
@@ -20,7 +20,7 @@
<DateInstalled>1629238508</DateInstalled>
<DonateText/>
<DonateLink/>
<Description>Audiobook manager and player</Description>
<Description>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.</Description>
<Networking>
<Mode>bridge</Mode>
<Publish>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+1
View File
@@ -11,6 +11,7 @@ if (isDev) {
process.env.CONFIG_PATH = devEnv.ConfigPath
process.env.METADATA_PATH = devEnv.MetadataPath
process.env.AUDIOBOOK_PATH = devEnv.AudiobookPath
process.env.FFMPEG_PATH = devEnv.FFmpegPath
}
const PORT = process.env.PORT || 80
+655 -2025
View File
File diff suppressed because it is too large Load Diff
+23 -11
View File
@@ -1,32 +1,44 @@
{
"name": "audiobookshelf",
"version": "0.9.61-beta",
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
"version": "1.2.7",
"description": "Self-hosted audiobook server for managing and playing audiobooks",
"main": "index.js",
"scripts": {
"dev": "node index.js",
"start": "node index.js",
"release": "dotenv release-it --disable-metrics --no-npm --npm.skipChecks",
"release-dry": "dotenv release-it --disable-metrics --no-npm --npm.skipChecks --dry-run"
"client": "cd client && npm install && npm run generate",
"prod": "npm run client && npm install && node prod.js",
"build-win": "npm run build-prep && pkg -t node12-win-x64 -o ./dist/win/audiobookshelf .",
"build-linux": "build/linuxpackager"
},
"bin": "prod.js",
"pkg": {
"assets": "client/dist/**/*",
"scripts": [
"prod.js",
"server/**/*.js"
]
},
"author": "advplyr",
"license": "ISC",
"dependencies": {
"archiver": "^5.3.0",
"axios": "^0.21.1",
"bcryptjs": "^2.4.3",
"chokidar": "^3.5.2",
"command-line-args": "^5.2.0",
"cookie-parser": "^1.4.5",
"express": "^4.17.1",
"express-fileupload": "^1.2.1",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^10.0.0",
"ip": "^1.1.5",
"jsonwebtoken": "^8.5.1",
"libgen": "^2.1.0",
"njodb": "^0.4.20",
"node-dir": "^0.1.17",
"socket.io": "^4.1.3"
"podcast": "^1.3.0",
"socket.io": "^4.1.3",
"watcher": "^1.2.0"
},
"devDependencies": {
"dotenv-cli": "^4.0.0",
"release-it": "^14.11.5"
}
}
"devDependencies": {}
}
+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()
+127 -19
View File
@@ -2,41 +2,149 @@
AudioBookshelf is a self-hosted audiobook server for managing and playing your audiobooks.
**Currently in early beta**
See [Install guides](https://audiobookshelf.org/install) and [documentation](https://audiobookshelf.org/docs)
<img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_bookshelf.png" />
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**
<img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_streaming.png" />
#### Folder Structures Supported:
## Directory Structure
```bash
/Title/...
/Author/Title/...
/Author/Series/Title/...
See [documentation](https://audiobookshelf.org/docs) for directory structure and naming.
Title can start with the publish year like so:
/1989 - Awesome Book/...
```
Author, Series, Volume Number, Title and Publish Year can all be parsed from your folder structure.
**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/...`
#### There is still a lot to do:
### 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
* Adding new audiobooks require pressing Scan button again (on settings page)
* Matching is all manual now and only using 1 source (openlibrary)
* Support different views to see more details of each audiobook
* Then comes the mobile app..
<img alt="Screenshot2" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_streaming.png" />
* iOS App (Android is in beta [here](https://play.google.com/store/apps/details?id=com.audiobookshelf.app))
## Installation
Built to run in Docker for now (also on Unraid server Community Apps)
### Docker Install
Available in Unraid Community Apps
```bash
docker run -d -p 1337:80 -v /audiobooks:/audiobooks -v /config:/config -v /metadata:/metadata --name audiobookshelf --rm advplyr/audiobookshelf
docker pull advplyr/audiobookshelf
docker run -d \
-p 1337:80 \
-v </path/to/audiobooks>:/audiobooks \
-v </path/to/config>:/config \
-v </path/to/metadata>:/metadata \
--name audiobookshelf \
--rm advplyr/audiobookshelf
```
<img alt="Screenshot3" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_audiobook.png" />
### 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
+400 -15
View File
@@ -1,13 +1,22 @@
const express = require('express')
const Path = require('path')
const fs = require('fs-extra')
const Logger = require('./Logger')
const User = require('./objects/User')
const { isObject, isAcceptableCoverMimeType } = require('./utils/index')
const { CoverDestination } = require('./utils/constants')
class ApiController {
constructor(db, scanner, auth, streamManager, emitter) {
constructor(MetadataPath, db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter, clientEmitter) {
this.db = db
this.scanner = scanner
this.auth = auth
this.streamManager = streamManager
this.rssFeeds = rssFeeds
this.downloadManager = downloadManager
this.emitter = emitter
this.clientEmitter = clientEmitter
this.MetadataPath = MetadataPath
this.router = express()
this.init()
@@ -17,21 +26,40 @@ class ApiController {
this.router.get('/find/covers', this.findCovers.bind(this))
this.router.get('/find/:method', this.find.bind(this))
this.router.get('/audiobooks', this.getAudiobooks.bind(this))
this.router.delete('/audiobooks', this.deleteAllAudiobooks.bind(this))
this.router.post('/audiobooks/delete', this.batchDeleteAudiobooks.bind(this))
this.router.post('/audiobooks/update', this.batchUpdateAudiobooks.bind(this))
this.router.get('/audiobook/:id', this.getAudiobook.bind(this))
this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this))
this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.bind(this))
this.router.post('/audiobook/:id/cover', this.uploadAudiobookCover.bind(this))
this.router.patch('/audiobook/:id', this.updateAudiobook.bind(this))
this.router.get('/metadata/:id/:trackIndex', this.getMetadata.bind(this))
this.router.patch('/match/:id', this.match.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/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.get('/genres', this.getGenres.bind(this))
this.router.post('/feed', this.openRssFeed.bind(this))
this.router.get('/download/:id', this.download.bind(this))
}
find(req, res) {
@@ -39,7 +67,6 @@ class ApiController {
}
findCovers(req, res) {
console.log('Find covers', req.query)
this.scanner.findCovers(req, res)
}
@@ -57,9 +84,26 @@ class ApiController {
}
getAudiobooks(req, res) {
Logger.info('Get Audiobooks')
var audiobooksMinified = this.db.audiobooks.map(ab => ab.toJSONMinified())
res.json(audiobooksMinified)
var audiobooks = []
if (req.query.q) {
audiobooks = this.db.audiobooks.filter(ab => {
return ab.isSearchMatch(req.query.q)
}).map(ab => ab.toJSONMinified())
} else {
audiobooks = this.db.audiobooks.map(ab => ab.toJSONMinified())
}
res.json(audiobooks)
}
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')
var success = await this.db.recreateAudiobookDb()
if (success) res.sendStatus(200)
else res.sendStatus(500)
}
getAudiobook(req, res) {
@@ -68,14 +112,11 @@ class ApiController {
res.json(audiobook.toJSONExpanded())
}
async deleteAudiobook(req, res) {
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
async handleDeleteAudiobook(audiobook) {
// Remove audiobook from users
for (let i = 0; i < this.db.users.length; i++) {
var user = this.db.users[i]
var madeUpdates = user.resetAudiobookProgress(audiobook.id)
var madeUpdates = user.deleteAudiobookProgress(audiobook.id)
if (madeUpdates) {
await this.db.updateEntity('user', user)
}
@@ -94,24 +135,177 @@ class ApiController {
}
}
var audiobookJSON = audiobook.toJSONMinified()
await this.db.removeEntity('audiobook', audiobook.id)
this.emitter('audiobook_removed', audiobookJSON)
}
this.emitter('audiobook_removed', audiobook.toJSONMinified())
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)
if (!audiobook) return res.sendStatus(404)
await this.handleDeleteAudiobook(audiobook)
res.sendStatus(200)
}
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
if (!audiobookIds || !audiobookIds.length) {
return res.sendStatus(500)
}
var audiobooksToDelete = this.db.audiobooks.filter(ab => audiobookIds.includes(ab.id))
if (!audiobooksToDelete.length) {
return res.sendStatus(404)
}
for (let i = 0; i < audiobooksToDelete.length; i++) {
Logger.info(`[ApiController] Deleting Audiobook "${audiobooksToDelete[i].title}"`)
await this.handleDeleteAudiobook(audiobooksToDelete[i])
}
res.sendStatus(200)
}
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
if (!audiobooks || !audiobooks.length) {
return res.sendStatus(500)
}
var audiobooksUpdated = 0
audiobooks = audiobooks.map((ab) => {
var _ab = this.db.audiobooks.find(__ab => __ab.id === ab.id)
if (!_ab) return null
var hasUpdated = _ab.update(ab)
if (!hasUpdated) return null
audiobooksUpdated++
return _ab
}).filter(ab => ab)
if (audiobooksUpdated) {
Logger.info(`[ApiController] ${audiobooksUpdated} Audiobooks have updates`)
for (let i = 0; i < audiobooks.length; i++) {
await this.db.updateAudiobook(audiobooks[i])
this.emitter('audiobook_updated', audiobooks[i].toJSONMinified())
}
}
res.json({
success: true,
updates: audiobooksUpdated
})
}
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)
if (!audiobook) return res.sendStatus(404)
var files = req.body.files
var orderedFileData = req.body.orderedFileData
Logger.info(`Updating audiobook tracks called ${audiobook.id}`)
audiobook.updateAudioTracks(files)
audiobook.updateAudioTracks(orderedFileData)
await this.db.updateAudiobook(audiobook)
this.emitter('audiobook_updated', audiobook.toJSONMinified())
res.json(audiobook.toJSON())
}
async uploadAudiobookCover(req, res) {
if (!req.user.canUpload || !req.user.canUpdate) {
Logger.warn('User attempted to upload a cover without permission', req.user)
return res.sendStatus(403)
}
if (!req.files || !req.files.cover) {
return res.status(400).send('No files were uploaded')
}
var audiobookId = req.params.id
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
if (!audiobook) {
return res.status(404).send('Audiobook not found')
}
var coverFile = req.files.cover
var mimeType = coverFile.mimetype
var extname = Path.extname(coverFile.name.toLowerCase()) || '.jpg'
if (!isAcceptableCoverMimeType(mimeType)) {
return res.status(400).send('Invalid image file type: ' + mimeType)
}
var coverDestination = this.db.serverSettings ? this.db.serverSettings.coverDestination : CoverDestination.METADATA
Logger.info(`[ApiController] Cover Upload destination ${coverDestination}`)
var coverDirpath = audiobook.fullPath
var coverRelDirpath = Path.join('/local', audiobook.path)
if (coverDestination === CoverDestination.METADATA) {
coverDirpath = Path.join(this.MetadataPath, 'books', audiobookId)
coverRelDirpath = Path.join('/metadata', 'books', audiobookId)
Logger.debug(`[ApiController] storing in metadata | ${coverDirpath}`)
await fs.ensureDir(coverDirpath)
} else {
Logger.debug(`[ApiController] storing in audiobook | ${coverRelDirpath}`)
}
var coverFilename = `cover${extname}`
var coverFullPath = Path.join(coverDirpath, coverFilename)
var coverPath = Path.join(coverRelDirpath, coverFilename)
// If current cover is a metadata cover and does not match replacement, then remove it
var currentBookCover = audiobook.book.cover
if (currentBookCover && currentBookCover.startsWith(Path.sep + 'metadata')) {
Logger.debug(`Current Book Cover is metadata ${currentBookCover}`)
if (currentBookCover !== coverPath) {
Logger.info(`[ApiController] removing old metadata cover "${currentBookCover}"`)
var oldFullBookCoverPath = Path.join(this.MetadataPath, currentBookCover.replace(Path.sep + 'metadata', ''))
// Metadata path may have changed, check if exists first
var exists = await fs.pathExists(oldFullBookCoverPath)
if (exists) {
try {
await fs.remove(oldFullBookCoverPath)
} catch (error) {
Logger.error(`[ApiController] Failed to remove old metadata book cover ${oldFullBookCoverPath}`)
}
}
}
}
var success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
Logger.error('Failed to move cover file', path, error)
return false
})
if (!success) {
return res.status(500).send('Failed to move cover into destination')
}
Logger.info(`[ApiController] Uploaded audiobook cover "${coverPath}" for "${audiobook.title}"`)
audiobook.updateBookCover(coverPath)
await this.db.updateAudiobook(audiobook)
this.emitter('audiobook_updated', audiobook.toJSONMinified())
res.json({
success: true,
cover: coverPath
})
}
async updateAudiobook(req, res) {
if (!req.user.canUpdate) {
Logger.warn('User attempted to update without permission', req.user)
return res.sendStatus(403)
}
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
var hasUpdates = audiobook.update(req.body)
@@ -142,13 +336,204 @@ class ApiController {
res.sendStatus(200)
}
getUsers(req, res) {
if (req.user.type !== 'root') return res.sendStatus(403)
return res.json(this.db.users.map(u => u.toJSONForBrowser()))
}
async resetUserAudiobookProgress(req, res) {
req.user.resetAudiobookProgress(req.params.id)
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)
}
userChangePassword(req, res) {
this.auth.userChangePassword(req, res)
}
async openRssFeed(req, res) {
var audiobookId = req.body.audiobookId
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
if (!audiobook) return res.sendStatus(404)
var feed = await this.rssFeeds.openFeed(audiobook)
console.log('Feed open', feed)
res.json(feed)
}
async userUpdateSettings(req, res) {
var settingsUpdate = req.body
if (!settingsUpdate || !isObject(settingsUpdate)) {
return res.sendStatus(500)
}
var madeUpdates = req.user.updateSettings(settingsUpdate)
if (madeUpdates) {
await this.db.updateEntity('user', req.user)
}
return res.json({
success: true,
settings: req.user.settings
})
}
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
account.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
account.pash = await this.auth.hashPass(account.password)
delete account.password
account.token = await this.auth.generateAccessToken({ userId: account.id })
account.createdAt = Date.now()
var newUser = new User(account)
var success = await this.db.insertUser(newUser)
if (success) {
this.clientEmitter(req.user.id, 'user_added', newUser)
res.json({
user: newUser.toJSONForBrowser()
})
} else {
res.json({
error: 'Failed to save new user'
})
}
}
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) {
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') {
return res.sendStatus(500)
}
if (req.user.id === req.params.id) {
Logger.error('Attempting to delete themselves...')
return res.sendStatus(500)
}
var user = this.db.users.find(u => u.id === req.params.id)
if (!user) {
Logger.error('User not found')
return res.json({
error: 'User not found'
})
}
// Todo: check if user is logged in and cancel streams
var userJson = user.toJSONForBrowser()
await this.db.removeEntity('user', user.id)
this.clientEmitter(req.user.id, 'user_removed', userJson)
res.json({
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) {
if (!req.user.canDownload) {
Logger.error('User attempting to download without permission', req.user)
return res.sendStatus(403)
}
var downloadId = req.params.id
Logger.info('Download Request', downloadId)
var download = this.downloadManager.getDownload(downloadId)
if (!download) {
Logger.error('Download request not found', downloadId)
return res.sendStatus(404)
}
var options = {
headers: {
'Content-Type': download.mimeType
}
}
res.download(download.fullPath, download.filename, options, (err) => {
if (err) {
Logger.error('Download Error', err)
}
})
}
getGenres(req, res) {
res.json({
genres: this.db.getGenres()
-211
View File
@@ -1,211 +0,0 @@
const { bytesPretty, elapsedPretty } = require('./utils/fileUtils')
const Book = require('./Book')
const AudioTrack = require('./AudioTrack')
class Audiobook {
constructor(audiobook = null) {
this.id = null
this.path = null
this.fullPath = null
this.addedAt = null
this.tracks = []
this.missingParts = []
this.invalidParts = []
this.audioFiles = []
this.otherFiles = []
this.tags = []
this.book = null
if (audiobook) {
this.construct(audiobook)
}
}
construct(audiobook) {
this.id = audiobook.id
this.path = audiobook.path
this.fullPath = audiobook.fullPath
this.addedAt = audiobook.addedAt
this.tracks = audiobook.tracks.map(track => {
return new AudioTrack(track)
})
this.missingParts = audiobook.missingParts
this.invalidParts = audiobook.invalidParts
this.audioFiles = audiobook.audioFiles
this.otherFiles = audiobook.otherFiles
this.tags = audiobook.tags
if (audiobook.book) {
this.book = new Book(audiobook.book)
}
}
get title() {
return this.book ? this.book.title : 'No Title'
}
get cover() {
return this.book ? this.book.cover : ''
}
get author() {
return this.book ? this.book.author : 'Unknown'
}
get genres() {
return this.book ? this.book.genres || [] : []
}
get totalDuration() {
var total = 0
this.tracks.forEach((track) => total += track.duration)
return total
}
get totalSize() {
var total = 0
this.tracks.forEach((track) => total += track.size)
return total
}
get sizePretty() {
return bytesPretty(this.totalSize)
}
get durationPretty() {
return elapsedPretty(this.totalDuration)
}
bookToJSON() {
return this.book ? this.book.toJSON() : null
}
tracksToJSON() {
if (!this.tracks || !this.tracks.length) return []
return this.tracks.map(t => t.toJSON())
}
toJSON() {
return {
id: this.id,
title: this.title,
author: this.author,
cover: this.cover,
path: this.path,
fullPath: this.fullPath,
addedAt: this.addedAt,
missingParts: this.missingParts,
invalidParts: this.invalidParts,
tags: this.tags,
book: this.bookToJSON(),
tracks: this.tracksToJSON(),
audioFiles: this.audioFiles,
otherFiles: this.otherFiles
}
}
toJSONMinified() {
return {
id: this.id,
book: this.bookToJSON(),
tags: this.tags,
path: this.path,
fullPath: this.fullPath,
addedAt: this.addedAt,
duration: this.totalDuration,
size: this.totalSize,
hasBookMatch: !!this.book,
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
numTracks: this.tracks.length
}
}
toJSONExpanded() {
return {
id: this.id,
title: this.title,
author: this.author,
cover: this.cover,
path: this.path,
fullPath: this.fullPath,
addedAt: this.addedAt,
duration: this.totalDuration,
durationPretty: this.durationPretty,
size: this.totalSize,
sizePretty: this.sizePretty,
missingParts: this.missingParts,
invalidParts: this.invalidParts,
audioFiles: this.audioFiles,
otherFiles: this.otherFiles,
tags: this.tags,
book: this.bookToJSON(),
tracks: this.tracksToJSON()
}
}
setData(data) {
this.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
this.path = data.path
this.fullPath = data.fullPath
this.addedAt = Date.now()
this.otherFiles = data.otherFiles || []
this.setBook(data)
}
setBook(data) {
this.book = new Book()
this.book.setData(data)
}
addTrack(trackData) {
var track = new AudioTrack()
track.setData(trackData)
this.tracks.push(track)
return track
}
update(payload) {
var hasUpdates = false
if (payload.tags && payload.tags.join(',') !== this.tags.join(',')) {
this.tags = payload.tags
hasUpdates = true
}
if (payload.book) {
if (!this.book) {
this.setBook(payload.book)
hasUpdates = true
} else if (this.book.update(payload.book)) {
hasUpdates = true
}
}
return hasUpdates
}
updateAudioTracks(files) {
var index = 1
this.audioFiles = files.map((file) => {
file.manuallyVerified = true
file.invalid = false
file.error = null
file.index = index++
return file
})
this.tracks = []
this.invalidParts = []
this.missingParts = []
this.audioFiles.forEach((file) => {
this.addTrack(file)
})
}
}
module.exports = Audiobook
+51 -47
View File
@@ -2,7 +2,6 @@ const bcrypt = require('bcryptjs')
const jwt = require('jsonwebtoken')
const Logger = require('./Logger')
class Auth {
constructor(db) {
this.db = db
@@ -39,16 +38,28 @@ class Auth {
}
async authMiddleware(req, res, next) {
const authHeader = req.headers['authorization']
const token = authHeader && authHeader.split(' ')[1]
var token = null
// If using a get request, the token can be passed as a query string
if (req.method === 'GET' && req.query && req.query.token) {
token = req.query.token
} else {
const authHeader = req.headers['authorization']
token = authHeader && authHeader.split(' ')[1]
}
if (token == null) {
Logger.error('Api called without a token')
Logger.error('Api called without a token', req.path)
return res.sendStatus(401)
}
var user = await this.verifyToken(token)
if (!user) {
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)
}
req.user = user
@@ -69,12 +80,16 @@ class Auth {
}
generateAccessToken(payload) {
return jwt.sign(payload, process.env.TOKEN_SECRET, { expiresIn: '1800s' });
return jwt.sign(payload, process.env.TOKEN_SECRET);
}
verifyToken(token) {
return new Promise((resolve) => {
jwt.verify(token, process.env.TOKEN_SECRET, (err, payload) => {
if (!payload || err) {
Logger.error('JWT Verify Token Failed', err)
return resolve(null)
}
var user = this.users.find(u => u.id === payload.userId)
resolve(user || null)
})
@@ -86,12 +101,16 @@ class Auth {
var password = req.body.password || ''
Logger.debug('Check Auth', username, !!password)
var user = this.users.find(u => u.id === username)
var user = this.users.find(u => u.username === username)
if (!user) {
return res.json({ error: 'User not found' })
}
if (!user.isActive) {
return res.json({ error: 'User unavailable' })
}
// Check passwordless root user
if (user.id === 'root' && (!user.pash || user.pash === '')) {
if (password) {
@@ -114,65 +133,50 @@ class Auth {
}
}
async checkAuth(req, res) {
var username = req.body.username
Logger.debug('Check Auth', username, !!req.body.password)
comparePassword(password, user) {
if (user.type === 'root' && !password && !user.pash) return true
if (!password || !user.pash) return false
return bcrypt.compare(password, user.pash)
}
var matchingUser = this.users.find(u => u.username === username)
if (!matchingUser) {
async userChangePassword(req, res) {
var { password, newPassword } = req.body
newPassword = newPassword || ''
var matchingUser = this.users.find(u => u.id === req.user.id)
// Only root can have an empty password
if (matchingUser.type !== 'root' && !newPassword) {
return res.json({
error: 'User not found'
error: 'Invalid new password - Only root can have an empty password'
})
}
var cleanedUser = { ...matchingUser }
delete cleanedUser.pash
// check for empty password (default)
if (!req.body.password) {
if (!matchingUser.pash) {
res.cookie('user', username, { signed: true })
return res.json({
user: cleanedUser
})
} else {
return res.json({
error: 'Invalid Password'
})
}
var compare = await this.comparePassword(password, matchingUser)
if (!compare) {
return res.json({
error: 'Invalid password'
})
}
// Set root password first time
if (matchingUser.type === 'root' && !matchingUser.pash && req.body.password && req.body.password.length > 1) {
console.log('Set root pash')
var pw = await this.hashPass(req.body.password)
var pw = ''
if (newPassword) {
pw = await this.hashPass(newPassword)
if (!pw) {
return res.json({
error: 'Hash failed'
})
}
this.users = this.users.map(u => {
if (u.username === matchingUser.username) {
u.pash = pw
}
return u
})
await this.saveAuthDb()
return res.json({
setroot: true,
user: cleanedUser
})
}
var compare = await bcrypt.compare(req.body.password, matchingUser.pash)
if (compare) {
res.cookie('user', username, { signed: true })
matchingUser.pash = pw
var success = await this.db.updateEntity('user', matchingUser)
if (success) {
res.json({
user: cleanedUser
success: true
})
} else {
res.json({
error: 'Invalid Password'
error: 'Unknown error'
})
}
}

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