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
test/
/client/.nuxt/
/client/dist/
/client/dist/
/dist/
+2 -1
View File
@@ -7,4 +7,5 @@ node_modules/
/metadata/
test/
/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);
max-height: calc(100% - 64px - 165px);
}
#bookshelf {
height: calc(100% - 40px);
}
/* width */
::-webkit-scrollbar {
width: 8px;
}
/* ::-webkit-scrollbar:horizontal { */
/* height: 16px; */
/* height: 24px;
} */
/* Track */
::-webkit-scrollbar-track {
background-color: rgba(0,0,0,0);
}
/* ::-webkit-scrollbar-track:horizontal { */
/* background: rgb(149, 119, 90); */
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
/* background: linear-gradient(180deg, rgb(117, 88, 60) 0%, rgb(65, 41, 17) 17%, rgb(71, 43, 15) 88%, rgb(3, 2, 1) 100%);
box-shadow: 2px 14px 8px #111111aa;
} */
/* Handle */
::-webkit-scrollbar-thumb {
background: #855620;
border-radius: 4px;
}
/* ::-webkit-scrollbar-thumb:horizontal { */
/* background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); */
/* box-shadow: 2px 14px 8px #111111aa;
border-radius: 4px;
} */
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
background: #704922;
@@ -108,5 +126,5 @@
}
.box-shadow-side {
box-shadow: 4px 0px 4px #11111166;
box-shadow: 5px 0px 5px #11111166;
}
+18 -10
View File
@@ -1,6 +1,6 @@
<template>
<div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-30">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-40">
<div class="flex h-full items-center">
<img v-if="!showBack" src="/Logo48.png" class="w-12 h-12 mr-4" />
<a v-if="showBack" @click="back" class="rounded-full h-12 w-12 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-4 cursor-pointer">
@@ -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">
<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" />
@@ -62,8 +64,11 @@ export default {
}
},
computed: {
isHome() {
return this.$route.name === 'index'
},
showBack() {
return this.$route.name !== 'library-id'
return this.$route.name !== 'library-id' && !this.isHome
},
user() {
return this.$store.state.user.user
@@ -71,6 +76,7 @@ export default {
isRootUser() {
return this.$store.getters['user/getIsRoot']
},
username() {
return this.user ? this.user.username : 'err'
},
@@ -87,7 +93,11 @@ export default {
return this.$store.state.user.user.audiobooks || {}
},
audiobooksShowing() {
return this.$store.getters['audiobooks/getFiltered']()
// return this.$store.getters['audiobooks/getFiltered']()
return this.$store.getters['audiobooks/getEntitiesShowing']()
},
selectedSeries() {
return this.$store.state.audiobooks.selectedSeries
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
@@ -110,12 +120,10 @@ export default {
}
},
methods: {
back() {
if (this.$route.name === 'audiobook-id-edit') {
this.$router.push(`/audiobook/${this.$route.params.id}`)
} else {
this.$router.push('/library')
}
async back() {
var popped = await this.$store.dispatch('popRoute')
var backTo = popped || '/'
this.$router.push(backTo)
},
cancelSelectionMode() {
if (this.processingBatchDelete) return
+36 -13
View File
@@ -1,5 +1,5 @@
<template>
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-auto relative">
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-scroll relative">
<!-- Cover size widget -->
<div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-20">
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
@@ -23,15 +23,17 @@
<template v-for="entity in shelf">
<cards-group-card v-if="showGroups" :key="entity.id" :width="bookCoverWidth" :group="entity" @click="clickGroup" />
<!-- <cards-book-3d :key="entity.id" v-else :width="100" :src="$store.getters['audiobooks/getBookCoverSrc'](entity.book)" /> -->
<cards-book-card v-else :key="entity.id" :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>
</div>
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
</div>
</template>
<div v-show="!shelves.length" class="w-full py-16 text-center text-xl">
<div 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-else-if="page === 'search'" to="/library">Back to Library</ui-btn>
</div>
</div>
</div>
@@ -41,7 +43,12 @@
export default {
props: {
page: String,
selectedSeries: String
selectedSeries: String,
searchResults: {
type: Array,
default: () => []
},
searchQuery: String
},
data() {
return {
@@ -51,7 +58,8 @@ export default {
selectedSizeIndex: 3,
rowPaddingX: 40,
keywordFilterTimeout: null,
scannerParseSubtitle: false
scannerParseSubtitle: false,
wrapperClientWidth: 0
}
},
watch: {
@@ -60,6 +68,13 @@ export default {
},
selectedSeries() {
this.$nextTick(() => {
this.$store.commit('audiobooks/setSelectedSeries', this.selectedSeries)
this.setBookshelfEntities()
})
},
searchResults() {
this.$nextTick(() => {
this.$store.commit('audiobooks/setSearchResults', this.searchResults)
this.setBookshelfEntities()
})
}
@@ -96,11 +111,13 @@ export default {
return this.$store.getters['user/getUserSetting']('filterBy')
},
showGroups() {
return this.page !== '' && !this.selectedSeries
return this.page !== '' && this.page !== 'search' && !this.selectedSeries
},
entities() {
if (this.page === '') {
return this.$store.getters['audiobooks/getFilteredAndSorted']()
} else if (this.page === 'search') {
return this.searchResults || []
} else {
var seriesGroups = this.$store.getters['audiobooks/getSeriesGroups']()
if (this.selectedSeries) {
@@ -115,9 +132,6 @@ export default {
clickGroup(group) {
this.$emit('update:selectedSeries', group.name)
},
changeRotation() {
this.rotation = 'show-right'
},
clearFilter() {
this.$store.commit('audiobooks/setKeywordFilter', null)
if (this.filterBy !== 'all') {
@@ -145,7 +159,8 @@ export default {
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
},
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 entities = this.entities
@@ -168,6 +183,8 @@ export default {
this.shelves = groups
},
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
@@ -178,9 +195,7 @@ export default {
}
},
resize() {
this.$nextTick(() => {
this.setBookshelfEntities()
})
this.$nextTick(this.setBookshelfEntities)
},
audiobooksUpdated() {
console.log('[AudioBookshelf] Audiobooks Updated')
@@ -202,10 +217,18 @@ export default {
this.$root.socket.emit('scan')
}
},
updated() {
if (this.$refs.wrapper) {
if (this.wrapperClientWidth !== this.$refs.wrapper.clientWidth) {
this.$nextTick(this.setBookshelfEntities)
}
}
},
mounted() {
window.addEventListener('resize', this.resize)
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
this.init()
},
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>
<div class="w-full h-10 relative">
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8">
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
<div v-else class="flex items-center">
<div @click="seriesBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-40 flex items-center px-8">
<template v-if="page !== 'search' && !isHome">
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
<div v-else class="flex items-center">
<div @click="seriesBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
<span class="material-icons text-3xl text-white">west</span>
</div>
<!-- <span class="material-icons text-2xl cursor-pointer" @click="seriesBackArrow">west</span> -->
<p class="pl-4 font-book text-lg">
{{ selectedSeries }} <span class="ml-3 font-mono text-lg bg-black bg-opacity-30 rounded-lg px-1 py-0.5">{{ numShowing }}</span>
</p>
</div>
<div class="flex-grow" />
<ui-text-input v-show="!selectedSeries" v-model="_keywordFilter" placeholder="Keyword Filter" :padding-y="1.5" class="text-xs w-40" />
<controls-filter-select v-show="showSortFilters" v-model="settings.filterBy" class="w-48 h-7.5 ml-4" @change="updateFilter" />
<controls-order-select v-show="showSortFilters" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5 ml-4" @change="updateOrder" />
</template>
<template v-else-if="!isHome">
<div @click="searchBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
<span class="material-icons text-3xl text-white">west</span>
</div>
<!-- <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="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" />
<!-- <p class="font-book pl-4">{{ numShowing }} showing</p> -->
<div class="flex-grow" />
<p>Search results for "{{ searchQuery }}"</p>
<div class="flex-grow" />
</template>
</div>
</div>
</template>
@@ -24,7 +35,13 @@
export default {
props: {
page: String,
selectedSeries: String
isHome: Boolean,
selectedSeries: String,
searchResults: {
type: Array,
default: () => []
},
searchQuery: String
},
data() {
return {
@@ -39,6 +56,8 @@ export default {
numShowing() {
if (this.page === '') {
return this.$store.getters['audiobooks/getFiltered']().length
} else if (this.page === 'search') {
return (this.searchResults || []).length
} else {
var groups = this.$store.getters['audiobooks/getSeriesGroups']()
if (this.selectedSeries) {
@@ -65,6 +84,9 @@ export default {
}
},
methods: {
searchBackArrow() {
this.$router.replace('/library')
},
seriesBackArrow() {
this.$router.replace('/library/series')
this.$emit('update:selectedSeries', null)
@@ -100,6 +122,6 @@ export default {
<style>
#toolbar {
box-shadow: 0px 8px 8px #111111aa;
box-shadow: 0px 8px 6px #111111aa;
}
</style>
+17 -3
View File
@@ -1,13 +1,24 @@
<template>
<div class="w-20 border-r border-primary bg-bg h-full relative box-shadow-side z-40" 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="w-20 bg-bg h-full relative box-shadow-side z-30" style="min-width: 80px">
<div class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
<nuxt-link to="/" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<p class="font-book pt-1.5" style="font-size: 1rem">Home</p>
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link to="/library" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === '' && !homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<p class="font-book pt-1.5" style="font-size: 1rem">Library</p>
<div v-show="paramId === ''" 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 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() {
return ''
},
homePage() {
return this.$route.name === 'index'
}
},
methods: {},
+19 -7
View File
@@ -8,9 +8,9 @@
<div class="absolute -bottom-4 left-0 triangle-right" />
</div>
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard">
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @click.stop>
<nuxt-link :to="isSelectionMode ? '' : `/audiobook/${audiobookId}`" class="cursor-pointer">
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }">
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }" @click="clickCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" />
<div v-show="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black rounded" :class="overlayWrapperClasslist">
@@ -29,7 +29,15 @@
</div>
</div>
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
</div>
<!-- <div v-if="true && hasEbook" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">EBook</p>
</div> -->
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0">
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
@@ -56,7 +64,8 @@ export default {
width: {
type: Number,
default: 120
}
},
showVolumeNumber: Boolean
},
data() {
return {
@@ -73,8 +82,12 @@ export default {
audiobookId() {
return this.audiobook.id
},
hasEbook() {
return this.audiobook.numEbooks
},
isSelectionMode() {
return this.$store.getters['getNumAudiobooksSelected']
// return this.$store.getters['getNumAudiobooksSelected']
return !!this.selectedAudiobooks.length
},
selectedAudiobooks() {
return this.$store.state.selectedAudiobooks
@@ -194,7 +207,6 @@ export default {
this.selectBtnClick()
}
}
},
mounted() {}
}
}
</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' }">
<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'">
<p class="truncate font-book" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ groupName }}</p>
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
<p class="font-book" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ groupName }}</p>
</div>
<div class="absolute top-2 right-2 w-7 h-7 rounded-lg bg-black bg-opacity-90 text-gray-300 box-shadow-book flex items-center justify-center border border-white border-opacity-25 pointer-events-none">
<p class="font-book text-xl">{{ bookItems.length }}</p>
</div>
<div class="absolute bottom-0 left-0 w-full h-1 flex flex-nowrap">
<div v-for="userProgress in userProgressItems" :key="userProgress.audiobookId" class="h-full w-full" :class="userProgress.isRead ? 'bg-success' : userProgress.progress > 0 ? 'bg-yellow-400' : ''" />
</div>
</div>
</nuxt-link>
</div>
@@ -60,6 +63,15 @@ export default {
bookItems() {
return this._group.books || []
},
userAudiobooks() {
return Object.values(this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {})
},
userProgressItems() {
return this.bookItems.map((item) => {
var userAudiobook = this.userAudiobooks.find((ab) => ab.audiobookId === item.id)
return userAudiobook || {}
})
},
groupName() {
return this._group.name || 'No Name'
},
@@ -81,7 +93,6 @@ export default {
clickCard() {
this.$emit('click', this.group)
}
},
mounted() {}
}
}
</script>
+1 -1
View File
@@ -1,6 +1,6 @@
<template>
<div ref="wrapper" :style="{ height: height + 'px', width: width + 'px' }" class="relative">
<div v-if="noValidCovers" class="absolute top-0 left-0 w-full h-full flex items-center justify-center box-shadow-book">
<div v-if="noValidCovers" class="absolute top-0 left-0 w-full h-full flex items-center justify-center box-shadow-book" :style="{ padding: `${sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p>
</div>
</div>
+20 -1
View File
@@ -1,6 +1,8 @@
<template>
<div class="w-64 ml-8 relative">
<ui-text-input v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
<form @submit.prevent="submitSearch">
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
</form>
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
@@ -51,6 +53,19 @@ export default {
}
},
methods: {
submitSearch() {
if (!this.search) return
this.$router.push(`/library/search?query=${this.search}`)
this.search = null
this.items = []
this.showMenu = false
this.$nextTick(() => {
if (this.$refs.input) {
this.$refs.input.blur()
}
})
},
focussed() {
this.isFocused = true
this.showMenu = true
@@ -73,6 +88,10 @@ export default {
return []
})
this.isFetching = false
if (!this.showMenu) {
return
}
this.items = results.map((res) => {
return {
id: res.id,
+23 -4
View File
@@ -1,11 +1,13 @@
<template>
<modals-modal v-model="show" :width="500" :height="'unset'">
<div class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 500px">
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
<template v-for="chap in chapters">
<div :key="chap.id" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg 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 }}
<span class="flex-grow" />
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
</div>
</template>
</div>
@@ -28,6 +30,11 @@ export default {
data() {
return {}
},
watch: {
value(newVal) {
this.$nextTick(this.scrollToChapter)
}
},
computed: {
show: {
get() {
@@ -44,8 +51,20 @@ export default {
methods: {
clickChapter(chap) {
this.$emit('select', chap)
},
scrollToChapter() {
if (!this.currentChapterId) return
var container = this.$refs.container
if (container) {
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
if (currChapterEl) {
var offsetTop = currChapterEl.offsetTop
var containerHeight = container.clientHeight
container.scrollTo({ top: offsetTop - containerHeight / 2 })
}
}
}
},
mounted() {}
}
}
</script>
+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="flex px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
<ui-tooltip text="(Root User Only) Save a NFO metadata file in your audiobooks directory" class="mx-4">
<ui-btn v-if="isRootUser" :loading="savingMetadata" color="bg" type="button" class="h-full" small @click.stop.prevent="saveMetadata">Save Metadata</ui-btn>
</ui-tooltip>
<div class="flex-grow" />
<ui-btn type="submit">Submit</ui-btn>
</div>
@@ -87,7 +92,8 @@ export default {
},
newTags: [],
resettingProgress: false,
isScrollable: false
isScrollable: false,
savingMetadata: false
}
},
watch: {
@@ -107,6 +113,9 @@ export default {
this.$emit('update:processing', val)
}
},
isRootUser() {
return this.$store.getters['user/getIsRoot']
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
@@ -127,6 +136,24 @@ export default {
}
},
methods: {
saveMetadataComplete(result) {
this.savingMetadata = false
if (result.error) {
this.$toast.error(result.error)
} else if (result.audiobookId) {
var { savedPath } = result
if (!savedPath) {
this.$toast.error(`Failed to save metadata file (${result.audiobookId})`)
} else {
this.$toast.success(`Metadata file saved "${result.audiobookTitle}"`)
}
}
},
saveMetadata() {
this.savingMetadata = true
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
this.$root.socket.emit('save_metadata', this.audiobookId)
},
async submitForm() {
if (this.isProcessing) {
return
+4 -1
View File
@@ -1,5 +1,5 @@
<template>
<input v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none border border-gray-600" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<input ref="input" v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none border border-gray-600" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
</template>
<script>
@@ -53,6 +53,9 @@ export default {
},
keyup(e) {
this.$emit('keyup', e)
},
blur() {
if (this.$refs.input) this.$refs.input.blur()
}
},
mounted() {}
+19
View File
@@ -26,6 +26,9 @@ export default {
if (this.$store.state.selectedAudiobooks) {
this.$store.commit('setSelectedAudiobooks', [])
}
if (this.$store.state.audiobooks.keywordFilter) {
this.$store.commit('audiobooks/setKeywordFilter', '')
}
}
},
computed: {
@@ -124,6 +127,21 @@ export default {
this.$store.commit('setScanProgress', progress)
}
},
saveMetadataComplete(result) {
if (result.error) {
this.$toast.error(result.error)
} else if (result.audiobookId) {
var { savedPath } = result
if (!savedPath) {
this.$toast.error(`Failed to save metadata file (${result.audiobookId})`)
} else {
this.$toast.success(`Metadata file saved (${result.audiobookId})`)
}
} else {
var { success, failed } = result
this.$toast.success(`Metadata save complete\n${success} Succeeded\n${failed} Failed`)
}
},
userUpdated(user) {
if (this.$store.state.user.user.id === user.id) {
this.$store.commit('user/setUser', user)
@@ -227,6 +245,7 @@ export default {
this.socket.on('scan_start', this.scanStart)
this.socket.on('scan_complete', this.scanComplete)
this.socket.on('scan_progress', this.scanProgress)
// this.socket.on('save_metadata_complete', this.saveMetadataComplete)
// Download Listeners
this.socket.on('download_started', this.downloadStarted)
+1 -1
View File
@@ -1,7 +1,7 @@
export default function ({ store, redirect, route, app }) {
// If the user is not authenticated
if (!store.state.user.user) {
if (route.name === 'batch') return redirect('/login')
if (route.name === 'batch' || route.name === 'index') return redirect('/login')
return redirect(`/login?redirect=${route.fullPath}`)
}
}
+19
View File
@@ -0,0 +1,19 @@
export default function (context) {
if (process.client) {
var route = context.route
var from = context.from
var store = context.store
if (route.name === 'login' || from.name === 'login') return
if (route.name === 'config' || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id')) {
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && from.name !== 'config' && from.name !== 'upload' && from.name !== 'account') {
var _history = [...store.state.routeHistory]
if (!_history.length || _history[_history.length - 1] !== from.fullPath) {
_history.push(from.fullPath)
store.commit('setRouteHistory', _history)
}
}
}
}
}
+4
View File
@@ -40,6 +40,10 @@ module.exports = {
]
},
router: {
middleware: ['routed']
},
// Global CSS: https://go.nuxtjs.dev/config-css
css: [
'@/assets/app.css'
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "1.1.13",
"version": "1.2.4",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "1.2.0",
"version": "1.2.8",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {
+22
View File
@@ -42,6 +42,11 @@
Missing
</ui-btn>
<!-- <ui-btn v-if="ebooks.length" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
Read
</ui-btn> -->
<ui-tooltip v-if="userCanUpdate" text="Edit" direction="top">
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
</ui-tooltip>
@@ -86,6 +91,8 @@
<tables-other-files-table v-if="otherFiles.length" :audiobook-id="audiobook.id" :files="otherFiles" class="mt-6" />
</div>
</div>
<div id="area"></div>
</div>
</div>
</template>
@@ -223,6 +230,9 @@ export default {
audioFiles() {
return this.audiobook.audioFiles || []
},
ebooks() {
return this.audiobook.ebooks
},
description() {
return this.book.description || ''
},
@@ -261,6 +271,18 @@ export default {
}
},
methods: {
openEbook() {
var ebook = this.ebooks[0]
console.log('Ebook', ebook)
this.$axios
.$get(`/ebook/open/${this.audiobookId}/${ebook.ino}`)
.then(() => {
console.log('opened')
})
.catch((error) => {
console.error('failed', error)
})
},
toggleRead() {
var updatePayload = {
isRead: !this.isRead
+7 -2
View File
@@ -1,5 +1,5 @@
<template>
<div id="page-wrapper" class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
<div id="page-wrapper" class="page p-6 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
<div class="w-full max-w-4xl mx-auto">
<div class="flex items-center mb-2">
<h1 class="text-2xl">Users</h1>
@@ -50,11 +50,13 @@
<div class="w-40 flex flex-col">
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
<div class="w-full">
<div class="w-full mb-4">
<ui-tooltip direction="bottom" text="Only scans audiobooks without a cover. Covers will be applied if a close match is found." class="w-full">
<ui-btn color="primary" class="w-full" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
</ui-tooltip>
</div>
<!-- <ui-btn color="primary" small @click="saveMetadataFiles">Save Metadata</ui-btn> -->
</div>
</div>
</div>
@@ -152,6 +154,9 @@ export default {
scanCovers() {
this.$root.socket.emit('scan_covers')
},
saveMetadataFiles() {
this.$root.socket.emit('save_metadata')
},
loadUsers() {
this.$axios
.$get('/api/users')
+11 -3
View File
@@ -7,14 +7,22 @@
<!-- <app-book-shelf /> -->
<!-- </div> -->
<!-- </div> -->
<div class="flex h-full">
<app-side-rail />
<div class="flex-grow">
<app-book-shelf-toolbar is-home />
<app-book-shelf-categorized />
</div>
</div>
</div>
</template>
<script>
export default {
asyncData({ redirect }) {
redirect('/library')
},
// asyncData({ redirect }) {
// redirect('/library')
// },
data() {
return {}
},
+41 -6
View File
@@ -3,8 +3,8 @@
<div class="flex h-full">
<app-side-rail />
<div class="flex-grow">
<app-book-shelf-toolbar :page="id || ''" :selected-series.sync="selectedSeries" />
<app-book-shelf :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 || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" />
</div>
</div>
</div>
@@ -12,24 +12,59 @@
<script>
export default {
asyncData({ params, query, store, app }) {
async asyncData({ params, query, store, app }) {
if (query.filter) {
store.dispatch('user/updateUserSettings', { filterBy: query.filter })
}
var searchResults = []
var searchQuery = null
if (params.id === 'search' && query.query) {
searchQuery = query.query
searchResults = await app.$axios.$get(`/api/audiobooks?q=${query.query}`).catch((error) => {
console.error('Search error', error)
return []
})
store.commit('audiobooks/setSearchResults', searchResults)
}
var selectedSeries = query.series ? app.$decode(query.series) : null
store.commit('audiobooks/setSelectedSeries', selectedSeries)
var libraryPage = params.id || ''
store.commit('audiobooks/setLibraryPage', libraryPage)
return {
id: params.id,
selectedSeries: query.series ? app.$decode(query.series) : null
id: libraryPage,
searchQuery,
searchResults,
selectedSeries
}
},
data() {
return {}
},
watch: {
'$route.query'(newVal) {
if (this.id === 'search' && this.$route.query.query) {
if (this.$route.query.query !== this.searchQuery) {
this.newQuery()
}
}
}
},
computed: {
streamAudiobook() {
return this.$store.state.streamAudiobook
}
},
methods: {},
methods: {
async newQuery() {
var query = this.$route.query.query
this.searchResults = await this.$axios.$get(`/api/audiobooks?q=${query}`).catch((error) => {
console.error('Search error', error)
return []
})
this.searchQuery = query
}
},
mounted() {}
}
</script>
+6 -8
View File
@@ -37,7 +37,7 @@ export default {
if (this.$route.query.redirect) {
this.$router.replace(this.$route.query.redirect)
} else {
this.$router.replace('/library')
this.$router.replace('/')
}
}
}
@@ -57,15 +57,14 @@ export default {
password: this.password || ''
}
var authRes = await this.$axios.$post('/login', payload).catch((error) => {
console.error('Failed', error)
console.error('Failed', error.response)
if (error.response) this.error = error.response.data
else this.error = 'Unknown Error'
return false
})
console.log('Auth res', authRes)
if (!authRes) {
this.error = 'Unknown Failure'
} else if (authRes.error) {
if (authRes && authRes.error) {
this.error = authRes.error
} else {
} else if (authRes) {
this.$store.commit('user/setUser', authRes.user)
}
this.processing = false
@@ -77,7 +76,6 @@ export default {
if (token) {
this.processing = true
console.log('Authorize', token)
this.$axios
.$post('/api/authorize', null, {
headers: {
+2 -1
View File
@@ -16,6 +16,7 @@ export default function ({ $axios, store }) {
$axios.onError(error => {
const code = parseInt(error.response && error.response.status)
console.error('Axios error code', code)
const message = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
console.error('Axios error', code, message)
})
}
+1 -1
View File
@@ -5,7 +5,7 @@ function parseSemver(ver) {
if (!ver) return null
var groups = ver.match(/^v((([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)$/)
if (groups && groups.length > 6) {
var total = Number(groups[3]) * 100 + Number(groups[4]) * 10 + Number(groups[5])
var total = Number(groups[3]) * 10000 + Number(groups[4]) * 100 + Number(groups[5])
if (isNaN(total)) {
console.warn('Invalid version total', groups[3], groups[4], groups[5])
return null
+45 -4
View File
@@ -10,13 +10,32 @@ export const state = () => ({
genres: [...STANDARD_GENRES],
tags: [],
series: [],
keywordFilter: null
keywordFilter: null,
selectedSeries: null,
libraryPage: null,
searchResults: []
})
export const getters = {
getAudiobook: (state) => id => {
return state.audiobooks.find(ab => ab.id === id)
},
getEntitiesShowing: (state, getters, rootState, rootGetters) => () => {
if (!state.libraryPage) {
return getters.getFiltered()
} else if (state.libraryPage === 'search') {
return state.searchResults
} else if (state.libraryPage === 'series') {
var series = getters.getSeriesGroups()
if (state.selectedSeries) {
var _series = series.find(__series => __series.name === state.selectedSeries)
if (!_series) return []
return _series.books || []
}
return series
}
return []
},
getFiltered: (state, getters, rootState, rootGetters) => () => {
var filtered = state.audiobooks
var settings = rootState.user.settings || {}
@@ -69,17 +88,30 @@ export const getters = {
state.audiobooks.forEach((audiobook) => {
if (audiobook.book && audiobook.book.series) {
if (series[audiobook.book.series]) {
var bookLastUpdate = audiobook.book.lastUpdate
if (bookLastUpdate > series[audiobook.book.series].lastUpdate) series[audiobook.book.series].lastUpdate = bookLastUpdate
series[audiobook.book.series].books.push(audiobook)
} else {
series[audiobook.book.series] = {
type: 'series',
name: audiobook.book.series,
books: [audiobook]
name: audiobook.book.series || '',
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) => {
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) {
state.keywordFilter = val
},
setSelectedSeries(state, val) {
state.selectedSeries = val
},
setLibraryPage(state, val) {
state.libraryPage = val
},
setSearchResults(state, val) {
state.searchResults = val
},
set(state, audiobooks) {
// GENRES
var genres = [...state.genres]
+26 -3
View File
@@ -1,4 +1,5 @@
import { checkForUpdate } from '@/plugins/version'
import Vue from 'vue'
export const state = () => ({
versionData: null,
@@ -14,7 +15,9 @@ export const state = () => ({
coverScanProgress: null,
developerMode: false,
selectedAudiobooks: [],
processingBatch: false
processingBatch: false,
previousPath: '/',
routeHistory: []
})
export const getters = {
@@ -51,10 +54,25 @@ export const actions = {
console.error('Update check failed', error)
return false
})
},
popRoute({ commit, state }) {
if (!state.routeHistory.length) {
return null
}
var _history = [...state.routeHistory]
var last = _history.pop()
commit('setRouteHistory', _history)
return last
}
}
export const mutations = {
setRouteHistory(state, val) {
state.routeHistory = val
},
setPreviousPath(state, val) {
state.previousPath = val
},
setVersionData(state, versionData) {
state.versionData = versionData
},
@@ -112,13 +130,18 @@ export const mutations = {
state.developerMode = val
},
setSelectedAudiobooks(state, audiobooks) {
state.selectedAudiobooks = audiobooks
Vue.set(state, 'selectedAudiobooks', audiobooks)
// state.selectedAudiobooks = audiobooks
},
toggleAudiobookSelected(state, audiobookId) {
if (state.selectedAudiobooks.includes(audiobookId)) {
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
} else {
state.selectedAudiobooks.push(audiobookId)
var newSel = state.selectedAudiobooks.concat([audiobookId])
// state.selectedAudiobooks = newSel
console.log('Setting toggle on sel', newSel)
Vue.set(state, 'selectedAudiobooks', newSel)
// state.selectedAudiobooks.push(audiobookId)
}
},
setProcessingBatch(state, val) {
+6
View File
@@ -16,6 +16,12 @@ module.exports = {
height: {
'7.5': '1.75rem'
},
spacing: {
'-54': '-13.5rem'
},
rotate: {
'-60': '-60deg'
},
colors: {
bg: '#373838',
primary: '#232323',
+6 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "1.1.13",
"version": "1.2.7",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -568,6 +568,11 @@
"busboy": "^0.3.1"
}
},
"express-rate-limit": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.3.0.tgz",
"integrity": "sha512-qJhfEgCnmteSeZAeuOKQ2WEIFTX5ajrzE0xS6gCOBCoRQcU+xEzQmgYQQTpzCcqUAAzTEtu4YEih4pnLfvNtew=="
},
"finalhandler": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
+5 -6
View File
@@ -1,17 +1,15 @@
{
"name": "audiobookshelf",
"version": "1.2.0",
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
"version": "1.2.8",
"description": "Self-hosted audiobook server for managing and playing audiobooks",
"main": "index.js",
"scripts": {
"dev": "node index.js",
"start": "node index.js",
"client": "cd client && npm install && npm run generate",
"prod": "npm run client && npm install && node prod.js",
"generate": "cd client && npm run generate",
"build-win": "pkg -t node12-win-x64 -o ./dist/win/audiobookshelf .",
"build-linuxarm": "pkg -t node12-linux-arm64 -o ./dist/linuxarm/audiobookshelf .",
"build-linuxamd": "pkg -t node12-linux-amd64 -o ./dist/linuxamd/audiobookshelf ."
"build-win": "npm run build-prep && pkg -t node12-win-x64 -o ./dist/win/audiobookshelf .",
"build-linux": "build/linuxpackager"
},
"bin": "prod.js",
"pkg": {
@@ -31,6 +29,7 @@
"cookie-parser": "^1.4.5",
"express": "^4.17.1",
"express-fileupload": "^1.2.1",
"express-rate-limit": "^5.3.0",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^10.0.0",
"ip": "^1.1.5",
+65 -3
View File
@@ -2,6 +2,8 @@
AudioBookshelf is a self-hosted audiobook server for managing and playing your audiobooks.
See [Install guides](https://audiobookshelf.org/install) and [documentation](https://audiobookshelf.org/docs)
Android app is in beta, try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
**Free & open source Android/iOS app is in development**
@@ -11,6 +13,8 @@ Android app is in beta, try it out on the [Google Play Store](https://play.googl
## 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.
**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
Built to run in Docker for now (also on Unraid server Community Apps)
### Docker Install
Available in Unraid Community Apps
```bash
docker run -d -p 1337:80 -v /audiobooks:/audiobooks -v /config:/config -v /metadata:/metadata --name audiobookshelf --rm advplyr/audiobookshelf
docker pull advplyr/audiobookshelf
docker run -d \
-p 1337:80 \
-v </path/to/audiobooks>:/audiobooks \
-v </path/to/config>:/config \
-v </path/to/metadata>:/metadata \
--name audiobookshelf \
--rm advplyr/audiobookshelf
```
## 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
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)
if (!user) {
return res.json({ error: 'User not found' })
}
if (!user.isActive) {
return res.json({ error: 'User unavailable' })
if (!user || !user.isActive) {
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
if (req.rateLimit.remaining <= 2) {
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
}
return res.status(401).send('Invalid user or password')
}
// Check passwordless root user
if (user.id === 'root' && (!user.pash || user.pash === '')) {
if (password) {
return res.json({ error: 'Invalid root password (hint: there is none)' })
return res.status(401).send('Invalid root password (hint: there is none)')
} else {
return res.json({ user: user.toJSONForBrowser() })
}
@@ -127,12 +127,24 @@ class Auth {
user: user.toJSONForBrowser()
})
} else {
res.json({
error: 'Invalid Password'
})
Logger.debug(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit}`)
if (req.rateLimit.remaining <= 2) {
Logger.error(`[Auth] Failed login attempt for user ${user.username}. Attempts: ${req.rateLimit.current}`)
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
}
return res.status(401).send('Invalid user or password')
}
}
// Not in use now
lockUser(user) {
user.isLocked = true
return this.db.updateEntity('user', user).catch((error) => {
Logger.error('[Auth] Failed to lock user', user.username, error)
return false
})
}
comparePassword(password, user) {
if (user.type === 'root' && !password && !user.pash) return true
if (!password || !user.pash) return false
+16 -2
View File
@@ -7,7 +7,7 @@ const Logger = require('./Logger')
const Download = require('./objects/Download')
const { writeConcatFile, writeMetadataFile } = require('./utils/ffmpegHelpers')
const { getFileSize } = require('./utils/fileUtils')
const TAG = 'DownloadManager'
class DownloadManager {
constructor(db, MetadataPath, AudiobookPath, emitter) {
this.db = db
@@ -260,7 +260,21 @@ class DownloadManager {
output: download.fullPath,
}
var worker = new workerThreads.Worker('./server/utils/downloadWorker.js', { workerData })
var worker = null
try {
var workerPath = Path.join(global.appRoot, 'server/utils/downloadWorker.js')
worker = new workerThreads.Worker(workerPath, { workerData })
} catch (error) {
Logger.error(`[${TAG}] Start worker thread failed`, error)
if (download.socket) {
var downloadJson = download.toJSON()
download.socket.emit('download_failed', downloadJson)
}
this.removeDownload(download)
return
}
worker.on('message', (message) => {
if (message != null && typeof message === 'object') {
if (message.type === 'RESULT') {
+42
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) {
this.AudiobookPath = AUDIOBOOK_PATH
this.MetadataPath = METADATA_PATH
this.BookMetadataPath = Path.join(this.MetadataPath, 'books')
this.db = db
this.emitter = emitter
@@ -25,25 +27,6 @@ class Scanner {
return this.db.audiobooks
}
async setAudiobookDataInos(audiobookData) {
for (let i = 0; i < audiobookData.length; i++) {
var abd = audiobookData[i]
var matchingAB = this.db.audiobooks.find(_ab => comparePaths(_ab.path, abd.path))
if (matchingAB) {
if (!matchingAB.ino) {
matchingAB.ino = await getIno(matchingAB.fullPath)
}
abd.ino = matchingAB.ino
} else {
abd.ino = await getIno(abd.fullPath)
if (!abd.ino) {
Logger.error('[Scanner] Invalid ino - ignoring audiobook data', abd.path)
}
}
}
return audiobookData.filter(abd => !!abd.ino)
}
async setAudioFileInos(audiobookDataAudioFiles, audiobookAudioFiles) {
for (let i = 0; i < audiobookDataAudioFiles.length; i++) {
var abdFile = audiobookDataAudioFiles[i]
@@ -68,7 +51,6 @@ class Scanner {
Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`)
if (existingAudiobook) {
// REMOVE: No valid audio files
// TODO: Label as incomplete, do not actually delete
if (!audiobookData.audioFiles.length) {
@@ -80,7 +62,10 @@ class Scanner {
return ScanResult.REMOVED
}
audiobookData.audioFiles = await this.setAudioFileInos(audiobookData.audioFiles, existingAudiobook.audioFiles)
// ino is now set for every file in scandir
audiobookData.audioFiles = audiobookData.audioFiles.filter(af => af.ino)
// audiobookData.audioFiles = await this.setAudioFileInos(audiobookData.audioFiles, existingAudiobook.audioFiles)
// Check for audio files that were removed
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
@@ -90,6 +75,14 @@ class Scanner {
removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af))
}
// Check for mismatched audio tracks - tracks with no matching audio file
var removedAudioTracks = existingAudiobook.tracks.filter(track => !abdAudioFileInos.includes(track.ino))
if (removedAudioTracks.length) {
Logger.info(`[Scanner] ${removedAudioTracks.length} tracks removed no matching audio file for audiobook "${existingAudiobook.title}"`)
removedAudioTracks.forEach((at) => existingAudiobook.removeAudioTrack(at))
}
// Check for new audio files and sync existing audio files
var newAudioFiles = []
var hasUpdatedAudioFiles = false
@@ -100,7 +93,12 @@ class Scanner {
hasUpdatedAudioFiles = true
}
} else {
newAudioFiles.push(file)
var audioFileWithMatchingPath = existingAudiobook.getAudioFileByPath(file.fullPath)
if (audioFileWithMatchingPath) {
Logger.warn(`[Scanner] Audio file with path already exists with different inode, New: "${file.filename}" (${file.ino}) | Existing: ${audioFileWithMatchingPath.filename} (${audioFileWithMatchingPath.ino})`)
} else {
newAudioFiles.push(file)
}
}
})
if (newAudioFiles.length) {
@@ -119,7 +117,7 @@ class Scanner {
return ScanResult.REMOVED
}
var hasUpdates = removedAudioFiles.length || newAudioFiles.length || hasUpdatedAudioFiles
var hasUpdates = removedAudioFiles.length || removedAudioTracks.length || newAudioFiles.length || hasUpdatedAudioFiles
if (existingAudiobook.checkUpdateMissingParts()) {
Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`)
@@ -182,27 +180,28 @@ class Scanner {
// TODO: This temporary fix from pre-release should be removed soon, including the "fixRelativePath" and "checkUpdateInos"
// TEMP - fix relative file paths
// TEMP - update ino for each audiobook
// if (this.audiobooks.length) {
// for (let i = 0; i < this.audiobooks.length; i++) {
// var ab = this.audiobooks[i]
// var shouldUpdate = ab.fixRelativePath(this.AudiobookPath) || !ab.ino
if (this.audiobooks.length) {
for (let i = 0; i < this.audiobooks.length; i++) {
var ab = this.audiobooks[i]
// var shouldUpdate = ab.fixRelativePath(this.AudiobookPath) || !ab.ino
// // Update ino if an audio file has the same ino as the audiobook
// var shouldUpdateIno = !ab.ino || (ab.audioFiles || []).find(abf => abf.ino === ab.ino)
// if (shouldUpdateIno) {
// await ab.checkUpdateInos()
// }
// if (shouldUpdate) {
// await this.db.updateAudiobook(ab)
// }
// }
// }
// Update ino if inos are not set
var shouldUpdateIno = ab.hasMissingIno
if (shouldUpdateIno) {
Logger.debug(`Updating inos for ${ab.title}`)
var hasUpdates = await ab.checkUpdateInos()
if (hasUpdates) {
await this.db.updateAudiobook(ab)
}
}
}
}
const scanStart = Date.now()
var audiobookDataFound = await scanRootDir(this.AudiobookPath, this.db.serverSettings)
// Set ino for each ab data as a string
audiobookDataFound = await this.setAudiobookDataInos(audiobookDataFound)
// Remove audiobooks with no inode
audiobookDataFound = audiobookDataFound.filter(abd => abd.ino)
if (this.cancelScan) {
this.cancelScan = false
@@ -318,7 +317,11 @@ class Scanner {
async filesChanged(filepaths) {
if (!filepaths.length) return ScanResult.NOTHING
var relfilepaths = filepaths.map(path => path.replace(this.AudiobookPath, ''))
var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths)
var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths, true)
Logger.debug(`[Scanner] fileGroupings `, filepaths, fileGroupings)
var results = []
for (const dir in fileGroupings) {
@@ -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) {
var method = req.params.method
var query = req.query
+35 -4
View File
@@ -4,6 +4,7 @@ const http = require('http')
const SocketIO = require('socket.io')
const fs = require('fs-extra')
const fileUpload = require('express-fileupload')
const rateLimit = require('express-rate-limit')
const Auth = require('./Auth')
const Watcher = require('./Watcher')
@@ -14,6 +15,7 @@ const HlsController = require('./HlsController')
const StreamManager = require('./StreamManager')
const RssFeeds = require('./RssFeeds')
const DownloadManager = require('./DownloadManager')
// const EbookReader = require('./EbookReader')
const Logger = require('./Logger')
class Server {
@@ -37,6 +39,7 @@ class Server {
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.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
// this.ebookReader = new EbookReader(this.db, this.MetadataPath, this.AudiobookPath)
this.server = null
this.io = null
@@ -108,6 +111,14 @@ class Server {
this.scanner.cancelScan = true
}
// Generates an NFO metadata file, if no audiobookId is passed then all audiobooks are done
async saveMetadata(socket, audiobookId = null) {
Logger.info('[Server] Starting save metadata files')
var response = await this.scanner.saveMetadata(audiobookId)
Logger.info(`[Server] Finished saving metadata files Successful: ${response.success}, Failed: ${response.failed}`)
socket.emit('save_metadata_complete', response)
}
async init() {
Logger.info('[Server] Init')
await this.streamManager.ensureStreamsDir()
@@ -170,6 +181,21 @@ class Server {
res.sendStatus(200)
}
// First time login rate limit is hit
loginLimitReached(req, res, options) {
Logger.error(`[Server] Login rate limit (${options.max}) was hit for ip ${req.ip}`)
options.message = 'Too many attempts. Login temporarily locked.'
}
getLoginRateLimiter() {
return rateLimit({
windowMs: this.db.serverSettings.rateLimitLoginWindow, // 5 minutes
max: this.db.serverSettings.rateLimitLoginRequests,
skipSuccessfulRequests: true,
onLimitReached: this.loginLimitReached
})
}
async start() {
Logger.info('=== Starting Server ===')
await this.init()
@@ -204,13 +230,18 @@ class Server {
app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
// app.use('/hls', this.hlsController.router)
app.use('/feeds', this.rssFeeds.router)
// 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('/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.get('/ping', (req, res) => {
Logger.info('Recieved ping')
res.json({ success: true })
@@ -229,7 +260,6 @@ class Server {
})
}
this.server.listen(this.Port, this.Host, () => {
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_covers', this.scanCovers.bind(this))
socket.on('cancel_scan', this.cancelScan.bind(this))
socket.on('save_metadata', (audiobookId) => this.saveMetadata(socket, audiobookId))
// Streaming
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
+2 -1
View File
@@ -65,12 +65,13 @@ class StreamManager {
if (!dirs || !dirs.length) return true
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)
Logger.warn(`Removing OLD Orphan Stream ${dirname}`)
return fs.remove(fullPath)
}
}))
return true
} catch (error) {
Logger.debug('No old orphan streams', error)
+71 -6
View File
@@ -1,6 +1,7 @@
const Path = require('path')
const { bytesPretty, elapsedPretty } = require('../utils/fileUtils')
const { comparePaths, getIno } = require('../utils/index')
const nfoGenerator = require('../utils/nfoGenerator')
const Logger = require('../Logger')
const Book = require('./Book')
const AudioTrack = require('./AudioTrack')
@@ -106,6 +107,14 @@ class Audiobook {
return (this.audioFiles || []).filter(af => af.invalid).map(af => ({ filename: af.filename, error: af.error || 'Unknown Error' }))
}
get ebooks() {
return this.otherFiles.filter(file => file.filetype === 'ebook')
}
get hasMissingIno() {
return !this.ino || (this.audioFiles || []).find(abf => !abf.ino) || (this.otherFiles || []).find(f => !f.ino) || (this.tracks || []).find(t => !t.ino)
}
bookToJSON() {
return this.book ? this.book.toJSON() : null
}
@@ -152,6 +161,7 @@ class Audiobook {
hasBookMatch: !!this.book,
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
numEbooks: this.ebooks.length,
numTracks: this.tracks.length,
chapters: this.chapters || [],
isMissing: !!this.isMissing
@@ -173,6 +183,7 @@ class Audiobook {
invalidParts: this.invalidParts,
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
ebooks: (this.ebooks || []).map(ebook => ebook.toJSON()),
tags: this.tags,
book: this.bookToJSON(),
tracks: this.tracksToJSON(),
@@ -195,7 +206,8 @@ class Audiobook {
return false
}
// Update was made to add ino values, ensure they are set
// Originally files did not store the inode value
// this function checks all files and sets the inode
async checkUpdateInos() {
var hasUpdates = false
if (!this.ino) {
@@ -204,15 +216,55 @@ class Audiobook {
}
for (let i = 0; i < this.audioFiles.length; i++) {
var af = this.audioFiles[i]
var at = this.tracks.find(t => t.ino === af.ino)
if (!at) {
at = this.tracks.find(t => comparePaths(t.path, af.path))
}
if (!af.ino || af.ino === this.ino) {
af.ino = await getIno(af.fullPath)
if (!af.ino) {
Logger.error('[Audiobook] checkUpdateInos: Failed to set ino for audio file', af.fullPath)
} else {
var track = this.tracks.find(t => comparePaths(t.path, af.path))
if (track) {
track.ino = af.ino
Logger.debug(`[Audiobook] Set INO For audio file ${af.path}`)
if (at) at.ino = af.ino
}
hasUpdates = true
} else if (at && at.ino !== af.ino) {
at.ino = af.ino
hasUpdates = true
}
}
for (let i = 0; i < this.tracks.length; i++) {
var at = this.tracks[i]
if (!at.ino) {
Logger.debug(`[Audiobook] Track ${at.filename} still does not have ino`)
var atino = await getIno(at.fullPath)
var af = this.audioFiles.find(_af => _af.ino === atino)
if (!af) {
Logger.debug(`[Audiobook] Track ${at.filename} no matching audio file with ino ${atino}`)
af = this.audioFiles.find(_af => _af.filename === at.filename)
if (!af) {
Logger.debug(`[Audiobook] Track ${at.filename} no matching audio file with filename`)
} else {
Logger.debug(`[Audiobook] Track ${at.filename} found matching filename but mismatch ino ${atino}/${af.ino}`)
// at.ino = af.ino
// at.path = af.path
// at.fullPath = af.fullPath
// hasUpdates = true
}
} else {
Logger.debug(`[Audiobook] Track ${at.filename} found audio file with matching ino ${at.path}/${af.path}`)
}
}
}
for (let i = 0; i < this.otherFiles.length; i++) {
var file = this.otherFiles[i]
if (!file.ino || file.ino === this.ino) {
file.ino = await getIno(file.fullPath)
if (!file.ino) {
Logger.error('[Audiobook] checkUpdateInos: Failed to set ino for other file', file.fullPath)
} else {
Logger.debug(`[Audiobook] Set INO For other file ${file.path}`)
}
hasUpdates = true
}
@@ -329,6 +381,11 @@ class Audiobook {
this.audioFiles = this.audioFiles.filter(f => f.ino !== audioFile.ino)
}
removeAudioTrack(track) {
this.tracks = this.tracks.filter(t => t.ino !== track.ino)
this.audioFiles = this.audioFiles.filter(f => f.ino !== track.ino)
}
checkUpdateMissingParts() {
var currMissingParts = (this.missingParts || []).join(',') || ''
@@ -363,6 +420,7 @@ class Audiobook {
var newOtherFilePaths = newOtherFiles.map(f => f.path)
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
// TODO: Should use inode
newOtherFiles.forEach((file) => {
var existingOtherFile = this.otherFiles.find(f => f.path === file.path)
if (!existingOtherFile) {
@@ -425,9 +483,12 @@ class Audiobook {
return this.audioFiles.find(af => af.ino === ino)
}
getAudioFileByPath(fullPath) {
return this.audioFiles.find(af => af.fullPath === fullPath)
}
setChapters() {
// If 1 audio file without chapters, then no chapters will be set
var includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
if (includedAudioFiles.length === 1) {
// 1 audio file with chapters
@@ -463,12 +524,16 @@ class Audiobook {
id: currChapterId++,
start: currStartTime,
end: currStartTime + file.duration,
title: `Chapter ${currChapterId}`
title: file.filename ? Path.basename(file.filename, Path.extname(file.filename)) : `Chapter ${currChapterId}`
})
currStartTime += file.duration
}
})
}
}
writeNfoFile(nfoFilename = 'metadata.nfo') {
return nfoGenerator(this, nfoFilename)
}
}
module.exports = Audiobook
+1
View File
@@ -1,3 +1,4 @@
const fs = require('fs-extra')
const Path = require('path')
const Logger = require('../Logger')
const parseAuthors = require('../utils/parseAuthors')
+11 -1
View File
@@ -3,10 +3,14 @@ const { CoverDestination } = require('../utils/constants')
class ServerSettings {
constructor(settings) {
this.id = 'server-settings'
this.autoTagNew = false
this.newTagExpireDays = 15
this.scannerParseSubtitle = false
this.coverDestination = CoverDestination.METADATA
this.saveMetadataFile = false
this.rateLimitLoginRequests = 10
this.rateLimitLoginWindow = 10 * 60 * 1000 // 10 Minutes
if (settings) {
this.construct(settings)
@@ -18,6 +22,9 @@ class ServerSettings {
this.newTagExpireDays = settings.newTagExpireDays
this.scannerParseSubtitle = settings.scannerParseSubtitle
this.coverDestination = settings.coverDestination || CoverDestination.METADATA
this.saveMetadataFile = !!settings.saveMetadataFile
this.rateLimitLoginRequests = !isNaN(settings.rateLimitLoginRequests) ? Number(settings.rateLimitLoginRequests) : 10
this.rateLimitLoginWindow = !isNaN(settings.rateLimitLoginWindow) ? Number(settings.rateLimitLoginWindow) : 10 * 60 * 1000 // 10 Minutes
}
toJSON() {
@@ -26,7 +33,10 @@ class ServerSettings {
autoTagNew: this.autoTagNew,
newTagExpireDays: this.newTagExpireDays,
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.token = null
this.isActive = true
this.isLocked = false
this.createdAt = null
this.audiobooks = null
@@ -76,6 +77,7 @@ class User {
token: this.token,
audiobooks: this.audiobooksToJSON(),
isActive: this.isActive,
isLocked: this.isLocked,
createdAt: this.createdAt,
settings: this.settings,
permissions: this.permissions
@@ -91,6 +93,7 @@ class User {
token: this.token,
audiobooks: this.audiobooksToJSON(),
isActive: this.isActive,
isLocked: this.isLocked,
createdAt: this.createdAt,
settings: this.settings,
permissions: this.permissions
@@ -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.settings = user.settings || this.getDefaultUserSettings()
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) {
if (!newAudioFiles || !newAudioFiles.length) {
Logger.error('[AudioFileScanner] Scan Audio Files no files', audiobook.title)
Logger.error('[AudioFileScanner] Scan Audio Files no new files', audiobook.title)
return
}
var tracks = []
+2 -1
View File
@@ -29,9 +29,10 @@ function bytesPretty(bytes, decimals = 0) {
return '0 Bytes'
}
const k = 1024
const dm = decimals < 0 ? 0 : decimals
var dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
if (i > 2 && dm === 0) dm = 1
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
module.exports.bytesPretty = bytesPretty
+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 dir = require('node-dir')
const Logger = require('../Logger')
const { getIno } = require('./index')
const AUDIO_FORMATS = ['m4b', 'mp3', 'm4a']
const INFO_FORMATS = ['nfo']
@@ -26,7 +27,7 @@ function isAudioFile(path) {
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
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
})
// Step 2.5: Seperate audio files and other files
// Step 2.5: Seperate audio files and other files (optional)
var audioFilePaths = []
var otherFilePaths = []
pathsFiltered.forEach(path => {
if (isAudioFile(path)) audioFilePaths.push(path)
if (isAudioFile(path) || useAllFileTypes) audioFilePaths.push(path)
else otherFilePaths.push(path)
})
@@ -134,7 +135,12 @@ async function scanRootDir(abRootPath, serverSettings = {}) {
var audiobookData = getAudiobookDataFromDir(abRootPath, audiobookPath, parseSubtitle)
var fileObjs = cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath])
for (let i = 0; i < fileObjs.length; i++) {
fileObjs[i].ino = await getIno(fileObjs[i].fullPath)
}
var audiobookIno = await getIno(audiobookData.fullPath)
audiobooks.push({
ino: audiobookIno,
...audiobookData,
audioFiles: fileObjs.filter(f => f.filetype === 'audio'),
otherFiles: fileObjs.filter(f => f.filetype !== 'audio')
@@ -241,11 +247,15 @@ async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings =
otherFiles: []
}
filepaths.forEach((filepath) => {
for (let i = 0; i < filepaths.length; i++) {
var filepath = filepaths[i]
var relpath = Path.normalize(filepath).replace(abRootPath, '').slice(1)
var extname = Path.extname(filepath)
var basename = Path.basename(filepath)
var ino = await getIno(filepath)
var fileObj = {
ino,
filetype: getFileType(extname),
filename: basename,
path: relpath,
@@ -257,7 +267,7 @@ async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings =
} else {
audiobook.otherFiles.push(fileObj)
}
})
}
return audiobook
}
module.exports.getAudiobookFileData = getAudiobookFileData