Compare commits

...

14 Commits

Author SHA1 Message Date
Mark Cooper f4d6e65380 Player track chapter tickmarks, highlight current chapter, progress filters, links in stream container 2021-09-21 16:42:01 -05:00
Mark Cooper baccbaf82a Remove production from prod script 2021-09-19 20:38:24 -05:00
Mark Cooper d6969e0b85 Update readme for running on local. Add command line arg parser. 2021-09-19 19:52:08 -05:00
Mark Cooper b3ad9c95ce Add script & file for running production without docker 2021-09-19 19:22:35 -05:00
Mark Cooper 2f6417dec2 Remove test stream, add prod script 2021-09-18 16:42:20 -05:00
Mark Cooper f54d48270e Update regex for volume scanner 2021-09-18 13:26:02 -05:00
Mark Cooper 57321084af Fix regex misplaced \b in volume parser 2021-09-18 13:09:30 -05:00
Mark Cooper 1d97422011 Readme update 2021-09-18 12:53:52 -05:00
Mark Cooper 2f2a64b89e Readme update 2021-09-18 12:53:14 -05:00
Mark Cooper b2e129eec7 Readme update 2021-09-18 12:52:38 -05:00
Mark Cooper cb79e48685 Readme Update 2021-09-18 12:50:22 -05:00
Mark Cooper e735ef7869 Readme update 2021-09-18 12:49:21 -05:00
Mark Cooper 8f1152762a Adding upload permission to users, directory structure readme update 2021-09-18 12:45:34 -05:00
Mark Cooper 587adb3773 Add volume number parsing to scanner 2021-09-18 11:13:05 -05:00
33 changed files with 373 additions and 122 deletions
+29 -8
View File
@@ -12,9 +12,6 @@
<div v-if="chapters.length" class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<span class="material-icons text-3xl">format_list_bulleted</span>
</div>
<div v-else class="flex items-center justify-center text-gray-500">
<span class="material-icons text-3xl">format_list_bulleted</span>
</div>
</div>
<div class="absolute right-32 top-0 bottom-0">
@@ -56,6 +53,11 @@
<div ref="trackCursor" class="h-full w-0.5 bg-gray-50 absolute top-0 left-0 opacity-0 pointer-events-none" />
<div v-if="loading" class="h-full w-1/4 absolute left-0 top-0 loadingTrack pointer-events-none bg-white bg-opacity-25" />
</div>
<div ref="track" class="w-full h-2 relative overflow-hidden">
<template v-for="(tick, index) in chapterTicks">
<div :key="index" :style="{ left: tick.left + 'px' }" class="absolute top-0 w-px bg-white bg-opacity-50 h-1 pointer-events-none" />
</template>
</div>
<!-- Hover timestamp -->
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
@@ -68,7 +70,7 @@
<audio ref="audio" @pause="paused" @playing="playing" @progress="progress" @timeupdate="timeupdate" @loadeddata="audioLoadedData" />
<modals-chapters-modal v-model="showChaptersModal" :chapters="chapters" @select="selectChapter" />
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
</div>
</template>
@@ -100,7 +102,8 @@ export default {
totalDuration: 0,
seekedTime: 0,
seekLoading: false,
showChaptersModal: false
showChaptersModal: false,
currentTime: 0
}
},
computed: {
@@ -109,6 +112,18 @@ export default {
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
},
chapterTicks() {
return this.chapters.map((chap) => {
var perc = chap.start / this.totalDuration
return {
title: chap.title,
left: perc * this.trackWidth
}
})
},
currentChapter() {
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
}
},
methods: {
@@ -175,7 +190,13 @@ export default {
this.$refs.hoverTimestamp.style.left = offsetX - width / 2 + 'px'
}
if (this.$refs.hoverTimestampText) {
this.$refs.hoverTimestampText.innerText = this.$secondsToTimestamp(time)
var hoverText = this.$secondsToTimestamp(time)
var chapter = this.chapters.find((chapter) => chapter.start <= time && time < chapter.end)
if (chapter && chapter.title) {
hoverText += ` - ${chapter.title}`
}
this.$refs.hoverTimestampText.innerText = hoverText
}
if (this.$refs.trackCursor) {
this.$refs.trackCursor.style.opacity = 1
@@ -289,7 +310,6 @@ export default {
end: end + offset
})
}
return ranges
},
getLastBufferedTime() {
@@ -315,7 +335,6 @@ export default {
this.bufferTrackWidth = bufferlen
},
timeupdate() {
// console.log('Time update', this.audioEl.currentTime)
if (!this.$refs.playedTrack) {
console.error('Invalid no played track ref')
return
@@ -335,6 +354,8 @@ export default {
this.updateTimestamp()
this.currentTime = this.audioEl.currentTime
var perc = this.audioEl.currentTime / this.audioEl.duration
var ptWidth = Math.round(perc * this.trackWidth)
if (this.playedTrackWidth === ptWidth) {
+5 -2
View File
@@ -16,11 +16,11 @@
<span class="pl-2">Update is available! Check release notes for v{{ latestVersion }}</span>
</a> -->
<nuxt-link v-if="isRootUser" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mr-4">
<nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center">
<span class="material-icons">upload</span>
</nuxt-link>
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center">
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center ml-4">
<span class="material-icons">settings</span>
</nuxt-link>
@@ -96,6 +96,9 @@ export default {
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
},
selectedIsRead() {
// Find an audiobook that is not read, if none then all audiobooks read
return !this.selectedAudiobooks.find((ab) => {
+7 -3
View File
@@ -10,8 +10,11 @@
</div>
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4">Your Audiobookshelf is empty!</p>
<ui-btn color="success" @click="scan">Scan your Audiobooks</ui-btn>
<p class="text-center text-2xl font-book mb-4 py-4">Your Audiobookshelf is empty!</p>
<div class="flex">
<ui-btn to="/config" color="primary" class="w-52 mr-2" @click="scan">Configure Scanner</ui-btn>
<ui-btn color="success" class="w-52" @click="scan">Scan Audiobooks</ui-btn>
</div>
</div>
<div v-else class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in groupedBooks">
@@ -43,7 +46,8 @@ export default {
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
selectedSizeIndex: 3,
rowPaddingX: 40,
keywordFilterTimeout: null
keywordFilterTimeout: null,
scannerParseSubtitle: false
}
},
watch: {
@@ -42,7 +42,6 @@ export default {
this.saveSettings()
},
saveSettings() {
this.$store.commit('user/setSettings', this.settings) // Immediate update
this.$store.dispatch('user/updateUserSettings', this.settings)
},
init() {
+15 -6
View File
@@ -1,14 +1,14 @@
<template>
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary p-4">
<div class="absolute -top-16 left-4">
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="absolute -top-16 left-4 cursor-pointer">
<cards-book-cover :audiobook="streamAudiobook" :width="88" />
</div>
</nuxt-link>
<div class="flex items-center pl-24">
<div>
<h1>
{{ title }} <span v-if="stream" class="text-xs text-gray-400">({{ stream.id }})</span>
</h1>
<p class="text-gray-400 text-sm">by {{ author }}</p>
<nuxt-link :to="`/audiobook/${streamAudiobook.id}`" class="hover:underline cursor-pointer">
{{ title }} <span v-if="stream && $isDev" class="text-xs text-gray-400">({{ stream.id }})</span>
</nuxt-link>
<p class="text-gray-400 text-sm hover:underline cursor-pointer" @click="filterByAuthor">by {{ author }}</p>
</div>
<div class="flex-grow" />
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
@@ -66,6 +66,15 @@ export default {
}
},
methods: {
filterByAuthor() {
if (this.$route.name !== 'index') {
this.$router.push('/')
}
var settingsUpdate = {
filterBy: `authors.${this.$encode(this.author)}`
}
this.$store.dispatch('user/updateUserSettings', settingsUpdate)
},
audioPlayerMounted() {
this.audioPlayerReady = true
if (this.stream) {
+2 -2
View File
@@ -1,7 +1,7 @@
<template>
<div class="relative">
<!-- New Book Flag -->
<div v-show="isNew" class="absolute top-4 left-0 w-4 h-10 pr-2 bg-darkgreen box-shadow-xl">
<div v-show="isNew" class="absolute top-4 left-0 w-4 h-10 pr-2 bg-darkgreen box-shadow-xl z-20">
<div class="absolute top-0 left-0 w-full h-full transform -rotate-90 flex items-center justify-center">
<p class="text-center text-sm">New</p>
</div>
@@ -65,7 +65,7 @@ export default {
},
computed: {
isNew() {
return this.tags.includes('new')
return this.tags.includes('New')
},
tags() {
return this.audiobook.tags || []
@@ -86,6 +86,11 @@ export default {
text: 'Authors',
value: 'authors',
sublist: true
},
{
text: 'Progress',
value: 'progress',
sublist: true
}
]
}
@@ -132,6 +137,9 @@ export default {
authors() {
return this.$store.getters['audiobooks/getUniqueAuthors']
},
progress() {
return ['Read', 'Unread', 'In Progress']
},
sublistItems() {
return (this[this.sublist] || []).map((item) => {
return {
+1 -1
View File
@@ -18,7 +18,7 @@
</li>
<template v-else>
<template v-for="item in items">
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(item)">
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickedOption(item)">
<template v-if="item.type === 'audiobook'">
<cards-audiobook-search-card :audiobook="item.data" />
</template>
@@ -74,10 +74,10 @@ export default {
this.showMenu = false
},
leftArrowClick() {
this.rateIndex = Math.max(0, this.rateIndex - 4)
this.rateIndex = Math.max(0, this.rateIndex - 1)
},
rightArrowClick() {
this.rateIndex = Math.min(this.rates.length - this.numVisible, this.rateIndex + 4)
this.rateIndex = Math.min(this.rates.length - this.numVisible, this.rateIndex + 1)
}
},
mounted() {}
+18 -7
View File
@@ -27,9 +27,9 @@
</div>
</div>
<div v-if="!isEditingRoot && newUser.permissions" class="w-full border-t border-b border-black-200 py-2 mt-4">
<p class="text-lg mb-2">Permissions</p>
<div class="flex items-center my-2 max-w-lg">
<div v-if="!isEditingRoot && newUser.permissions" class="w-full border-t border-b border-black-200 py-2 px-3 mt-4">
<p class="text-lg mb-2 font-semibold">Permissions</p>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Download</p>
</div>
@@ -38,7 +38,7 @@
</div>
</div>
<div class="flex items-center my-2 max-w-lg">
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Update</p>
</div>
@@ -47,7 +47,7 @@
</div>
</div>
<div class="flex items-center my-2 max-w-lg">
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Delete</p>
</div>
@@ -55,6 +55,15 @@
<ui-toggle-switch v-model="newUser.permissions.delete" />
</div>
</div>
<div class="flex items-center my-2 max-w-md">
<div class="w-1/2">
<p>Can Upload</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.upload" />
</div>
</div>
</div>
<div class="flex pt-4">
@@ -179,7 +188,8 @@ export default {
this.newUser.permissions = {
download: type !== 'guest',
update: type === 'admin',
delete: type === 'admin'
delete: type === 'admin',
upload: type === 'admin'
}
},
init() {
@@ -201,7 +211,8 @@ export default {
permissions: {
download: true,
update: false,
delete: false
delete: false,
upload: false
}
}
}
+8 -1
View File
@@ -2,7 +2,7 @@
<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">
<template v-for="chap in chapters">
<div :key="chap.id" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg bg-opacity-20 rounded-lg relative" @click="clickChapter(chap)">
<div :key="chap.id" 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>
@@ -19,6 +19,10 @@ export default {
chapters: {
type: Array,
default: () => []
},
currentChapter: {
type: Object,
default: () => null
}
},
data() {
@@ -32,6 +36,9 @@ export default {
set(val) {
this.$emit('input', val)
}
},
currentChapterId() {
return this.currentChapter ? this.currentChapter.id : null
}
},
methods: {
+11 -1
View File
@@ -1,5 +1,14 @@
<template>
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @click="click">
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :class="classList">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> -->
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
</nuxt-link>
<button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @click="click">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> -->
@@ -13,6 +22,7 @@
<script>
export default {
props: {
to: String,
color: {
type: String,
default: 'primary'
-1
View File
@@ -113,7 +113,6 @@ export default {
this.currentSearch = null
},
clickedOption(e, item) {
var newValue = this.input === item ? null : item
this.textInput = null
this.currentSearch = null
this.input = this.textInput ? this.textInput.trim() : null
+1 -1
View File
@@ -180,7 +180,7 @@ export default {
submitForm() {
if (!this.textInput) return
var cleaned = this.textInput.toLowerCase().trim()
var cleaned = this.textInput.trim()
var matchesItem = this.items.find((i) => {
return i === cleaned
})
+3 -2
View File
@@ -1,5 +1,5 @@
<template>
<div ref="box" class="tooltip-box" @mouseover="mouseover" @mouseleave="mouseleave">
<div ref="box" @mouseover="mouseover" @mouseleave="mouseleave">
<slot />
</div>
</template>
@@ -51,8 +51,9 @@ export default {
createTooltip() {
if (!this.$refs.box) return
var tooltip = document.createElement('div')
tooltip.className = 'absolute px-2 bg-black bg-opacity-90 py-1 text-white pointer-events-none text-xs rounded shadow-lg'
tooltip.className = 'absolute px-2 py-1 text-white pointer-events-none text-xs rounded shadow-lg max-w-xs'
tooltip.style.zIndex = 100
tooltip.style.backgroundColor = 'rgba(0,0,0,0.75)'
tooltip.innerHTML = this.text
this.setTooltipPosition(tooltip)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "1.0.0",
"version": "1.1.13",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "1.1.12",
"version": "1.1.14",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {
@@ -9,7 +9,7 @@
"start": "nuxt start",
"generate": "nuxt generate"
},
"author": "",
"author": "advplyr",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
+5 -3
View File
@@ -13,9 +13,11 @@
<div class="mb-2">
<h1 class="text-2xl font-book leading-7">{{ title }}</h1>
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
<ui-tooltip :text="authorTooltipText" direction="bottom">
<p class="text-sm text-gray-100 leading-7">by {{ author }}</p>
</ui-tooltip>
<div class="w-min">
<ui-tooltip :text="authorTooltipText" direction="bottom">
<span class="text-sm text-gray-100 leading-7 whitespace-nowrap">by {{ author }}</span>
</ui-tooltip>
</div>
</div>
<div class="flex-grow" />
</div>
+6 -1
View File
@@ -49,7 +49,12 @@
<div class="flex-grow" />
<div class="w-40 flex flex-col">
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
<ui-btn color="primary" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
<div class="w-full">
<ui-tooltip direction="bottom" text="Only scans audiobooks without a cover. Covers will be applied if a close match is found." class="w-full">
<ui-btn color="primary" class="w-full" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
</ui-tooltip>
</div>
</div>
</div>
</div>
+2 -6
View File
@@ -1,8 +1,8 @@
<template>
<div id="page-wrapper" class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
<main class="container mx-auto h-full max-w-screen-lg p-6">
<article class="max-h-full overflow-y-auto relative flex flex-col bg-primary shadow-xl rounded-md" @drop="drop" @dragover="dragover" @dragleave="dragleave" @dragenter="dragenter">
<h1 class="text-xl font-book px-4 pt-4 pb-2"><span class="text-error pr-4">(Experimental)</span>Audiobook Uploader</h1>
<article class="max-h-full overflow-y-auto relative flex flex-col rounded-md" @drop="drop" @dragover="dragover" @dragleave="dragleave" @dragenter="dragenter">
<h1 class="text-xl font-book px-8 pt-4 pb-2">Audiobook Uploader</h1>
<div class="flex my-2 px-6">
<div class="w-1/2 px-2">
@@ -170,19 +170,16 @@ export default {
}
},
drop(evt) {
console.log('Dropped event', evt)
this.isDragOver = false
this.preventDefaults(evt)
const files = [...evt.dataTransfer.files]
this.filesChanged(files)
},
dragover(evt) {
console.log('Dragged over', evt)
this.isDragOver = true
this.preventDefaults(evt)
},
dragleave(evt) {
console.log('Dragged leave', evt)
this.isDragOver = false
this.preventDefaults(evt)
},
@@ -195,7 +192,6 @@ export default {
e.stopPropagation()
},
filesChanged(files) {
console.log('FilesChanged', files)
this.showUploader = false
for (let i = 0; i < files.length; i++) {
+12 -2
View File
@@ -16,12 +16,12 @@ export const getters = {
getAudiobook: (state) => id => {
return state.audiobooks.find(ab => ab.id === id)
},
getFiltered: (state, getters, rootState) => () => {
getFiltered: (state, getters, rootState, rootGetters) => () => {
var filtered = state.audiobooks
var settings = rootState.user.settings || {}
var filterBy = settings.filterBy || ''
var searchGroups = ['genres', 'tags', 'series', 'authors']
var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) {
var filter = decode(filterBy.replace(`${group}.`, ''))
@@ -29,6 +29,16 @@ export const getters = {
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
else if (group === 'progress') {
filtered = filtered.filter(ab => {
var userAudiobook = rootGetters['user/getUserAudiobook'](ab.id)
var isRead = userAudiobook && userAudiobook.isRead
if (filter === 'Read' && isRead) return true
if (filter === 'Unread' && !isRead) return true
if (filter === 'In Progress' && (userAudiobook && !userAudiobook.isRead && userAudiobook.progress > 0)) return true
return false
})
}
}
if (state.keywordFilter) {
const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrarator']
+5 -2
View File
@@ -33,6 +33,9 @@ export const getters = {
},
getUserCanDownload: (state) => {
return state.user && state.user.permissions ? !!state.user.permissions.download : false
},
getUserCanUpload: (state) => {
return state.user && state.user.permissions ? !!state.user.permissions.upload : false
}
}
@@ -41,6 +44,8 @@ export const actions = {
var updatePayload = {
...payload
}
// Immediately update
commit('setSettings', updatePayload)
return this.$axios.$patch('/api/user/settings', updatePayload).then((result) => {
if (result.success) {
commit('setSettings', result.settings)
@@ -60,10 +65,8 @@ export const mutations = {
state.user = user
if (user) {
if (user.token) localStorage.setItem('token', user.token)
console.log('setUser', user.username)
} else {
localStorage.removeItem('token')
console.warn('setUser cleared')
}
},
setSettings(state, settings) {
+35 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "1.1.7",
"version": "1.1.13",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -138,6 +138,11 @@
"is-primitive": "^3.0.1"
}
},
"array-back": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz",
"integrity": "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q=="
},
"array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -288,6 +293,17 @@
"mimic-response": "^1.0.0"
}
},
"command-line-args": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-5.2.0.tgz",
"integrity": "sha512-4zqtU1hYsSJzcJBOcNZIbW5Fbk9BkjCp1pZVhQKoRaWL5J7N4XphDLwo8aWwdQpTugxwu+jf9u2ZhkXiqp5Z6A==",
"requires": {
"array-back": "^3.1.0",
"find-replace": "^3.0.0",
"lodash.camelcase": "^4.3.0",
"typical": "^4.0.0"
}
},
"component-emitter": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
@@ -566,6 +582,14 @@
"unpipe": "~1.0.0"
}
},
"find-replace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/find-replace/-/find-replace-3.0.0.tgz",
"integrity": "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==",
"requires": {
"array-back": "^3.0.1"
}
},
"fluent-ffmpeg": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.2.tgz",
@@ -830,6 +854,11 @@
"got": "11.3.x"
}
},
"lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY="
},
"lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -1339,6 +1368,11 @@
"mime-types": "~2.1.24"
}
},
"typical": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/typical/-/typical-4.0.0.tgz",
"integrity": "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw=="
},
"universalify": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
+5 -2
View File
@@ -1,11 +1,13 @@
{
"name": "audiobookshelf",
"version": "1.1.12",
"version": "1.1.14",
"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",
"start": "node prod.js"
},
"author": "advplyr",
"license": "ISC",
@@ -13,6 +15,7 @@
"archiver": "^5.3.0",
"axios": "^0.21.1",
"bcryptjs": "^2.4.3",
"command-line-args": "^5.2.0",
"cookie-parser": "^1.4.5",
"express": "^4.17.1",
"express-fileupload": "^1.2.1",
+30
View File
@@ -0,0 +1,30 @@
const optionDefinitions = [
{ name: 'config', alias: 'c', type: String },
{ name: 'audiobooks', alias: 'a', type: String },
{ name: 'metadata', alias: 'm', type: String },
{ name: 'port', alias: 'p', type: String }
]
const commandLineArgs = require('command-line-args')
const options = commandLineArgs(optionDefinitions)
const Path = require('path')
process.env.TOKEN_SECRET = '09f26e402586e2faa8da4c98a35f1b20d6b033c6097befa8be3486a829587fe2f90a832bd3ff9d42710a4da095a2ce285b009f0c3730cd9b8e1af3eb84df6611'
process.env.NODE_ENV = 'production'
const server = require('./server/Server')
global.appRoot = __dirname
var inputConfig = options.config ? Path.resolve(options.config) : null
var inputAudiobook = options.audiobooks ? Path.resolve(options.audiobooks) : null
var inputMetadata = options.metadata ? Path.resolve(options.metadata) : null
const PORT = options.port || process.env.PORT || 3333
const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')
const AUDIOBOOK_PATH = inputAudiobook || process.env.AUDIOBOOK_PATH || Path.resolve('audiobooks')
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
console.log('Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
const Server = new server(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
Server.start()
+61 -15
View File
@@ -9,30 +9,61 @@ Android app is in beta, try it out on the [Google Play Store](https://play.googl
<img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_streaming.png" />
#### Folder Structures Supported:
## Directory Structure
```bash
/Title/...
/Author/Title/...
/Author/Series/Title/...
Author, Series, Volume Number, Title and Publish Year can all be parsed from your folder structure.
Title can start with the publish year like so:
/1989 - Book Title/...
**Note**: Files in the root directory `/audiobooks` will be ignored, all audiobooks should be in a directory
(Optional Setting) Subtitle can be seperated to its own field:
/Book Title - With a Subtitle/...
/1989 - Book Title - With a Subtitle/...
will store "With a Subtitle" as the subtitle
```
**1 Folder:** `/Title/...`\
**2 Folders:** `/Author/Title/...`\
**3 Folders:** `/Author/Series/Title/...`
### Parsing publish year
`/1984 - Hackers/...`\
Will save the publish year as `1984` and the title as `Hackers`
### Parsing volume number (only for series)
`/Book 3 - Hackers/...`\
Will save the volume number as `3` and the title as `Hackers`
`Book` `Volume` `Vol` `Vol.` are all supported case insensitive
These combinations will also work:\
`/Hackers - Vol. 3/...`\
`/1984 - Volume 3 - Hackers/...`\
`/1984 - Hackers Book 3/...`
#### Features coming soon:
### Parsing subtitles (optional in settings)
Title Folder: `/Hackers - Heroes of the Computer Revolution/...`
Will save the title as `Hackers` and the subtitle as `Heroes of the Computer Revolution`
### Full example
`/Steven Levy/The Hacker Series/1984 - Hackers - Heroes of the Computer Revolution - Vol. 1/...`
**Becomes:**
| Key | Value |
|---------------|-----------------------------------|
| Author | Steven Levy |
| Series | The Hacker Series |
| Publish Year | 1984 |
| Title | Hackers |
| Subtitle | Heroes of the Computer Revolution |
| Volume Number | 1 |
## Features coming soon
* Support different views to see more details of each audiobook
* iOS App (Android is in beta [here](https://play.google.com/store/apps/details?id=com.audiobookshelf.app))
<img alt="Screenshot2" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_audiobook.png" />
## Installation
Built to run in Docker for now (also on Unraid server Community Apps)
@@ -41,6 +72,21 @@ Built to run in Docker for now (also on Unraid server Community Apps)
docker run -d -p 1337:80 -v /audiobooks:/audiobooks -v /config:/config -v /metadata:/metadata --name audiobookshelf --rm advplyr/audiobookshelf
```
## Running on your local
```bash
git clone https://github.com/advplyr/audiobookshelf.git
cd audiobookshelf
# All paths default to root directory. Config path is the database.
# Directories will be created if they don't exist
# Paths are relative to the root directory, so "../Audiobooks" would be a valid path
npm run prod -- -p [PORT] --audiobooks [AUDIOBOOKS_PATH] --config [CONFIG_PATH] --metadata [METADATA_PATH]
# You only need to use `npm run prod` the first time, after that use `npm run start`
npm run start -- -p [PORT] --audiobooks [AUDIOBOOKS_PATH] --config [CONFIG_PATH] --metadata [METADATA_PATH]
```
## Contributing
Feel free to help out
+46 -32
View File
@@ -123,6 +123,51 @@ class Server {
this.auth.authMiddleware(req, res, next)
}
async handleUpload(req, res) {
if (!req.user.canUpload) {
Logger.warn('User attempted to upload without permission', req.user)
return res.sendStatus(403)
}
var files = Object.values(req.files)
var title = req.body.title
var author = req.body.author
var series = req.body.series
if (!files.length || !title || !author) {
return res.json({
error: 'Invalid post data received'
})
}
var outputDirectory = ''
if (series && series.length && series !== 'null') {
outputDirectory = Path.join(this.AudiobookPath, author, series, title)
} else {
outputDirectory = Path.join(this.AudiobookPath, author, title)
}
var exists = await fs.pathExists(outputDirectory)
if (exists) {
Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`)
return res.json({
error: `Directory "${outputDirectory}" already exists`
})
}
await fs.ensureDir(outputDirectory)
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
for (let i = 0; i < files.length; i++) {
var file = files[i]
var path = Path.join(outputDirectory, file.name)
await file.mv(path).catch((error) => {
Logger.error('Failed to move file', path, error)
})
}
res.sendStatus(200)
}
async start() {
Logger.info('=== Starting Server ===')
@@ -157,38 +202,7 @@ class Server {
// app.use('/hls', this.hlsController.router)
app.use('/feeds', this.rssFeeds.router)
app.post('/upload', this.authMiddleware.bind(this), async (req, res) => {
var files = Object.values(req.files)
var title = req.body.title
var author = req.body.author
var series = req.body.series
if (!files.length || !title || !author) {
return res.json({
error: 'Invalid post data received'
})
}
var outputDirectory = ''
if (series && series.length && series !== 'null') {
outputDirectory = Path.join(this.AudiobookPath, author, series, title)
} else {
outputDirectory = Path.join(this.AudiobookPath, author, title)
}
await fs.ensureDir(outputDirectory)
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
for (let i = 0; i < files.length; i++) {
var file = files[i]
var path = Path.join(outputDirectory, file.name)
await file.mv(path).catch((error) => {
Logger.error('Failed to move file', path, error)
})
}
res.sendStatus(200)
})
app.post('/upload', this.authMiddleware.bind(this), this.handleUpload.bind(this))
app.post('/login', (req, res) => this.auth.login(req, res))
app.post('/logout', this.logout.bind(this))
+11 -11
View File
@@ -1,5 +1,5 @@
const Stream = require('./objects/Stream')
const StreamTest = require('./test/StreamTest')
// const StreamTest = require('./test/StreamTest')
const Logger = require('./Logger')
const fs = require('fs-extra')
const Path = require('path')
@@ -104,19 +104,19 @@ class StreamManager {
async openTestStream(streamPath, audiobookId) {
Logger.info('Open Stream Test Request', audiobookId)
var audiobook = this.audiobooks.find(ab => ab.id === audiobookId)
var stream = new StreamTest(streamPath, audiobook)
// var audiobook = this.audiobooks.find(ab => ab.id === audiobookId)
// var stream = new StreamTest(streamPath, audiobook)
stream.on('closed', () => {
console.log('Stream closed')
})
// stream.on('closed', () => {
// console.log('Stream closed')
// })
var playlistUri = await stream.generatePlaylist()
stream.start()
// var playlistUri = await stream.generatePlaylist()
// stream.start()
Logger.info('Stream Playlist', playlistUri)
Logger.info('Test Stream Opened for audiobook', audiobook.title, 'with streamId', stream.id)
return playlistUri
// Logger.info('Stream Playlist', playlistUri)
// Logger.info('Test Stream Opened for audiobook', audiobook.title, 'with streamId', stream.id)
// return playlistUri
}
streamUpdate(socket, { currentTime, streamId }) {
+1 -1
View File
@@ -151,7 +151,7 @@ class Book {
// If audiobook directory path was changed, check and update properties set from dirnames
// May be worthwhile checking if these were manually updated and not override manual updates
syncPathsUpdated(audiobookData) {
var keysToSync = ['author', 'title', 'series', 'publishYear']
var keysToSync = ['author', 'title', 'series', 'publishYear', 'volumeNumber']
var syncPayload = {}
keysToSync.forEach((key) => {
if (audiobookData[key]) syncPayload[key] = audiobookData[key]
-2
View File
@@ -171,13 +171,11 @@ class Stream extends EventEmitter {
this.furthestSegmentCreated = lastSegment
}
// console.log('SORT', [...this.segmentsCreated].slice(0, 200).join(', '), segments.slice(0, 200).join(', '))
segments.forEach((seg) => {
if (!current_chunk.length || last_seg_in_chunk + 1 === seg) {
last_seg_in_chunk = seg
current_chunk.push(seg)
} else {
// console.log('Last Seg is not equal to - 1', last_seg_in_chunk, seg)
if (current_chunk.length === 1) chunks.push(current_chunk[0])
else chunks.push(`${current_chunk[0]}-${current_chunk[current_chunk.length - 1]}`)
last_seg_in_chunk = seg
+7 -1
View File
@@ -32,6 +32,9 @@ class User {
get canDownload() {
return !!this.permissions.download && this.isActive
}
get canUpload() {
return !!this.permissions.upload && this.isActive
}
getDefaultUserSettings() {
return {
@@ -47,7 +50,8 @@ class User {
return {
download: true,
update: true,
delete: this.id === 'root'
delete: this.type === 'root',
upload: this.type === 'root' || this.type === 'admin'
}
}
@@ -112,6 +116,8 @@ class User {
this.createdAt = user.createdAt || Date.now()
this.settings = user.settings || this.getDefaultUserSettings()
this.permissions = user.permissions || this.getDefaultUserPermissions()
// Upload permission added v1.1.13, make sure root user has upload permissions
if (this.type === 'root' && !this.permissions.upload) this.permissions.upload = true
}
update(payload) {
+1 -1
View File
@@ -1,6 +1,6 @@
const Ffmpeg = require('fluent-ffmpeg')
if (process.env.NODE_ENV !== 'production') {
if (process.env.FFMPEG_PATH) {
Ffmpeg.setFfmpegPath(process.env.FFMPEG_PATH)
}
+33 -1
View File
@@ -155,10 +155,39 @@ function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) {
// If there are at least 2 more directories, next furthest will be the series
if (splitDir.length > 1) series = splitDir.pop()
if (splitDir.length > 0) author = splitDir.pop()
// There could be many more directories, but only the top 3 are used for naming /author/series/title/
// If in a series directory check for volume number match
/* ACCEPTS:
Book 2 - Title Here - Subtitle Here
Title Here - Subtitle Here - Vol 12
Title Here - volume 9 - Subtitle Here
Vol. 3 Title Here - Subtitle Here
1980 - Book 2-Title Here
Title Here-Volume 999-Subtitle Here
*/
var volumeNumber = null
if (series) {
var volumeMatch = title.match(/(-? ?)\b((?:Book|Vol.?|Volume) (\d{1,3}))\b( ?-?)/i)
if (volumeMatch && volumeMatch.length > 3 && volumeMatch[2] && volumeMatch[3]) {
volumeNumber = volumeMatch[3]
var replaceChunk = volumeMatch[2]
// "1980 - Book 2-Title Here"
// Group 1 would be "- "
// Group 3 would be "-"
// Only remove the first group
if (volumeMatch[1]) {
replaceChunk = volumeMatch[1] + replaceChunk
} else if (volumeMatch[4]) {
replaceChunk += volumeMatch[4]
}
title = title.replace(replaceChunk, '').trim()
}
}
var publishYear = null
// If Title is of format 1999 - Title, then use 1999 as publish year
var publishYearMatch = title.match(/^([0-9]{4}) - (.+)/)
@@ -169,7 +198,9 @@ function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) {
}
}
// Subtitle can be parsed from the title if user enabled
// Subtitle is everything after " - "
var subtitle = null
if (parseSubtitle && title.includes(' - ')) {
var splitOnSubtitle = title.split(' - ')
@@ -182,6 +213,7 @@ function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) {
title,
subtitle,
series,
volumeNumber,
publishYear,
path: dir, // relative audiobook path i.e. /Author Name/Book Name/..
fullPath: Path.join(abRootPath, dir) // i.e. /audiobook/Author Name/Book Name/..