Compare commits

...

31 Commits

Author SHA1 Message Date
Mark Cooper 4c07f9ec25 Write metadata file option, rate limiting login attempts, generic failed login message 2021-09-29 10:16:38 -05:00
Mark Cooper 0ba38d45bc Readme update 2021-09-28 17:59:44 -05:00
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
58 changed files with 1508 additions and 177 deletions
+2 -1
View File
@@ -10,4 +10,5 @@ npm-debug.log
dev.js dev.js
test/ test/
/client/.nuxt/ /client/.nuxt/
/client/dist/ /client/dist/
/dist/
+2 -1
View File
@@ -7,4 +7,5 @@ node_modules/
/metadata/ /metadata/
test/ test/
/client/.nuxt/ /client/.nuxt/
/client/dist/ /client/dist/
/dist/
+8
View File
@@ -0,0 +1,8 @@
Package: audiobookshelf
Version: 1.2.1
Section: base
Priority: optional
Architecture: amd64
Depends:
Maintainer: advplyr
Description: Self-hosted audiobook server for managing and playing audiobooks
+85
View File
@@ -0,0 +1,85 @@
#!/bin/bash
set -e
set -o pipefail
declare -r init_type='auto'
declare -ri no_rebuild='0'
add_user() {
: "${1:?'User was not defined'}"
declare -r user="$1"
declare -r uid="$2"
if [ -z "$uid" ]; then
declare -r uid_flags=""
else
declare -r uid_flags="--uid $uid"
fi
declare -r group="${3:-$user}"
declare -r descr="${4:-No description}"
declare -r shell="${5:-/bin/false}"
if ! getent passwd | grep -q "^$user:"; then
echo "Creating system user: $user in $group with $descr and shell $shell"
useradd $uid_flags --gid $group --no-create-home --system --shell $shell -c "$descr" $user
fi
}
add_group() {
: "${1:?'Group was not defined'}"
declare -r group="$1"
declare -r gid="$2"
if [ -z "$gid" ]; then
declare -r gid_flags=""
else
declare -r gid_flags="--gid $gid"
fi
if ! getent group | grep -q "^$group:" ; then
echo "Creating system group: $group"
groupadd $gid_flags --system $group
fi
}
start_service () {
: "${1:?'Service name was not defined'}"
declare -r service_name="$1"
if hash systemctl 2> /dev/null; then
if [[ "$init_type" == 'auto' || "$init_type" == 'systemd' ]]; then
{
systemctl enable "$service_name.service" && \
systemctl start "$service_name.service"
} || echo "$service_name could not be registered or started"
fi
elif hash service 2> /dev/null; then
if [[ "$init_type" == 'auto' || "$init_type" == 'upstart' || "$init_type" == 'sysv' ]]; then
service "$service_name" start || echo "$service_name could not be registered or started"
fi
elif hash start 2> /dev/null; then
if [[ "$init_type" == 'auto' || "$init_type" == 'upstart' ]]; then
start "$service_name" || echo "$service_name could not be registered or started"
fi
elif hash update-rc.d 2> /dev/null; then
if [[ "$init_type" == 'auto' || "$init_type" == 'sysv' ]]; then
{
update-rc.d "$service_name" defaults && \
"/etc/init.d/$service_name" start
} || echo "$service_name could not be registered or started"
fi
else
echo 'Your system does not appear to use systemd, Upstart, or System V, so the service could not be started'
fi
}
add_group 'audiobookshelf' ''
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
mkdir -p '/var/log/audiobookshelf'
chown -R 'audiobookshelf:audiobookshelf' '/var/log/audiobookshelf'
chown -R 'audiobookshelf:audiobookshelf' '/usr/share/audiobookshelf'
start_service 'audiobookshelf'
+99
View File
@@ -0,0 +1,99 @@
#!/bin/bash
set -e
set -o pipefail
DEFAULT_AUDIOBOOK_PATH="/usr/share/audiobookshelf/audiobooks"
DEFAULT_DATA_PATH="/usr/share/audiobookshelf"
DEFAULT_PORT=7331
CONFIG_PATH="/etc/default/audiobookshelf"
install_ffmpeg() {
echo "Starting FFMPEG Install"
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz"
TARGET_DIR="/usr/lib/audiobookshelf-ffmpeg/"
if ! cd "$TARGET_DIR"; then
echo "WARNING: can't access working directory ($TARGET_DIR) creating it" >&2
mkdir "$TARGET_DIR"
cd "$TARGET_DIR"
fi
$WGET
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1
rm ffmpeg-git-amd64-static.tar.xz
chown -R 'audiobookshelf:audiobookshelf' "$TARGET_DIR"
echo "Good to go on Ffmpeg... hopefully"
}
should_build_config() {
if [ -f "$CONFIG_PATH" ]; then
echo "You already have a config file. Do you want to use it?"
options=("Yes" "No")
select yn in "${options[@]}"
do
case $yn in
"Yes")
false; return
;;
"No")
true; return
;;
esac
done
else
echo "No existing config found in $CONFIG_PATH"
true; return
fi
}
setup_config() {
if should_build_config; then
echo "Okay, let's setup a new config."
AUDIOBOOK_PATH=""
read -p "
Enter path for your audiobooks [Default: $DEFAULT_AUDIOBOOK_PATH]:" AUDIOBOOK_PATH
if [[ -z "$AUDIOBOOK_PATH" ]]; then
AUDIOBOOK_PATH="$DEFAULT_AUDIOBOOK_PATH"
fi
DATA_PATH=""
read -p "
Enter path for data files, i.e. streams, downloads, database [Default: $DEFAULT_DATA_PATH]:" DATA_PATH
if [[ -z "$DATA_PATH" ]]; then
DATA_PATH="$DEFAULT_DATA_PATH"
fi
PORT=""
read -p "
Port for the web ui [Default: $DEFAULT_PORT]:" PORT
if [[ -z "$PORT" ]]; then
PORT="$DEFAULT_PORT"
fi
config_text="AUDIOBOOK_PATH=$AUDIOBOOK_PATH
METADATA_PATH=$DATA_PATH/metadata
CONFIG_PATH=$DATA_PATH/config
FFMPEG_PATH=/usr/lib/audiobookshelf-ffmpeg/ffmpeg
PORT=$PORT"
echo "$config_text"
echo "$config_text" > /etc/default/audiobookshelf;
echo "Config created"
fi
}
setup_config
install_ffmpeg
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
set -e
set -o pipefail
declare -r init_type='auto'
declare -r service_name='audiobookshelf'
if [[ "$init_type" == 'auto' || "$init_type" == 'systemd' || "$init_type" == 'upstart' || "$init_type" == 'sysv' ]]; then
if hash systemctl 2> /dev/null; then
systemctl disable "$service_name.service" && \
systemctl stop "$service_name.service" || \
echo "$service_name wasn't even running!"
elif hash service 2> /dev/null; then
service "$service_name" stop || echo "$service_name wasn't even running!"
elif hash stop 2> /dev/null; then
stop "$service_name" || echo "$service_name wasn't even running!"
elif hash update-rc.d 2> /dev/null; then
{
update-rc.d "$service_name" remove && \
"/etc/init.d/$service_name" stop
} || "$service_name wasn't even running!"
else
echo "Your system does not appear to use upstart, systemd or sysv, so $service_name could not be stopped"
echo 'Unless these systems were removed since install, no processes have been left running'
fi
fi
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Self-hosted audiobook server for managing and playing audiobooks
Requires=network.target
[Service]
Type=simple
EnvironmentFile=/etc/default/audiobookshelf
WorkingDirectory=/usr/share/audiobookshelf
ExecStart=/usr/share/audiobookshelf/audiobookshelf
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
User=audiobookshelf
PermissionsStartOnly=true
[Install]
WantedBy=multi-user.target
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
set -e
set -o pipefail
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
cd "$SCRIPT_DIR/.."
# Get package version without double quotes
VERSION="$( eval echo $( jq '.version' package.json) )"
DESCRIPTION="$( eval echo $( jq '.description' package.json) )"
OUTPUT_FILE="audiobookshelf_${VERSION}_amd64.deb"
echo ">>> Building Client"
echo "--------------------"
cd client
rm -rf node_modules
npm ci --unsafe-perm=true --allow-root
npm run generate
cd ..
echo ">>> Building Server"
echo "--------------------"
rm -rf node_modules
npm ci --unsafe-perm=true --allow-root
echo ">>> Packaging"
echo "--------------------"
# Create debian control file
mkdir -p dist
cp -R build/debian dist/debian
chmod -R 775 dist/debian
controlfile="Package: audiobookshelf
Version: $VERSION
Section: base
Priority: optional
Architecture: amd64
Depends:
Maintainer: advplyr
Description: $DESCRIPTION"
echo "$controlfile" > dist/debian/DEBIAN/control;
# Package debian
pkg -t node12-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
fakeroot dpkg-deb --build dist/debian
mv dist/debian.deb "dist/$OUTPUT_FILE"
chmod +x "dist/$OUTPUT_FILE"
echo "Finished! Filename: $OUTPUT_FILE"
+19 -1
View File
@@ -9,20 +9,38 @@
height: calc(100% - 64px - 165px); height: calc(100% - 64px - 165px);
max-height: calc(100% - 64px - 165px); max-height: calc(100% - 64px - 165px);
} }
#bookshelf {
height: calc(100% - 40px);
}
/* width */ /* width */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px; width: 8px;
} }
/* ::-webkit-scrollbar:horizontal { */
/* height: 16px; */
/* height: 24px;
} */
/* Track */ /* Track */
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background-color: rgba(0,0,0,0); background-color: rgba(0,0,0,0);
} }
/* ::-webkit-scrollbar-track:horizontal { */
/* background: rgb(149, 119, 90); */
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
/* background: linear-gradient(180deg, rgb(117, 88, 60) 0%, rgb(65, 41, 17) 17%, rgb(71, 43, 15) 88%, rgb(3, 2, 1) 100%);
box-shadow: 2px 14px 8px #111111aa;
} */
/* Handle */ /* Handle */
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #855620; background: #855620;
border-radius: 4px; border-radius: 4px;
} }
/* ::-webkit-scrollbar-thumb:horizontal { */
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
/* box-shadow: 2px 14px 8px #111111aa;
border-radius: 4px;
} */
/* Handle on hover */ /* Handle on hover */
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #704922; background: #704922;
@@ -108,5 +126,5 @@
} }
.box-shadow-side { .box-shadow-side {
box-shadow: 4px 0px 4px #11111166; box-shadow: 5px 0px 5px #11111166;
} }
+18 -10
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="w-full h-16 bg-primary relative"> <div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-30"> <div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-40">
<div class="flex h-full items-center"> <div class="flex h-full items-center">
<img v-if="!showBack" src="/Logo48.png" class="w-12 h-12 mr-4" /> <img v-if="!showBack" src="/Logo48.png" class="w-12 h-12 mr-4" />
<a v-if="showBack" @click="back" class="rounded-full h-12 w-12 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-4 cursor-pointer"> <a v-if="showBack" @click="back" class="rounded-full h-12 w-12 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-4 cursor-pointer">
@@ -37,7 +37,9 @@
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center"> <div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1> <h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
<ui-btn small class="text-sm mx-2" @click="toggleSelectAll">{{ isAllSelected ? 'Select None' : 'Select All' }}</ui-btn> <ui-btn v-show="!isHome" small class="text-sm mx-2" @click="toggleSelectAll"
>{{ isAllSelected ? 'Select None' : 'Select All' }}<span class="pl-2">({{ audiobooksShowing.length }})</span></ui-btn
>
<div class="flex-grow" /> <div class="flex-grow" />
@@ -62,8 +64,11 @@ export default {
} }
}, },
computed: { computed: {
isHome() {
return this.$route.name === 'index'
},
showBack() { showBack() {
return this.$route.name !== 'library-id' return this.$route.name !== 'library-id' && !this.isHome
}, },
user() { user() {
return this.$store.state.user.user return this.$store.state.user.user
@@ -71,6 +76,7 @@ export default {
isRootUser() { isRootUser() {
return this.$store.getters['user/getIsRoot'] return this.$store.getters['user/getIsRoot']
}, },
username() { username() {
return this.user ? this.user.username : 'err' return this.user ? this.user.username : 'err'
}, },
@@ -87,7 +93,11 @@ export default {
return this.$store.state.user.user.audiobooks || {} return this.$store.state.user.user.audiobooks || {}
}, },
audiobooksShowing() { audiobooksShowing() {
return this.$store.getters['audiobooks/getFiltered']() // return this.$store.getters['audiobooks/getFiltered']()
return this.$store.getters['audiobooks/getEntitiesShowing']()
},
selectedSeries() {
return this.$store.state.audiobooks.selectedSeries
}, },
userCanUpdate() { userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate'] return this.$store.getters['user/getUserCanUpdate']
@@ -110,12 +120,10 @@ export default {
} }
}, },
methods: { methods: {
back() { async back() {
if (this.$route.name === 'audiobook-id-edit') { var popped = await this.$store.dispatch('popRoute')
this.$router.push(`/audiobook/${this.$route.params.id}`) var backTo = popped || '/'
} else { this.$router.push(backTo)
this.$router.push('/library')
}
}, },
cancelSelectionMode() { cancelSelectionMode() {
if (this.processingBatchDelete) return if (this.processingBatchDelete) return
+36 -13
View File
@@ -1,5 +1,5 @@
<template> <template>
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-auto relative"> <div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
<!-- Cover size widget --> <!-- Cover size widget -->
<div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-20"> <div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-20">
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent> <div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
@@ -23,15 +23,17 @@
<template v-for="entity in shelf"> <template v-for="entity in shelf">
<cards-group-card v-if="showGroups" :key="entity.id" :width="bookCoverWidth" :group="entity" @click="clickGroup" /> <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-3d :key="entity.id" v-else :width="100" :src="$store.getters['audiobooks/getBookCoverSrc'](entity.book)" /> -->
<cards-book-card v-else :key="entity.id" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" /> <cards-book-card v-else :key="entity.id" :show-volume-number="!!selectedSeries" :width="bookCoverWidth" :user-progress="userAudiobooks[entity.id]" :audiobook="entity" />
</template> </template>
</div> </div>
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" /> <div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
</div> </div>
</template> </template>
<div v-show="!shelves.length" class="w-full py-16 text-center text-xl"> <div v-show="!shelves.length" class="w-full py-16 text-center text-xl">
<div class="py-4">No {{ showGroups ? 'Series' : 'Audiobooks' }}</div> <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-if="!showGroups && (filterBy !== 'all' || keywordFilter)" @click="clearFilter">Clear Filter</ui-btn>
<ui-btn v-else-if="page === 'search'" to="/library">Back to Library</ui-btn>
</div> </div>
</div> </div>
</div> </div>
@@ -41,7 +43,12 @@
export default { export default {
props: { props: {
page: String, page: String,
selectedSeries: String selectedSeries: String,
searchResults: {
type: Array,
default: () => []
},
searchQuery: String
}, },
data() { data() {
return { return {
@@ -51,7 +58,8 @@ export default {
selectedSizeIndex: 3, selectedSizeIndex: 3,
rowPaddingX: 40, rowPaddingX: 40,
keywordFilterTimeout: null, keywordFilterTimeout: null,
scannerParseSubtitle: false scannerParseSubtitle: false,
wrapperClientWidth: 0
} }
}, },
watch: { watch: {
@@ -60,6 +68,13 @@ export default {
}, },
selectedSeries() { selectedSeries() {
this.$nextTick(() => { this.$nextTick(() => {
this.$store.commit('audiobooks/setSelectedSeries', this.selectedSeries)
this.setBookshelfEntities()
})
},
searchResults() {
this.$nextTick(() => {
this.$store.commit('audiobooks/setSearchResults', this.searchResults)
this.setBookshelfEntities() this.setBookshelfEntities()
}) })
} }
@@ -96,11 +111,13 @@ export default {
return this.$store.getters['user/getUserSetting']('filterBy') return this.$store.getters['user/getUserSetting']('filterBy')
}, },
showGroups() { showGroups() {
return this.page !== '' && !this.selectedSeries return this.page !== '' && this.page !== 'search' && !this.selectedSeries
}, },
entities() { entities() {
if (this.page === '') { if (this.page === '') {
return this.$store.getters['audiobooks/getFilteredAndSorted']() return this.$store.getters['audiobooks/getFilteredAndSorted']()
} else if (this.page === 'search') {
return this.searchResults || []
} else { } else {
var seriesGroups = this.$store.getters['audiobooks/getSeriesGroups']() var seriesGroups = this.$store.getters['audiobooks/getSeriesGroups']()
if (this.selectedSeries) { if (this.selectedSeries) {
@@ -115,9 +132,6 @@ export default {
clickGroup(group) { clickGroup(group) {
this.$emit('update:selectedSeries', group.name) this.$emit('update:selectedSeries', group.name)
}, },
changeRotation() {
this.rotation = 'show-right'
},
clearFilter() { clearFilter() {
this.$store.commit('audiobooks/setKeywordFilter', null) this.$store.commit('audiobooks/setKeywordFilter', null)
if (this.filterBy !== 'all') { if (this.filterBy !== 'all') {
@@ -145,7 +159,8 @@ export default {
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth }) this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
}, },
setBookshelfEntities() { setBookshelfEntities() {
var width = Math.max(0, this.$refs.wrapper.clientWidth - this.rowPaddingX * 2) this.wrapperClientWidth = this.$refs.wrapper.clientWidth
var width = Math.max(0, this.wrapperClientWidth - this.rowPaddingX * 2)
var booksPerRow = Math.floor(width / this.bookWidth) var booksPerRow = Math.floor(width / this.bookWidth)
var entities = this.entities var entities = this.entities
@@ -168,6 +183,8 @@ export default {
this.shelves = groups this.shelves = groups
}, },
async init() { async init() {
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize') var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
var sizeIndex = this.availableSizes.findIndex((s) => s === bookshelfCoverSize) var sizeIndex = this.availableSizes.findIndex((s) => s === bookshelfCoverSize)
if (!isNaN(sizeIndex)) this.selectedSizeIndex = sizeIndex if (!isNaN(sizeIndex)) this.selectedSizeIndex = sizeIndex
@@ -178,9 +195,7 @@ export default {
} }
}, },
resize() { resize() {
this.$nextTick(() => { this.$nextTick(this.setBookshelfEntities)
this.setBookshelfEntities()
})
}, },
audiobooksUpdated() { audiobooksUpdated() {
console.log('[AudioBookshelf] Audiobooks Updated') console.log('[AudioBookshelf] Audiobooks Updated')
@@ -202,10 +217,18 @@ export default {
this.$root.socket.emit('scan') this.$root.socket.emit('scan')
} }
}, },
updated() {
if (this.$refs.wrapper) {
if (this.wrapperClientWidth !== this.$refs.wrapper.clientWidth) {
this.$nextTick(this.setBookshelfEntities)
}
}
},
mounted() { mounted() {
window.addEventListener('resize', this.resize) window.addEventListener('resize', this.resize)
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated }) this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated }) this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
this.init() this.init()
}, },
beforeDestroy() { beforeDestroy() {
@@ -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>
+38 -16
View File
@@ -1,21 +1,32 @@
<template> <template>
<div class="w-full h-10 relative"> <div class="w-full h-10 relative">
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8"> <div id="toolbar" class="absolute top-0 left-0 w-full h-full z-40 flex items-center px-8">
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p> <template v-if="page !== 'search' && !isHome">
<div v-else class="flex items-center"> <p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
<div @click="seriesBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer"> <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> <span class="material-icons text-3xl text-white">west</span>
</div> </div>
<!-- <span class="material-icons text-2xl cursor-pointer" @click="seriesBackArrow">west</span> --> <!-- <p class="font-book pl-4">{{ numShowing }} showing</p> -->
<p class="pl-4 font-book text-lg"> <div class="flex-grow" />
{{ selectedSeries }} <span class="ml-3 font-mono text-lg bg-black bg-opacity-30 rounded-lg px-1 py-0.5">{{ numShowing }}</span> <p>Search results for "{{ searchQuery }}"</p>
</p> <div class="flex-grow" />
</div> </template>
<div class="flex-grow" />
<ui-text-input v-show="showSortFilters" 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" />
</div> </div>
</div> </div>
</template> </template>
@@ -24,7 +35,13 @@
export default { export default {
props: { props: {
page: String, page: String,
selectedSeries: String isHome: Boolean,
selectedSeries: String,
searchResults: {
type: Array,
default: () => []
},
searchQuery: String
}, },
data() { data() {
return { return {
@@ -39,6 +56,8 @@ export default {
numShowing() { numShowing() {
if (this.page === '') { if (this.page === '') {
return this.$store.getters['audiobooks/getFiltered']().length return this.$store.getters['audiobooks/getFiltered']().length
} else if (this.page === 'search') {
return (this.searchResults || []).length
} else { } else {
var groups = this.$store.getters['audiobooks/getSeriesGroups']() var groups = this.$store.getters['audiobooks/getSeriesGroups']()
if (this.selectedSeries) { if (this.selectedSeries) {
@@ -65,6 +84,9 @@ export default {
} }
}, },
methods: { methods: {
searchBackArrow() {
this.$router.replace('/library')
},
seriesBackArrow() { seriesBackArrow() {
this.$router.replace('/library/series') this.$router.replace('/library/series')
this.$emit('update:selectedSeries', null) this.$emit('update:selectedSeries', null)
@@ -100,6 +122,6 @@ export default {
<style> <style>
#toolbar { #toolbar {
box-shadow: 0px 8px 8px #111111aa; box-shadow: 0px 8px 6px #111111aa;
} }
</style> </style>
+17 -3
View File
@@ -1,13 +1,24 @@
<template> <template>
<div class="w-20 border-r border-primary bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px"> <div class="w-20 bg-bg h-full relative box-shadow-side z-30" style="min-width: 80px">
<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 === '' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> <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"> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg> </svg>
<p class="font-book pt-1.5" style="font-size: 1rem">Library</p> <p class="font-book pt-1.5" style="font-size: 1rem">Library</p>
<div v-show="paramId === ''" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> <div v-show="paramId === '' && !homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link> </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'"> <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'">
@@ -63,6 +74,9 @@ export default {
}, },
selectedClassName() { selectedClassName() {
return '' return ''
},
homePage() {
return this.$route.name === 'index'
} }
}, },
methods: {}, methods: {},
+19 -7
View File
@@ -8,9 +8,9 @@
<div class="absolute -bottom-4 left-0 triangle-right" /> <div class="absolute -bottom-4 left-0 triangle-right" />
</div> </div>
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard"> <div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @click.stop>
<nuxt-link :to="isSelectionMode ? '' : `/audiobook/${audiobookId}`" class="cursor-pointer"> <nuxt-link :to="isSelectionMode ? '' : `/audiobook/${audiobookId}`" class="cursor-pointer">
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }"> <div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }" @click="clickCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" /> <cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" />
<div v-show="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black rounded" :class="overlayWrapperClasslist"> <div v-show="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black rounded" :class="overlayWrapperClasslist">
@@ -29,7 +29,15 @@
</div> </div>
</div> </div>
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div> <div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
</div>
<!-- <div v-if="true && hasEbook" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">EBook</p>
</div> -->
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0"> <ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0">
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300"> <div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
@@ -56,7 +64,8 @@ export default {
width: { width: {
type: Number, type: Number,
default: 120 default: 120
} },
showVolumeNumber: Boolean
}, },
data() { data() {
return { return {
@@ -73,8 +82,12 @@ export default {
audiobookId() { audiobookId() {
return this.audiobook.id return this.audiobook.id
}, },
hasEbook() {
return this.audiobook.numEbooks
},
isSelectionMode() { isSelectionMode() {
return this.$store.getters['getNumAudiobooksSelected'] // return this.$store.getters['getNumAudiobooksSelected']
return !!this.selectedAudiobooks.length
}, },
selectedAudiobooks() { selectedAudiobooks() {
return this.$store.state.selectedAudiobooks return this.$store.state.selectedAudiobooks
@@ -194,7 +207,6 @@ export default {
this.selectBtnClick() this.selectBtnClick()
} }
} }
}, }
mounted() {}
} }
</script> </script>
+15 -4
View File
@@ -5,13 +5,16 @@
<div class="w-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: height + 'px', width: height + 'px' }"> <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" /> <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'"> <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="truncate font-book" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ groupName }}</p> <p class="font-book" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ groupName }}</p>
</div> </div>
<div class="absolute top-2 right-2 w-7 h-7 rounded-lg bg-black bg-opacity-90 text-gray-300 box-shadow-book flex items-center justify-center border border-white border-opacity-25 pointer-events-none"> <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> <p class="font-book text-xl">{{ bookItems.length }}</p>
</div> </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> </div>
</nuxt-link> </nuxt-link>
</div> </div>
@@ -60,6 +63,15 @@ export default {
bookItems() { bookItems() {
return this._group.books || [] 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() { groupName() {
return this._group.name || 'No Name' return this._group.name || 'No Name'
}, },
@@ -81,7 +93,6 @@ export default {
clickCard() { clickCard() {
this.$emit('click', this.group) this.$emit('click', this.group)
} }
}, }
mounted() {}
} }
</script> </script>
+1 -1
View File
@@ -1,6 +1,6 @@
<template> <template>
<div ref="wrapper" :style="{ height: height + 'px', width: width + 'px' }" class="relative"> <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"> <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> <p :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p>
</div> </div>
</div> </div>
+20 -1
View File
@@ -1,6 +1,8 @@
<template> <template>
<div class="w-64 ml-8 relative"> <div class="w-64 ml-8 relative">
<ui-text-input v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" /> <form @submit.prevent="submitSearch">
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
</form>
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear"> <div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span> <span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
<span v-else class="material-icons" style="font-size: 1.2rem">close</span> <span v-else class="material-icons" style="font-size: 1.2rem">close</span>
@@ -51,6 +53,19 @@ export default {
} }
}, },
methods: { methods: {
submitSearch() {
if (!this.search) return
this.$router.push(`/library/search?query=${this.search}`)
this.search = null
this.items = []
this.showMenu = false
this.$nextTick(() => {
if (this.$refs.input) {
this.$refs.input.blur()
}
})
},
focussed() { focussed() {
this.isFocused = true this.isFocused = true
this.showMenu = true this.showMenu = true
@@ -73,6 +88,10 @@ export default {
return [] return []
}) })
this.isFetching = false this.isFetching = false
if (!this.showMenu) {
return
}
this.items = results.map((res) => { this.items = results.map((res) => {
return { return {
id: res.id, id: res.id,
+23 -4
View File
@@ -1,11 +1,13 @@
<template> <template>
<modals-modal v-model="show" :width="500" :height="'unset'"> <modals-modal v-model="show" :width="500" :height="'unset'">
<div class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 500px"> <div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<template v-for="chap in chapters"> <template v-for="chap in chapters">
<div :key="chap.id" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-bg bg-opacity-80' : 'bg-opacity-20'" @click="clickChapter(chap)"> <div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-bg bg-opacity-80' : 'bg-opacity-20'" @click="clickChapter(chap)">
{{ chap.title }} {{ chap.title }}
<span class="flex-grow" /> <span class="flex-grow" />
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span> <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> </div>
</template> </template>
</div> </div>
@@ -28,6 +30,11 @@ export default {
data() { data() {
return {} return {}
}, },
watch: {
value(newVal) {
this.$nextTick(this.scrollToChapter)
}
},
computed: { computed: {
show: { show: {
get() { get() {
@@ -44,8 +51,20 @@ export default {
methods: { methods: {
clickChapter(chap) { clickChapter(chap) {
this.$emit('select', chap) this.$emit('select', chap)
},
scrollToChapter() {
if (!this.currentChapterId) return
var container = this.$refs.container
if (container) {
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
if (currChapterEl) {
var offsetTop = currChapterEl.offsetTop
var containerHeight = container.clientHeight
container.scrollTo({ top: offsetTop - containerHeight / 2 })
}
}
} }
}, }
mounted() {}
} }
</script> </script>
+28 -1
View File
@@ -55,6 +55,11 @@
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'"> <div class="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"> <div class="flex px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn> <ui-btn v-if="userCanDelete" color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
<ui-tooltip text="(Root User Only) Save a NFO metadata file in your audiobooks directory" class="mx-4">
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
</ui-tooltip>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn type="submit">Submit</ui-btn> <ui-btn type="submit">Submit</ui-btn>
</div> </div>
@@ -87,7 +92,8 @@ export default {
}, },
newTags: [], newTags: [],
resettingProgress: false, resettingProgress: false,
isScrollable: false isScrollable: false,
savingMetadata: false
} }
}, },
watch: { watch: {
@@ -107,6 +113,9 @@ export default {
this.$emit('update:processing', val) this.$emit('update:processing', val)
} }
}, },
isRootUser() {
return this.$store.getters['user/getIsRoot']
},
audiobookId() { audiobookId() {
return this.audiobook ? this.audiobook.id : null return this.audiobook ? this.audiobook.id : null
}, },
@@ -127,6 +136,24 @@ export default {
} }
}, },
methods: { methods: {
saveMetadataComplete(result) {
this.savingMetadata = false
if (result.error) {
this.$toast.error(result.error)
} else if (result.audiobookId) {
var { savedPath } = result
if (!savedPath) {
this.$toast.error(`Failed to save metadata file (${result.audiobookId})`)
} else {
this.$toast.success(`Metadata file saved "${result.audiobookTitle}"`)
}
}
},
saveMetadata() {
this.savingMetadata = true
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
this.$root.socket.emit('save_metadata', this.audiobookId)
},
async submitForm() { async submitForm() {
if (this.isProcessing) { if (this.isProcessing) {
return return
+4 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<input v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none border border-gray-600" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" /> <input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none border border-gray-600" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
</template> </template>
<script> <script>
@@ -53,6 +53,9 @@ export default {
}, },
keyup(e) { keyup(e) {
this.$emit('keyup', e) this.$emit('keyup', e)
},
blur() {
if (this.$refs.input) this.$refs.input.blur()
} }
}, },
mounted() {} mounted() {}
+19
View File
@@ -26,6 +26,9 @@ export default {
if (this.$store.state.selectedAudiobooks) { if (this.$store.state.selectedAudiobooks) {
this.$store.commit('setSelectedAudiobooks', []) this.$store.commit('setSelectedAudiobooks', [])
} }
if (this.$store.state.audiobooks.keywordFilter) {
this.$store.commit('audiobooks/setKeywordFilter', '')
}
} }
}, },
computed: { computed: {
@@ -124,6 +127,21 @@ export default {
this.$store.commit('setScanProgress', progress) this.$store.commit('setScanProgress', progress)
} }
}, },
saveMetadataComplete(result) {
if (result.error) {
this.$toast.error(result.error)
} else if (result.audiobookId) {
var { savedPath } = result
if (!savedPath) {
this.$toast.error(`Failed to save metadata file (${result.audiobookId})`)
} else {
this.$toast.success(`Metadata file saved (${result.audiobookId})`)
}
} else {
var { success, failed } = result
this.$toast.success(`Metadata save complete\n${success} Succeeded\n${failed} Failed`)
}
},
userUpdated(user) { userUpdated(user) {
if (this.$store.state.user.user.id === user.id) { if (this.$store.state.user.user.id === user.id) {
this.$store.commit('user/setUser', user) this.$store.commit('user/setUser', user)
@@ -227,6 +245,7 @@ export default {
this.socket.on('scan_start', this.scanStart) this.socket.on('scan_start', this.scanStart)
this.socket.on('scan_complete', this.scanComplete) this.socket.on('scan_complete', this.scanComplete)
this.socket.on('scan_progress', this.scanProgress) this.socket.on('scan_progress', this.scanProgress)
// this.socket.on('save_metadata_complete', this.saveMetadataComplete)
// Download Listeners // Download Listeners
this.socket.on('download_started', this.downloadStarted) this.socket.on('download_started', this.downloadStarted)
+1 -1
View File
@@ -1,7 +1,7 @@
export default function ({ store, redirect, route, app }) { export default function ({ store, redirect, route, app }) {
// If the user is not authenticated // If the user is not authenticated
if (!store.state.user.user) { if (!store.state.user.user) {
if (route.name === 'batch') return redirect('/login') if (route.name === 'batch' || route.name === 'index') return redirect('/login')
return redirect(`/login?redirect=${route.fullPath}`) 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)
}
}
}
}
}
+4
View File
@@ -40,6 +40,10 @@ module.exports = {
] ]
}, },
router: {
middleware: ['routed']
},
// Global CSS: https://go.nuxtjs.dev/config-css // Global CSS: https://go.nuxtjs.dev/config-css
css: [ css: [
'@/assets/app.css' '@/assets/app.css'
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "1.1.13", "version": "1.2.4",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "1.2.0", "version": "1.2.8",
"description": "Audiobook manager and player", "description": "Audiobook manager and player",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+22
View File
@@ -42,6 +42,11 @@
Missing Missing
</ui-btn> </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-tooltip v-if="userCanUpdate" text="Edit" direction="top">
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" /> <ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
</ui-tooltip> </ui-tooltip>
@@ -86,6 +91,8 @@
<tables-other-files-table v-if="otherFiles.length" :audiobook-id="audiobook.id" :files="otherFiles" class="mt-6" /> <tables-other-files-table v-if="otherFiles.length" :audiobook-id="audiobook.id" :files="otherFiles" class="mt-6" />
</div> </div>
</div> </div>
<div id="area"></div>
</div> </div>
</div> </div>
</template> </template>
@@ -223,6 +230,9 @@ export default {
audioFiles() { audioFiles() {
return this.audiobook.audioFiles || [] return this.audiobook.audioFiles || []
}, },
ebooks() {
return this.audiobook.ebooks
},
description() { description() {
return this.book.description || '' return this.book.description || ''
}, },
@@ -261,6 +271,18 @@ export default {
} }
}, },
methods: { methods: {
openEbook() {
var ebook = this.ebooks[0]
console.log('Ebook', ebook)
this.$axios
.$get(`/ebook/open/${this.audiobookId}/${ebook.ino}`)
.then(() => {
console.log('opened')
})
.catch((error) => {
console.error('failed', error)
})
},
toggleRead() { toggleRead() {
var updatePayload = { var updatePayload = {
isRead: !this.isRead isRead: !this.isRead
+7 -2
View File
@@ -1,5 +1,5 @@
<template> <template>
<div id="page-wrapper" class="page p-6" :class="streamAudiobook ? 'streaming' : ''"> <div id="page-wrapper" class="page p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
<div class="w-full max-w-4xl mx-auto"> <div class="w-full max-w-4xl mx-auto">
<div class="flex items-center mb-2"> <div class="flex items-center mb-2">
<h1 class="text-2xl">Users</h1> <h1 class="text-2xl">Users</h1>
@@ -50,11 +50,13 @@
<div class="w-40 flex flex-col"> <div class="w-40 flex flex-col">
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn> <ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
<div class="w-full"> <div class="w-full mb-4">
<ui-tooltip direction="bottom" text="Only scans audiobooks without a cover. Covers will be applied if a close match is found." class="w-full"> <ui-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-btn color="primary" class="w-full" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
</ui-tooltip> </ui-tooltip>
</div> </div>
<!-- <ui-btn color="primary" small @click="saveMetadataFiles">Save Metadata</ui-btn> -->
</div> </div>
</div> </div>
</div> </div>
@@ -152,6 +154,9 @@ export default {
scanCovers() { scanCovers() {
this.$root.socket.emit('scan_covers') this.$root.socket.emit('scan_covers')
}, },
saveMetadataFiles() {
this.$root.socket.emit('save_metadata')
},
loadUsers() { loadUsers() {
this.$axios this.$axios
.$get('/api/users') .$get('/api/users')
+11 -3
View File
@@ -7,14 +7,22 @@
<!-- <app-book-shelf /> --> <!-- <app-book-shelf /> -->
<!-- </div> --> <!-- </div> -->
<!-- </div> --> <!-- </div> -->
<div class="flex h-full">
<app-side-rail />
<div class="flex-grow">
<app-book-shelf-toolbar is-home />
<app-book-shelf-categorized />
</div>
</div>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
asyncData({ redirect }) { // asyncData({ redirect }) {
redirect('/library') // redirect('/library')
}, // },
data() { data() {
return {} return {}
}, },
+41 -6
View File
@@ -3,8 +3,8 @@
<div class="flex h-full"> <div class="flex h-full">
<app-side-rail /> <app-side-rail />
<div class="flex-grow"> <div class="flex-grow">
<app-book-shelf-toolbar :page="id || ''" :selected-series.sync="selectedSeries" /> <app-book-shelf-toolbar :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" />
<app-book-shelf :page="id || ''" :selected-series.sync="selectedSeries" /> <app-book-shelf :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" />
</div> </div>
</div> </div>
</div> </div>
@@ -12,24 +12,59 @@
<script> <script>
export default { export default {
asyncData({ params, query, store, app }) { async asyncData({ params, query, store, app }) {
if (query.filter) { if (query.filter) {
store.dispatch('user/updateUserSettings', { filterBy: 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 { return {
id: params.id, id: libraryPage,
selectedSeries: query.series ? app.$decode(query.series) : null searchQuery,
searchResults,
selectedSeries
} }
}, },
data() { data() {
return {} return {}
}, },
watch: {
'$route.query'(newVal) {
if (this.id === 'search' && this.$route.query.query) {
if (this.$route.query.query !== this.searchQuery) {
this.newQuery()
}
}
}
},
computed: { computed: {
streamAudiobook() { streamAudiobook() {
return this.$store.state.streamAudiobook return this.$store.state.streamAudiobook
} }
}, },
methods: {}, 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() {} mounted() {}
} }
</script> </script>
+6 -8
View File
@@ -37,7 +37,7 @@ export default {
if (this.$route.query.redirect) { if (this.$route.query.redirect) {
this.$router.replace(this.$route.query.redirect) this.$router.replace(this.$route.query.redirect)
} else { } else {
this.$router.replace('/library') this.$router.replace('/')
} }
} }
} }
@@ -57,15 +57,14 @@ export default {
password: this.password || '' password: this.password || ''
} }
var authRes = await this.$axios.$post('/login', payload).catch((error) => { var authRes = await this.$axios.$post('/login', payload).catch((error) => {
console.error('Failed', error) console.error('Failed', error.response)
if (error.response) this.error = error.response.data
else this.error = 'Unknown Error'
return false return false
}) })
console.log('Auth res', authRes) if (authRes && authRes.error) {
if (!authRes) {
this.error = 'Unknown Failure'
} else if (authRes.error) {
this.error = authRes.error this.error = authRes.error
} else { } else if (authRes) {
this.$store.commit('user/setUser', authRes.user) this.$store.commit('user/setUser', authRes.user)
} }
this.processing = false this.processing = false
@@ -77,7 +76,6 @@ export default {
if (token) { if (token) {
this.processing = true this.processing = true
console.log('Authorize', token)
this.$axios this.$axios
.$post('/api/authorize', null, { .$post('/api/authorize', null, {
headers: { headers: {
+2 -1
View File
@@ -16,6 +16,7 @@ export default function ({ $axios, store }) {
$axios.onError(error => { $axios.onError(error => {
const code = parseInt(error.response && error.response.status) const code = parseInt(error.response && error.response.status)
console.error('Axios error code', code) const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
console.error('Axios error', code, message)
}) })
} }
+1 -1
View File
@@ -5,7 +5,7 @@ function parseSemver(ver) {
if (!ver) return null 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-]+)*))?)$/) 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) { if (groups && groups.length > 6) {
var total = Number(groups[3]) * 100 + Number(groups[4]) * 10 + Number(groups[5]) var total = Number(groups[3]) * 10000 + Number(groups[4]) * 100 + Number(groups[5])
if (isNaN(total)) { if (isNaN(total)) {
console.warn('Invalid version total', groups[3], groups[4], groups[5]) console.warn('Invalid version total', groups[3], groups[4], groups[5])
return null return null
+45 -4
View File
@@ -10,13 +10,32 @@ export const state = () => ({
genres: [...STANDARD_GENRES], genres: [...STANDARD_GENRES],
tags: [], tags: [],
series: [], series: [],
keywordFilter: null keywordFilter: null,
selectedSeries: null,
libraryPage: null,
searchResults: []
}) })
export const getters = { export const getters = {
getAudiobook: (state) => id => { getAudiobook: (state) => id => {
return state.audiobooks.find(ab => ab.id === 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) => () => { getFiltered: (state, getters, rootState, rootGetters) => () => {
var filtered = state.audiobooks var filtered = state.audiobooks
var settings = rootState.user.settings || {} var settings = rootState.user.settings || {}
@@ -69,17 +88,30 @@ export const getters = {
state.audiobooks.forEach((audiobook) => { state.audiobooks.forEach((audiobook) => {
if (audiobook.book && audiobook.book.series) { if (audiobook.book && audiobook.book.series) {
if (series[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) series[audiobook.book.series].books.push(audiobook)
} else { } else {
series[audiobook.book.series] = { series[audiobook.book.series] = {
type: 'series', type: 'series',
name: audiobook.book.series, name: audiobook.book.series || '',
books: [audiobook] books: [audiobook],
lastUpdate: audiobook.book.lastUpdate
} }
} }
} }
}) })
return Object.values(series) var seriesArray = Object.values(series).map((_series) => {
_series.books = sort(_series.books)['asc']((ab) => {
return ab.book && ab.book.volumeNumber && !isNaN(ab.book.volumeNumber) ? Number(ab.book.volumeNumber) : null
})
return _series
})
if (state.keywordFilter) {
const keywordFilter = state.keywordFilter.toLowerCase()
return seriesArray.filter((_series) => _series.name.toLowerCase().includes(keywordFilter))
}
return seriesArray
}, },
getUniqueAuthors: (state) => { getUniqueAuthors: (state) => {
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author) var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
@@ -149,6 +181,15 @@ export const mutations = {
setKeywordFilter(state, val) { setKeywordFilter(state, val) {
state.keywordFilter = val state.keywordFilter = val
}, },
setSelectedSeries(state, val) {
state.selectedSeries = val
},
setLibraryPage(state, val) {
state.libraryPage = val
},
setSearchResults(state, val) {
state.searchResults = val
},
set(state, audiobooks) { set(state, audiobooks) {
// GENRES // GENRES
var genres = [...state.genres] var genres = [...state.genres]
+26 -3
View File
@@ -1,4 +1,5 @@
import { checkForUpdate } from '@/plugins/version' import { checkForUpdate } from '@/plugins/version'
import Vue from 'vue'
export const state = () => ({ export const state = () => ({
versionData: null, versionData: null,
@@ -14,7 +15,9 @@ export const state = () => ({
coverScanProgress: null, coverScanProgress: null,
developerMode: false, developerMode: false,
selectedAudiobooks: [], selectedAudiobooks: [],
processingBatch: false processingBatch: false,
previousPath: '/',
routeHistory: []
}) })
export const getters = { export const getters = {
@@ -51,10 +54,25 @@ export const actions = {
console.error('Update check failed', error) console.error('Update check failed', error)
return false return false
}) })
},
popRoute({ commit, state }) {
if (!state.routeHistory.length) {
return null
}
var _history = [...state.routeHistory]
var last = _history.pop()
commit('setRouteHistory', _history)
return last
} }
} }
export const mutations = { export const mutations = {
setRouteHistory(state, val) {
state.routeHistory = val
},
setPreviousPath(state, val) {
state.previousPath = val
},
setVersionData(state, versionData) { setVersionData(state, versionData) {
state.versionData = versionData state.versionData = versionData
}, },
@@ -112,13 +130,18 @@ export const mutations = {
state.developerMode = val state.developerMode = val
}, },
setSelectedAudiobooks(state, audiobooks) { setSelectedAudiobooks(state, audiobooks) {
state.selectedAudiobooks = audiobooks Vue.set(state, 'selectedAudiobooks', audiobooks)
// state.selectedAudiobooks = audiobooks
}, },
toggleAudiobookSelected(state, audiobookId) { toggleAudiobookSelected(state, audiobookId) {
if (state.selectedAudiobooks.includes(audiobookId)) { if (state.selectedAudiobooks.includes(audiobookId)) {
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId) state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
} else { } else {
state.selectedAudiobooks.push(audiobookId) var newSel = state.selectedAudiobooks.concat([audiobookId])
// state.selectedAudiobooks = newSel
console.log('Setting toggle on sel', newSel)
Vue.set(state, 'selectedAudiobooks', newSel)
// state.selectedAudiobooks.push(audiobookId)
} }
}, },
setProcessingBatch(state, val) { setProcessingBatch(state, val) {
+6
View File
@@ -16,6 +16,12 @@ module.exports = {
height: { height: {
'7.5': '1.75rem' '7.5': '1.75rem'
}, },
spacing: {
'-54': '-13.5rem'
},
rotate: {
'-60': '-60deg'
},
colors: { colors: {
bg: '#373838', bg: '#373838',
primary: '#232323', primary: '#232323',
+6 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "1.1.13", "version": "1.2.7",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@@ -568,6 +568,11 @@
"busboy": "^0.3.1" "busboy": "^0.3.1"
} }
}, },
"express-rate-limit": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.3.0.tgz",
"integrity": "sha512-qJhfEgCnmteSeZAeuOKQ2WEIFTX5ajrzE0xS6gCOBCoRQcU+xEzQmgYQQTpzCcqUAAzTEtu4YEih4pnLfvNtew=="
},
"finalhandler": { "finalhandler": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
+5 -6
View File
@@ -1,17 +1,15 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "1.2.0", "version": "1.2.8",
"description": "Self-hosted audiobook server for managing and playing audiobooks.", "description": "Self-hosted audiobook server for managing and playing audiobooks",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "node index.js", "dev": "node index.js",
"start": "node index.js", "start": "node index.js",
"client": "cd client && npm install && npm run generate", "client": "cd client && npm install && npm run generate",
"prod": "npm run client && npm install && node prod.js", "prod": "npm run client && npm install && node prod.js",
"generate": "cd client && npm run generate", "build-win": "npm run build-prep && pkg -t node12-win-x64 -o ./dist/win/audiobookshelf .",
"build-win": "pkg -t node12-win-x64 -o ./dist/win/audiobookshelf .", "build-linux": "build/linuxpackager"
"build-linuxarm": "pkg -t node12-linux-arm64 -o ./dist/linuxarm/audiobookshelf .",
"build-linuxamd": "pkg -t node12-linux-amd64 -o ./dist/linuxamd/audiobookshelf ."
}, },
"bin": "prod.js", "bin": "prod.js",
"pkg": { "pkg": {
@@ -31,6 +29,7 @@
"cookie-parser": "^1.4.5", "cookie-parser": "^1.4.5",
"express": "^4.17.1", "express": "^4.17.1",
"express-fileupload": "^1.2.1", "express-fileupload": "^1.2.1",
"express-rate-limit": "^5.3.0",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"ip": "^1.1.5", "ip": "^1.1.5",
+65 -3
View File
@@ -2,6 +2,8 @@
AudioBookshelf is a self-hosted audiobook server for managing and playing your audiobooks. AudioBookshelf is a self-hosted audiobook server for managing and playing your audiobooks.
See [Install guides](https://audiobookshelf.org/install) and [documentation](https://audiobookshelf.org/docs)
Android app is in beta, try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app) Android app is in beta, try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
**Free & open source Android/iOS app is in development** **Free & open source Android/iOS app is in development**
@@ -11,6 +13,8 @@ Android app is in beta, try it out on the [Google Play Store](https://play.googl
## Directory Structure ## Directory Structure
See [documentation](https://audiobookshelf.org/docs) for directory structure and naming.
Author, Series, Volume Number, Title and Publish Year can all be parsed from your folder structure. 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 **Note**: Files in the root directory `/audiobooks` will be ignored, all audiobooks should be in a directory
@@ -66,13 +70,71 @@ Will save the title as `Hackers` and the subtitle as `Heroes of the Computer Rev
## Installation ## Installation
Built to run in Docker for now (also on Unraid server Community Apps) ### Docker Install
Available in Unraid Community Apps
```bash ```bash
docker run -d -p 1337:80 -v /audiobooks:/audiobooks -v /config:/config -v /metadata:/metadata --name audiobookshelf --rm advplyr/audiobookshelf docker pull advplyr/audiobookshelf
docker run -d \
-p 1337:80 \
-v </path/to/audiobooks>:/audiobooks \
-v </path/to/config>:/config \
-v </path/to/metadata>:/metadata \
--name audiobookshelf \
--rm advplyr/audiobookshelf
``` ```
## Running on your local ### Linux (amd64) Install
A simple installer is added to setup the initial config. If you already have audiobooks, you can enter the path to your audiobooks during the install. The installer will create a user and group named `audiobookshelf`.
### Ubuntu Install via PPA
A PPA is hosted on [github](https://github.com/advplyr/audiobookshelf-ppa), add and install:
```bash
curl -s --compressed "https://advplyr.github.io/audiobookshelf-ppa/KEY.gpg" | sudo apt-key add -
sudo curl -s --compressed -o /etc/apt/sources.list.d/audiobookshelf.list "https://advplyr.github.io/audiobookshelf-ppa/audiobookshelf.list"
sudo apt update
sudo apt install audiobookshelf
```
or use a single command
```bash
curl -s --compressed "https://advplyr.github.io/audiobookshelf-ppa/KEY.gpg" | sudo apt-key add - && sudo curl -s --compressed -o /etc/apt/sources.list.d/audiobookshelf.list "https://advplyr.github.io/audiobookshelf-ppa/audiobookshelf.list" && sudo apt update && sudo apt install audiobookshelf
```
### Install via debian package
Get the `deb` file from the [github repo](https://github.com/advplyr/audiobookshelf-ppa).
```bash
wget https://advplyr.github.io/audiobookshelf-ppa/audiobookshelf_1.2.7_amd64.deb
sudo apt install ./audiobookshelf_1.2.7_amd64.deb
```
#### File locations
Project directory: `/usr/share/audiobookshelf/`
Config file: `/etc/default/audiobookshelf`
System Service: `/lib/systemd/system/audiobookshelf.service`
Ffmpeg static build: `/usr/lib/audiobookshelf-ffmpeg/`
## Run from source
Note: you will need `npm`, `node12`, and `ffmpeg` to run this project locally
```bash ```bash
git clone https://github.com/advplyr/audiobookshelf.git git clone https://github.com/advplyr/audiobookshelf.git
+22 -10
View File
@@ -103,18 +103,18 @@ class Auth {
var user = this.users.find(u => u.username === username) var user = this.users.find(u => u.username === username)
if (!user) { if (!user || !user.isActive) {
return res.json({ error: 'User not found' }) Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
} if (req.rateLimit.remaining <= 2) {
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
if (!user.isActive) { }
return res.json({ error: 'User unavailable' }) return res.status(401).send('Invalid user or password')
} }
// Check passwordless root user // Check passwordless root user
if (user.id === 'root' && (!user.pash || user.pash === '')) { if (user.id === 'root' && (!user.pash || user.pash === '')) {
if (password) { if (password) {
return res.json({ error: 'Invalid root password (hint: there is none)' }) return res.status(401).send('Invalid root password (hint: there is none)')
} else { } else {
return res.json({ user: user.toJSONForBrowser() }) return res.json({ user: user.toJSONForBrowser() })
} }
@@ -127,12 +127,24 @@ class Auth {
user: user.toJSONForBrowser() user: user.toJSONForBrowser()
}) })
} else { } else {
res.json({ Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
error: 'Invalid Password' if (req.rateLimit.remaining <= 2) {
}) Logger.error(`[Auth] Failed login attempt for user ${user.username}. Attempts: ${req.rateLimit.current}`)
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
}
return res.status(401).send('Invalid user or password')
} }
} }
// Not in use now
lockUser(user) {
user.isLocked = true
return this.db.updateEntity('user', user).catch((error) => {
Logger.error('[Auth] Failed to lock user', user.username, error)
return false
})
}
comparePassword(password, user) { comparePassword(password, user) {
if (user.type === 'root' && !password && !user.pash) return true if (user.type === 'root' && !password && !user.pash) return true
if (!password || !user.pash) return false if (!password || !user.pash) return false
+16 -2
View File
@@ -7,7 +7,7 @@ const Logger = require('./Logger')
const Download = require('./objects/Download') const Download = require('./objects/Download')
const { writeConcatFile, writeMetadataFile } = require('./utils/ffmpegHelpers') const { writeConcatFile, writeMetadataFile } = require('./utils/ffmpegHelpers')
const { getFileSize } = require('./utils/fileUtils') const { getFileSize } = require('./utils/fileUtils')
const TAG = 'DownloadManager'
class DownloadManager { class DownloadManager {
constructor(db, MetadataPath, AudiobookPath, emitter) { constructor(db, MetadataPath, AudiobookPath, emitter) {
this.db = db this.db = db
@@ -260,7 +260,21 @@ class DownloadManager {
output: download.fullPath, output: download.fullPath,
} }
var worker = new workerThreads.Worker('./server/utils/downloadWorker.js', { workerData }) var worker = null
try {
var workerPath = Path.join(global.appRoot, 'server/utils/downloadWorker.js')
worker = new workerThreads.Worker(workerPath, { workerData })
} catch (error) {
Logger.error(`[${TAG}] Start worker thread failed`, error)
if (download.socket) {
var downloadJson = download.toJSON()
download.socket.emit('download_failed', downloadJson)
}
this.removeDownload(download)
return
}
worker.on('message', (message) => { worker.on('message', (message) => {
if (message != null && typeof message === 'object') { if (message != null && typeof message === 'object') {
if (message.type === 'RESULT') { if (message.type === 'RESULT') {
+42
View File
@@ -0,0 +1,42 @@
// const express = require('express')
// const EPub = require('epub')
// const Logger = require('./Logger')
// class EbookReader {
// constructor(db, MetadataPath, AudiobookPath) {
// this.db = db
// this.MetadataPath = MetadataPath
// this.AudiobookPath = AudiobookPath
// this.router = express()
// this.init()
// }
// init() {
// this.router.get('/open/:id/:ino', this.openRequest.bind(this))
// }
// openRequest(req, res) {
// Logger.info('Open request received', req.params)
// var audiobookId = req.params.id
// var fileIno = req.params.ino
// var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
// if (!audiobook) {
// return res.sendStatus(404)
// }
// var ebook = audiobook.ebooks.find(eb => eb.ino === fileIno)
// if (!ebook) {
// Logger.error('Ebook file not found', fileIno)
// return res.sendStatus(404)
// }
// Logger.info('Ebook found', ebook)
// this.open(ebook.fullPath)
// res.sendStatus(200)
// }
// open(path) {
// var epub = new EPub(path)
// console.log('epub', epub)
// }
// }
// module.exports = EbookReader
+76 -40
View File
@@ -13,6 +13,8 @@ class Scanner {
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) { constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) {
this.AudiobookPath = AUDIOBOOK_PATH this.AudiobookPath = AUDIOBOOK_PATH
this.MetadataPath = METADATA_PATH this.MetadataPath = METADATA_PATH
this.BookMetadataPath = Path.join(this.MetadataPath, 'books')
this.db = db this.db = db
this.emitter = emitter this.emitter = emitter
@@ -25,25 +27,6 @@ class Scanner {
return this.db.audiobooks return this.db.audiobooks
} }
async setAudiobookDataInos(audiobookData) {
for (let i = 0; i < audiobookData.length; i++) {
var abd = audiobookData[i]
var matchingAB = this.db.audiobooks.find(_ab => comparePaths(_ab.path, abd.path))
if (matchingAB) {
if (!matchingAB.ino) {
matchingAB.ino = await getIno(matchingAB.fullPath)
}
abd.ino = matchingAB.ino
} else {
abd.ino = await getIno(abd.fullPath)
if (!abd.ino) {
Logger.error('[Scanner] Invalid ino - ignoring audiobook data', abd.path)
}
}
}
return audiobookData.filter(abd => !!abd.ino)
}
async setAudioFileInos(audiobookDataAudioFiles, audiobookAudioFiles) { async setAudioFileInos(audiobookDataAudioFiles, audiobookAudioFiles) {
for (let i = 0; i < audiobookDataAudioFiles.length; i++) { for (let i = 0; i < audiobookDataAudioFiles.length; i++) {
var abdFile = audiobookDataAudioFiles[i] var abdFile = audiobookDataAudioFiles[i]
@@ -68,7 +51,6 @@ class Scanner {
Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`) Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`)
if (existingAudiobook) { if (existingAudiobook) {
// REMOVE: No valid audio files // REMOVE: No valid audio files
// TODO: Label as incomplete, do not actually delete // TODO: Label as incomplete, do not actually delete
if (!audiobookData.audioFiles.length) { if (!audiobookData.audioFiles.length) {
@@ -80,7 +62,10 @@ class Scanner {
return ScanResult.REMOVED return ScanResult.REMOVED
} }
audiobookData.audioFiles = await this.setAudioFileInos(audiobookData.audioFiles, existingAudiobook.audioFiles) // ino is now set for every file in scandir
audiobookData.audioFiles = audiobookData.audioFiles.filter(af => af.ino)
// audiobookData.audioFiles = await this.setAudioFileInos(audiobookData.audioFiles, existingAudiobook.audioFiles)
// Check for audio files that were removed // Check for audio files that were removed
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino) var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
@@ -90,6 +75,14 @@ class Scanner {
removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af)) removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af))
} }
// Check for mismatched audio tracks - tracks with no matching audio file
var removedAudioTracks = existingAudiobook.tracks.filter(track => !abdAudioFileInos.includes(track.ino))
if (removedAudioTracks.length) {
Logger.info(`[Scanner] ${removedAudioTracks.length} tracks removed no matching audio file for audiobook "${existingAudiobook.title}"`)
removedAudioTracks.forEach((at) => existingAudiobook.removeAudioTrack(at))
}
// Check for new audio files and sync existing audio files // Check for new audio files and sync existing audio files
var newAudioFiles = [] var newAudioFiles = []
var hasUpdatedAudioFiles = false var hasUpdatedAudioFiles = false
@@ -100,7 +93,12 @@ class Scanner {
hasUpdatedAudioFiles = true hasUpdatedAudioFiles = true
} }
} else { } else {
newAudioFiles.push(file) var audioFileWithMatchingPath = existingAudiobook.getAudioFileByPath(file.fullPath)
if (audioFileWithMatchingPath) {
Logger.warn(`[Scanner] Audio file with path already exists with different inode, New: "${file.filename}" (${file.ino}) | Existing: ${audioFileWithMatchingPath.filename} (${audioFileWithMatchingPath.ino})`)
} else {
newAudioFiles.push(file)
}
} }
}) })
if (newAudioFiles.length) { if (newAudioFiles.length) {
@@ -119,7 +117,7 @@ class Scanner {
return ScanResult.REMOVED return ScanResult.REMOVED
} }
var hasUpdates = removedAudioFiles.length || newAudioFiles.length || hasUpdatedAudioFiles var hasUpdates = removedAudioFiles.length || removedAudioTracks.length || newAudioFiles.length || hasUpdatedAudioFiles
if (existingAudiobook.checkUpdateMissingParts()) { if (existingAudiobook.checkUpdateMissingParts()) {
Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`) Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`)
@@ -182,27 +180,28 @@ class Scanner {
// TODO: This temporary fix from pre-release should be removed soon, including the "fixRelativePath" and "checkUpdateInos" // TODO: This temporary fix from pre-release should be removed soon, including the "fixRelativePath" and "checkUpdateInos"
// TEMP - fix relative file paths // TEMP - fix relative file paths
// TEMP - update ino for each audiobook // TEMP - update ino for each audiobook
// if (this.audiobooks.length) { if (this.audiobooks.length) {
// for (let i = 0; i < this.audiobooks.length; i++) { for (let i = 0; i < this.audiobooks.length; i++) {
// var ab = this.audiobooks[i] var ab = this.audiobooks[i]
// var shouldUpdate = ab.fixRelativePath(this.AudiobookPath) || !ab.ino // var shouldUpdate = ab.fixRelativePath(this.AudiobookPath) || !ab.ino
// // Update ino if an audio file has the same ino as the audiobook // Update ino if inos are not set
// var shouldUpdateIno = !ab.ino || (ab.audioFiles || []).find(abf => abf.ino === ab.ino) var shouldUpdateIno = ab.hasMissingIno
// if (shouldUpdateIno) { if (shouldUpdateIno) {
// await ab.checkUpdateInos() Logger.debug(`Updating inos for ${ab.title}`)
// } var hasUpdates = await ab.checkUpdateInos()
// if (shouldUpdate) { if (hasUpdates) {
// await this.db.updateAudiobook(ab) await this.db.updateAudiobook(ab)
// } }
// } }
// } }
}
const scanStart = Date.now() const scanStart = Date.now()
var audiobookDataFound = await scanRootDir(this.AudiobookPath, this.db.serverSettings) var audiobookDataFound = await scanRootDir(this.AudiobookPath, this.db.serverSettings)
// Set ino for each ab data as a string // Remove audiobooks with no inode
audiobookDataFound = await this.setAudiobookDataInos(audiobookDataFound) audiobookDataFound = audiobookDataFound.filter(abd => abd.ino)
if (this.cancelScan) { if (this.cancelScan) {
this.cancelScan = false this.cancelScan = false
@@ -318,7 +317,11 @@ class Scanner {
async filesChanged(filepaths) { async filesChanged(filepaths) {
if (!filepaths.length) return ScanResult.NOTHING if (!filepaths.length) return ScanResult.NOTHING
var relfilepaths = filepaths.map(path => path.replace(this.AudiobookPath, '')) var relfilepaths = filepaths.map(path => path.replace(this.AudiobookPath, ''))
var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths) var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths, true)
Logger.debug(`[Scanner] fileGroupings `, filepaths, fileGroupings)
var results = [] var results = []
for (const dir in fileGroupings) { for (const dir in fileGroupings) {
@@ -386,6 +389,39 @@ class Scanner {
} }
} }
async saveMetadata(audiobookId) {
if (audiobookId) {
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
if (!audiobook) {
return {
error: 'Audiobook not found'
}
}
var savedPath = await audiobook.writeNfoFile()
return {
audiobookId,
audiobookTitle: audiobook.title,
savedPath
}
} else {
var response = {
success: 0,
failed: 0
}
for (let i = 0; i < this.db.audiobooks.length; i++) {
var audiobook = this.db.audiobooks[i]
var savedPath = await audiobook.writeNfoFile()
if (savedPath) {
Logger.info(`[Scanner] Saved metadata nfo ${savedPath}`)
response.success++
} else {
response.failed++
}
}
return response
}
}
async find(req, res) { async find(req, res) {
var method = req.params.method var method = req.params.method
var query = req.query var query = req.query
+35 -4
View File
@@ -4,6 +4,7 @@ const http = require('http')
const SocketIO = require('socket.io') const SocketIO = require('socket.io')
const fs = require('fs-extra') const fs = require('fs-extra')
const fileUpload = require('express-fileupload') const fileUpload = require('express-fileupload')
const rateLimit = require('express-rate-limit')
const Auth = require('./Auth') const Auth = require('./Auth')
const Watcher = require('./Watcher') const Watcher = require('./Watcher')
@@ -14,6 +15,7 @@ const HlsController = require('./HlsController')
const StreamManager = require('./StreamManager') const StreamManager = require('./StreamManager')
const RssFeeds = require('./RssFeeds') const RssFeeds = require('./RssFeeds')
const DownloadManager = require('./DownloadManager') const DownloadManager = require('./DownloadManager')
// const EbookReader = require('./EbookReader')
const Logger = require('./Logger') const Logger = require('./Logger')
class Server { class Server {
@@ -37,6 +39,7 @@ class Server {
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this)) this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.emitter.bind(this), this.clientEmitter.bind(this)) this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.emitter.bind(this), this.clientEmitter.bind(this))
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath) this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
// this.ebookReader = new EbookReader(this.db, this.MetadataPath, this.AudiobookPath)
this.server = null this.server = null
this.io = null this.io = null
@@ -108,6 +111,14 @@ class Server {
this.scanner.cancelScan = true this.scanner.cancelScan = true
} }
// Generates an NFO metadata file, if no audiobookId is passed then all audiobooks are done
async saveMetadata(socket, audiobookId = null) {
Logger.info('[Server] Starting save metadata files')
var response = await this.scanner.saveMetadata(audiobookId)
Logger.info(`[Server] Finished saving metadata files Successful: ${response.success}, Failed: ${response.failed}`)
socket.emit('save_metadata_complete', response)
}
async init() { async init() {
Logger.info('[Server] Init') Logger.info('[Server] Init')
await this.streamManager.ensureStreamsDir() await this.streamManager.ensureStreamsDir()
@@ -170,6 +181,21 @@ class Server {
res.sendStatus(200) res.sendStatus(200)
} }
// First time login rate limit is hit
loginLimitReached(req, res, options) {
Logger.error(`[Server] Login rate limit (${options.max}) was hit for ip ${req.ip}`)
options.message = 'Too many attempts. Login temporarily locked.'
}
getLoginRateLimiter() {
return rateLimit({
windowMs: this.db.serverSettings.rateLimitLoginWindow, // 5 minutes
max: this.db.serverSettings.rateLimitLoginRequests,
skipSuccessfulRequests: true,
onLimitReached: this.loginLimitReached
})
}
async start() { async start() {
Logger.info('=== Starting Server ===') Logger.info('=== Starting Server ===')
await this.init() await this.init()
@@ -204,13 +230,18 @@ class Server {
app.use('/api', this.authMiddleware.bind(this), this.apiController.router) app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router) app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
// app.use('/hls', this.hlsController.router)
app.use('/feeds', this.rssFeeds.router) // Incomplete work in progress
// app.use('/ebook', this.ebookReader.router)
// app.use('/feeds', this.rssFeeds.router)
app.post('/upload', this.authMiddleware.bind(this), this.handleUpload.bind(this)) app.post('/upload', this.authMiddleware.bind(this), this.handleUpload.bind(this))
app.post('/login', (req, res) => this.auth.login(req, res)) var loginRateLimiter = this.getLoginRateLimiter()
app.post('/login', loginRateLimiter, (req, res) => this.auth.login(req, res))
app.post('/logout', this.logout.bind(this)) app.post('/logout', this.logout.bind(this))
app.get('/ping', (req, res) => { app.get('/ping', (req, res) => {
Logger.info('Recieved ping') Logger.info('Recieved ping')
res.json({ success: true }) res.json({ success: true })
@@ -229,7 +260,6 @@ class Server {
}) })
} }
this.server.listen(this.Port, this.Host, () => { this.server.listen(this.Port, this.Host, () => {
Logger.info(`Running on http://${this.Host}:${this.Port}`) Logger.info(`Running on http://${this.Host}:${this.Port}`)
}) })
@@ -257,6 +287,7 @@ class Server {
socket.on('scan', this.scan.bind(this)) socket.on('scan', this.scan.bind(this))
socket.on('scan_covers', this.scanCovers.bind(this)) socket.on('scan_covers', this.scanCovers.bind(this))
socket.on('cancel_scan', this.cancelScan.bind(this)) socket.on('cancel_scan', this.cancelScan.bind(this))
socket.on('save_metadata', (audiobookId) => this.saveMetadata(socket, audiobookId))
// Streaming // Streaming
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId)) socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
+2 -1
View File
@@ -65,12 +65,13 @@ class StreamManager {
if (!dirs || !dirs.length) return true if (!dirs || !dirs.length) return true
await Promise.all(dirs.map(async (dirname) => { await Promise.all(dirs.map(async (dirname) => {
if (dirname !== 'streams' && dirname !== 'books') { if (dirname !== 'streams' && dirname !== 'books' && dirname !== 'downloads') {
var fullPath = Path.join(this.MetadataPath, dirname) var fullPath = Path.join(this.MetadataPath, dirname)
Logger.warn(`Removing OLD Orphan Stream ${dirname}`) Logger.warn(`Removing OLD Orphan Stream ${dirname}`)
return fs.remove(fullPath) return fs.remove(fullPath)
} }
})) }))
return true return true
} catch (error) { } catch (error) {
Logger.debug('No old orphan streams', error) Logger.debug('No old orphan streams', error)
+71 -6
View File
@@ -1,6 +1,7 @@
const Path = require('path') const Path = require('path')
const { bytesPretty, elapsedPretty } = require('../utils/fileUtils') const { bytesPretty, elapsedPretty } = require('../utils/fileUtils')
const { comparePaths, getIno } = require('../utils/index') const { comparePaths, getIno } = require('../utils/index')
const nfoGenerator = require('../utils/nfoGenerator')
const Logger = require('../Logger') const Logger = require('../Logger')
const Book = require('./Book') const Book = require('./Book')
const AudioTrack = require('./AudioTrack') const AudioTrack = require('./AudioTrack')
@@ -106,6 +107,14 @@ class Audiobook {
return (this.audioFiles || []).filter(af => af.invalid).map(af => ({ filename: af.filename, error: af.error || 'Unknown Error' })) return (this.audioFiles || []).filter(af => af.invalid).map(af => ({ filename: af.filename, error: af.error || 'Unknown Error' }))
} }
get ebooks() {
return this.otherFiles.filter(file => file.filetype === 'ebook')
}
get hasMissingIno() {
return !this.ino || (this.audioFiles || []).find(abf => !abf.ino) || (this.otherFiles || []).find(f => !f.ino) || (this.tracks || []).find(t => !t.ino)
}
bookToJSON() { bookToJSON() {
return this.book ? this.book.toJSON() : null return this.book ? this.book.toJSON() : null
} }
@@ -152,6 +161,7 @@ class Audiobook {
hasBookMatch: !!this.book, hasBookMatch: !!this.book,
hasMissingParts: this.missingParts ? this.missingParts.length : 0, hasMissingParts: this.missingParts ? this.missingParts.length : 0,
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0, hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
numEbooks: this.ebooks.length,
numTracks: this.tracks.length, numTracks: this.tracks.length,
chapters: this.chapters || [], chapters: this.chapters || [],
isMissing: !!this.isMissing isMissing: !!this.isMissing
@@ -173,6 +183,7 @@ class Audiobook {
invalidParts: this.invalidParts, invalidParts: this.invalidParts,
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()), audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()), otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
ebooks: (this.ebooks || []).map(ebook => ebook.toJSON()),
tags: this.tags, tags: this.tags,
book: this.bookToJSON(), book: this.bookToJSON(),
tracks: this.tracksToJSON(), tracks: this.tracksToJSON(),
@@ -195,7 +206,8 @@ class Audiobook {
return false return false
} }
// Update was made to add ino values, ensure they are set // Originally files did not store the inode value
// this function checks all files and sets the inode
async checkUpdateInos() { async checkUpdateInos() {
var hasUpdates = false var hasUpdates = false
if (!this.ino) { if (!this.ino) {
@@ -204,15 +216,55 @@ class Audiobook {
} }
for (let i = 0; i < this.audioFiles.length; i++) { for (let i = 0; i < this.audioFiles.length; i++) {
var af = this.audioFiles[i] var af = this.audioFiles[i]
var at = this.tracks.find(t => t.ino === af.ino)
if (!at) {
at = this.tracks.find(t => comparePaths(t.path, af.path))
}
if (!af.ino || af.ino === this.ino) { if (!af.ino || af.ino === this.ino) {
af.ino = await getIno(af.fullPath) af.ino = await getIno(af.fullPath)
if (!af.ino) { if (!af.ino) {
Logger.error('[Audiobook] checkUpdateInos: Failed to set ino for audio file', af.fullPath) Logger.error('[Audiobook] checkUpdateInos: Failed to set ino for audio file', af.fullPath)
} else { } else {
var track = this.tracks.find(t => comparePaths(t.path, af.path)) Logger.debug(`[Audiobook] Set INO For audio file ${af.path}`)
if (track) { if (at) at.ino = af.ino
track.ino = af.ino }
hasUpdates = true
} else if (at && at.ino !== af.ino) {
at.ino = af.ino
hasUpdates = true
}
}
for (let i = 0; i < this.tracks.length; i++) {
var at = this.tracks[i]
if (!at.ino) {
Logger.debug(`[Audiobook] Track ${at.filename} still does not have ino`)
var atino = await getIno(at.fullPath)
var af = this.audioFiles.find(_af => _af.ino === atino)
if (!af) {
Logger.debug(`[Audiobook] Track ${at.filename} no matching audio file with ino ${atino}`)
af = this.audioFiles.find(_af => _af.filename === at.filename)
if (!af) {
Logger.debug(`[Audiobook] Track ${at.filename} no matching audio file with filename`)
} else {
Logger.debug(`[Audiobook] Track ${at.filename} found matching filename but mismatch ino ${atino}/${af.ino}`)
// at.ino = af.ino
// at.path = af.path
// at.fullPath = af.fullPath
// hasUpdates = true
} }
} else {
Logger.debug(`[Audiobook] Track ${at.filename} found audio file with matching ino ${at.path}/${af.path}`)
}
}
}
for (let i = 0; i < this.otherFiles.length; i++) {
var file = this.otherFiles[i]
if (!file.ino || file.ino === this.ino) {
file.ino = await getIno(file.fullPath)
if (!file.ino) {
Logger.error('[Audiobook] checkUpdateInos: Failed to set ino for other file', file.fullPath)
} else {
Logger.debug(`[Audiobook] Set INO For other file ${file.path}`)
} }
hasUpdates = true hasUpdates = true
} }
@@ -329,6 +381,11 @@ class Audiobook {
this.audioFiles = this.audioFiles.filter(f => f.ino !== audioFile.ino) this.audioFiles = this.audioFiles.filter(f => f.ino !== audioFile.ino)
} }
removeAudioTrack(track) {
this.tracks = this.tracks.filter(t => t.ino !== track.ino)
this.audioFiles = this.audioFiles.filter(f => f.ino !== track.ino)
}
checkUpdateMissingParts() { checkUpdateMissingParts() {
var currMissingParts = (this.missingParts || []).join(',') || '' var currMissingParts = (this.missingParts || []).join(',') || ''
@@ -363,6 +420,7 @@ class Audiobook {
var newOtherFilePaths = newOtherFiles.map(f => f.path) var newOtherFilePaths = newOtherFiles.map(f => f.path)
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path)) this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
// TODO: Should use inode
newOtherFiles.forEach((file) => { newOtherFiles.forEach((file) => {
var existingOtherFile = this.otherFiles.find(f => f.path === file.path) var existingOtherFile = this.otherFiles.find(f => f.path === file.path)
if (!existingOtherFile) { if (!existingOtherFile) {
@@ -425,9 +483,12 @@ class Audiobook {
return this.audioFiles.find(af => af.ino === ino) return this.audioFiles.find(af => af.ino === ino)
} }
getAudioFileByPath(fullPath) {
return this.audioFiles.find(af => af.fullPath === fullPath)
}
setChapters() { setChapters() {
// If 1 audio file without chapters, then no chapters will be set // If 1 audio file without chapters, then no chapters will be set
var includedAudioFiles = this.audioFiles.filter(af => !af.exclude) var includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
if (includedAudioFiles.length === 1) { if (includedAudioFiles.length === 1) {
// 1 audio file with chapters // 1 audio file with chapters
@@ -463,12 +524,16 @@ class Audiobook {
id: currChapterId++, id: currChapterId++,
start: currStartTime, start: currStartTime,
end: currStartTime + file.duration, end: currStartTime + file.duration,
title: `Chapter ${currChapterId}` title: file.filename ? Path.basename(file.filename, Path.extname(file.filename)) : `Chapter ${currChapterId}`
}) })
currStartTime += file.duration currStartTime += file.duration
} }
}) })
} }
} }
writeNfoFile(nfoFilename = 'metadata.nfo') {
return nfoGenerator(this, nfoFilename)
}
} }
module.exports = Audiobook module.exports = Audiobook
+1
View File
@@ -1,3 +1,4 @@
const fs = require('fs-extra')
const Path = require('path') const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
const parseAuthors = require('../utils/parseAuthors') const parseAuthors = require('../utils/parseAuthors')
+11 -1
View File
@@ -3,10 +3,14 @@ const { CoverDestination } = require('../utils/constants')
class ServerSettings { class ServerSettings {
constructor(settings) { constructor(settings) {
this.id = 'server-settings' this.id = 'server-settings'
this.autoTagNew = false this.autoTagNew = false
this.newTagExpireDays = 15 this.newTagExpireDays = 15
this.scannerParseSubtitle = false this.scannerParseSubtitle = false
this.coverDestination = CoverDestination.METADATA this.coverDestination = CoverDestination.METADATA
this.saveMetadataFile = false
this.rateLimitLoginRequests = 10
this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes
if (settings) { if (settings) {
this.construct(settings) this.construct(settings)
@@ -18,6 +22,9 @@ class ServerSettings {
this.newTagExpireDays = settings.newTagExpireDays this.newTagExpireDays = settings.newTagExpireDays
this.scannerParseSubtitle = settings.scannerParseSubtitle this.scannerParseSubtitle = settings.scannerParseSubtitle
this.coverDestination = settings.coverDestination || CoverDestination.METADATA this.coverDestination = settings.coverDestination || CoverDestination.METADATA
this.saveMetadataFile = !!settings.saveMetadataFile
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
} }
toJSON() { toJSON() {
@@ -26,7 +33,10 @@ class ServerSettings {
autoTagNew: this.autoTagNew, autoTagNew: this.autoTagNew,
newTagExpireDays: this.newTagExpireDays, newTagExpireDays: this.newTagExpireDays,
scannerParseSubtitle: this.scannerParseSubtitle, scannerParseSubtitle: this.scannerParseSubtitle,
coverDestination: this.coverDestination coverDestination: this.coverDestination,
saveMetadataFile: !!this.saveMetadataFile,
rateLimitLoginRequests: this.rateLimitLoginRequests,
rateLimitLoginWindow: this.rateLimitLoginWindow
} }
} }
+5 -1
View File
@@ -9,6 +9,7 @@ class User {
this.stream = null this.stream = null
this.token = null this.token = null
this.isActive = true this.isActive = true
this.isLocked = false
this.createdAt = null this.createdAt = null
this.audiobooks = null this.audiobooks = null
@@ -76,6 +77,7 @@ class User {
token: this.token, token: this.token,
audiobooks: this.audiobooksToJSON(), audiobooks: this.audiobooksToJSON(),
isActive: this.isActive, isActive: this.isActive,
isLocked: this.isLocked,
createdAt: this.createdAt, createdAt: this.createdAt,
settings: this.settings, settings: this.settings,
permissions: this.permissions permissions: this.permissions
@@ -91,6 +93,7 @@ class User {
token: this.token, token: this.token,
audiobooks: this.audiobooksToJSON(), audiobooks: this.audiobooksToJSON(),
isActive: this.isActive, isActive: this.isActive,
isLocked: this.isLocked,
createdAt: this.createdAt, createdAt: this.createdAt,
settings: this.settings, settings: this.settings,
permissions: this.permissions permissions: this.permissions
@@ -112,7 +115,8 @@ class User {
} }
} }
} }
this.isActive = (user.isActive === undefined || user.id === 'root') ? true : !!user.isActive this.isActive = (user.isActive === undefined || user.type === 'root') ? true : !!user.isActive
this.isLocked = user.type === 'root' ? false : !!user.isLocked
this.createdAt = user.createdAt || Date.now() this.createdAt = user.createdAt || Date.now()
this.settings = user.settings || this.getDefaultUserSettings() this.settings = user.settings || this.getDefaultUserSettings()
this.permissions = user.permissions || this.getDefaultUserPermissions() this.permissions = user.permissions || this.getDefaultUserPermissions()
+1 -1
View File
@@ -85,7 +85,7 @@ function getTrackNumberFromFilename(title, author, series, publishYear, filename
async function scanAudioFiles(audiobook, newAudioFiles) { async function scanAudioFiles(audiobook, newAudioFiles) {
if (!newAudioFiles || !newAudioFiles.length) { if (!newAudioFiles || !newAudioFiles.length) {
Logger.error('[AudioFileScanner] Scan Audio Files no files', audiobook.title) Logger.error('[AudioFileScanner] Scan Audio Files no new files', audiobook.title)
return return
} }
var tracks = [] var tracks = []
+2 -1
View File
@@ -29,9 +29,10 @@ function bytesPretty(bytes, decimals = 0) {
return '0 Bytes' return '0 Bytes'
} }
const k = 1024 const k = 1024
const dm = decimals < 0 ? 0 : decimals var dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k)) const i = Math.floor(Math.log(bytes) / Math.log(k))
if (i > 2 && dm === 0) dm = 1
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
} }
module.exports.bytesPretty = bytesPretty module.exports.bytesPretty = bytesPretty
+91
View File
@@ -0,0 +1,91 @@
const fs = require('fs-extra')
const Path = require('path')
const { bytesPretty } = require('./fileUtils')
const Logger = require('../Logger')
const LEFT_COL_LEN = 25
function sectionHeaderLines(title) {
return [title, ''.padEnd(10, '=')]
}
function generateSection(sectionTitle, sectionData) {
var lines = sectionHeaderLines(sectionTitle)
for (const key in sectionData) {
var line = key.padEnd(LEFT_COL_LEN) + (sectionData[key] || '')
lines.push(line)
}
return lines
}
async function generate(audiobook, nfoFilename = 'metadata.nfo') {
var jsonObj = audiobook.toJSON()
var book = jsonObj.book
var generalSectionData = {
'Title': book.title,
'Subtitle': book.subtitle,
'Author': book.author,
'Narrator': book.narrarator,
'Series': book.series,
'Volume Number': book.volumeNumber,
'Publish Year': book.publishYear,
'Genre': book.genres ? book.genres.join(', ') : '',
'Duration': audiobook.durationPretty,
'Chapters': jsonObj.chapters.length
}
if (!book.subtitle) {
delete generalSectionData['Subtitle']
}
if (!book.series) {
delete generalSectionData['Series']
delete generalSectionData['Volume Number']
}
var tracks = audiobook.tracks
var audioTrack = tracks.length ? audiobook.tracks[0] : {}
var totalBitrate = 0
var numBitrates = 0
for (let i = 0; i < tracks.length; i++) {
if (tracks[i].bitRate) {
totalBitrate += tracks[i].bitRate
numBitrates++
}
}
var averageBitrate = numBitrates ? totalBitrate / numBitrates : 0
var mediaSectionData = {
'Tracks': jsonObj.tracks.length,
'Size': audiobook.sizePretty,
'Codec': audioTrack.codec,
'Ext': audioTrack.ext,
'Channels': audioTrack.channels,
'Channel Layout': audioTrack.channelLayout,
'Average Bitrate': bytesPretty(averageBitrate)
}
var bookSection = generateSection('Book Info', generalSectionData)
var descriptionSection = null
if (book.description) {
descriptionSection = sectionHeaderLines('Book Description')
descriptionSection.push(book.description)
}
var mediaSection = generateSection('Media Info', mediaSectionData)
var fullFile = bookSection.join('\n') + '\n\n'
if (descriptionSection) fullFile += descriptionSection.join('\n') + '\n\n'
fullFile += mediaSection.join('\n')
var nfoPath = Path.join(audiobook.fullPath, nfoFilename)
var relativePath = Path.join(audiobook.path, nfoFilename)
return fs.writeFile(nfoPath, fullFile).then(() => relativePath).catch((error) => {
Logger.error(`Failed to write nfo file ${error}`)
return false
})
}
module.exports = generate
+15 -5
View File
@@ -1,6 +1,7 @@
const Path = require('path') const Path = require('path')
const dir = require('node-dir') const dir = require('node-dir')
const Logger = require('../Logger') const Logger = require('../Logger')
const { getIno } = require('./index')
const AUDIO_FORMATS = ['m4b', 'mp3', 'm4a'] const AUDIO_FORMATS = ['m4b', 'mp3', 'm4a']
const INFO_FORMATS = ['nfo'] const INFO_FORMATS = ['nfo']
@@ -26,7 +27,7 @@ function isAudioFile(path) {
return AUDIO_FORMATS.includes(ext.slice(1).toLowerCase()) return AUDIO_FORMATS.includes(ext.slice(1).toLowerCase())
} }
function groupFilesIntoAudiobookPaths(paths) { function groupFilesIntoAudiobookPaths(paths, useAllFileTypes = false) {
// Step 1: Normalize path, Remove leading "/", Filter out files in root dir // Step 1: Normalize path, Remove leading "/", Filter out files in root dir
var pathsFiltered = paths.map(path => Path.normalize(path.slice(1))).filter(path => Path.parse(path).dir) var pathsFiltered = paths.map(path => Path.normalize(path.slice(1))).filter(path => Path.parse(path).dir)
@@ -37,11 +38,11 @@ function groupFilesIntoAudiobookPaths(paths) {
return pathsA - pathsB return pathsA - pathsB
}) })
// Step 2.5: Seperate audio files and other files // Step 2.5: Seperate audio files and other files (optional)
var audioFilePaths = [] var audioFilePaths = []
var otherFilePaths = [] var otherFilePaths = []
pathsFiltered.forEach(path => { pathsFiltered.forEach(path => {
if (isAudioFile(path)) audioFilePaths.push(path) if (isAudioFile(path) || useAllFileTypes) audioFilePaths.push(path)
else otherFilePaths.push(path) else otherFilePaths.push(path)
}) })
@@ -134,7 +135,12 @@ async function scanRootDir(abRootPath, serverSettings = {}) {
var audiobookData = getAudiobookDataFromDir(abRootPath, audiobookPath, parseSubtitle) var audiobookData = getAudiobookDataFromDir(abRootPath, audiobookPath, parseSubtitle)
var fileObjs = cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath]) var fileObjs = cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath])
for (let i = 0; i < fileObjs.length; i++) {
fileObjs[i].ino = await getIno(fileObjs[i].fullPath)
}
var audiobookIno = await getIno(audiobookData.fullPath)
audiobooks.push({ audiobooks.push({
ino: audiobookIno,
...audiobookData, ...audiobookData,
audioFiles: fileObjs.filter(f => f.filetype === 'audio'), audioFiles: fileObjs.filter(f => f.filetype === 'audio'),
otherFiles: fileObjs.filter(f => f.filetype !== 'audio') otherFiles: fileObjs.filter(f => f.filetype !== 'audio')
@@ -241,11 +247,15 @@ async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings =
otherFiles: [] otherFiles: []
} }
filepaths.forEach((filepath) => { for (let i = 0; i < filepaths.length; i++) {
var filepath = filepaths[i]
var relpath = Path.normalize(filepath).replace(abRootPath, '').slice(1) var relpath = Path.normalize(filepath).replace(abRootPath, '').slice(1)
var extname = Path.extname(filepath) var extname = Path.extname(filepath)
var basename = Path.basename(filepath) var basename = Path.basename(filepath)
var ino = await getIno(filepath)
var fileObj = { var fileObj = {
ino,
filetype: getFileType(extname), filetype: getFileType(extname),
filename: basename, filename: basename,
path: relpath, path: relpath,
@@ -257,7 +267,7 @@ async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings =
} else { } else {
audiobook.otherFiles.push(fileObj) audiobook.otherFiles.push(fileObj)
} }
}) }
return audiobook return audiobook
} }
module.exports.getAudiobookFileData = getAudiobookFileData module.exports.getAudiobookFileData = getAudiobookFileData