narrator filter, no series filter, full paths toggle, book landing page details, new sans font, update query string on filter/sort, persist experimental feature flag, batch edit redirect bug, upload file permissions and owner

This commit is contained in:
advplyr 2021-10-06 21:08:52 -05:00
parent 75aede914f
commit f752c19418
36 changed files with 454 additions and 230 deletions

View File

@ -66,10 +66,10 @@
background-color: #474747; background-color: #474747;
} }
.tracksTable td { .tracksTable td {
padding: 4px; padding: 4px 8px;
} }
.tracksTable th { .tracksTable th {
padding: 4px; padding: 4px 8px;
font-size: 0.75rem; font-size: 0.75rem;
} }

View File

@ -23,7 +23,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg> </svg>
<p class="text-base leading-3 font-book pl-2">{{ libraryName }}</p> <p class="text-sm leading-3 font-sans pl-2">{{ libraryName }}</p>
</div> </div>
<controls-global-search /> <controls-global-search />

View File

@ -53,7 +53,7 @@ export default {
data() { data() {
return { return {
shelves: [], shelves: [],
currFilterOrderKey: null, currSearchParams: null,
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220], availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
selectedSizeIndex: 3, selectedSizeIndex: 3,
rowPaddingX: 40, rowPaddingX: 40,
@ -89,9 +89,6 @@ export default {
audiobooks() { audiobooks() {
return this.$store.state.audiobooks.audiobooks return this.$store.state.audiobooks.audiobooks
}, },
filterOrderKey() {
return this.$store.getters['user/getFilterOrderKey']
},
bookCoverWidth() { bookCoverWidth() {
return this.availableSizes[this.selectedSizeIndex] return this.availableSizes[this.selectedSizeIndex]
}, },
@ -111,6 +108,12 @@ export default {
filterBy() { filterBy() {
return this.$store.getters['user/getUserSetting']('filterBy') return this.$store.getters['user/getUserSetting']('filterBy')
}, },
orderBy() {
return this.$store.getters['user/getUserSetting']('orderBy')
},
orderDesc() {
return this.$store.getters['user/getUserSetting']('orderDesc')
},
showGroups() { showGroups() {
return this.page !== '' && this.page !== 'search' && !this.selectedSeries return this.page !== '' && this.page !== 'search' && !this.selectedSeries
}, },
@ -165,6 +168,8 @@ export default {
var booksPerRow = Math.floor(width / this.bookWidth) var booksPerRow = Math.floor(width / this.bookWidth)
this.currSearchParams = this.buildSearchParams()
var entities = this.entities var entities = this.entities
var groups = [] var groups = []
var currentRow = 0 var currentRow = 0
@ -185,6 +190,8 @@ export default {
this.shelves = groups this.shelves = groups
}, },
async init() { async init() {
this.checkUpdateSearchParams()
this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0 this.wrapperClientWidth = this.$refs.wrapper ? this.$refs.wrapper.clientWidth : 0
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize') var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
@ -203,10 +210,41 @@ export default {
console.log('[AudioBookshelf] Audiobooks Updated') console.log('[AudioBookshelf] Audiobooks Updated')
this.setBookshelfEntities() this.setBookshelfEntities()
}, },
settingsUpdated(settings) { buildSearchParams() {
if (this.currFilterOrderKey !== this.filterOrderKey) { if (this.page === 'search' || this.page === 'series') {
this.setBookshelfEntities() return ''
} }
let searchParams = new URLSearchParams()
if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy)
}
if (this.orderBy) {
searchParams.set('order', this.orderBy)
searchParams.set('orderdesc', this.orderDesc ? 1 : 0)
}
return searchParams.toString()
},
checkUpdateSearchParams() {
var newSearchParams = this.buildSearchParams()
var currentQueryString = window.location.search
if (newSearchParams === '') {
return false
}
if (newSearchParams !== this.currSearchParams || newSearchParams !== currentQueryString) {
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
window.history.replaceState({ path: newurl }, '', newurl)
return true
}
return false
},
settingsUpdated(settings) {
var wasUpdated = this.checkUpdateSearchParams()
if (wasUpdated) this.setBookshelfEntities()
if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) { if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) {
var index = this.availableSizes.indexOf(settings.bookshelfCoverSize) var index = this.availableSizes.indexOf(settings.bookshelfCoverSize)
if (index >= 0) { if (index >= 0) {

View File

@ -14,7 +14,7 @@
</div> </div>
<div class="flex-grow" /> <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" /> <ui-text-input v-show="!selectedSeries" v-model="_keywordFilter" placeholder="Keyword Filter" :padding-y="1.5" clearable 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-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" /> <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>

View File

@ -41,6 +41,11 @@
<span class="font-normal block truncate py-2">No {{ sublist }}</span> <span class="font-normal block truncate py-2">No {{ sublist }}</span>
</div> </div>
</li> </li>
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('No Series'))">
<div class="flex items-center">
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">No Series</span>
</div>
</li>
<template v-for="item in sublistItems"> <template v-for="item in sublistItems">
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)"> <li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
@ -87,6 +92,11 @@ export default {
value: 'authors', value: 'authors',
sublist: true sublist: true
}, },
{
text: 'Narrator',
value: 'narrators',
sublist: true
},
{ {
text: 'Progress', text: 'Progress',
value: 'progress', value: 'progress',
@ -137,6 +147,9 @@ export default {
authors() { authors() {
return this.$store.getters['audiobooks/getUniqueAuthors'] return this.$store.getters['audiobooks/getUniqueAuthors']
}, },
narrators() {
return this.$store.getters['audiobooks/getUniqueNarrators']
},
progress() { progress() {
return ['Read', 'Unread', 'In Progress'] return ['Read', 'Unread', 'In Progress']
}, },

View File

@ -47,7 +47,7 @@
<div class="flex mt-2 -mx-1"> <div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1"> <div class="w-1/2 px-1">
<ui-text-input-with-label v-model="details.narrarator" label="Narrarator" /> <ui-text-input-with-label v-model="details.narrator" label="Narrator" />
</div> </div>
</div> </div>
</div> </div>
@ -88,7 +88,7 @@ export default {
subtitle: null, subtitle: null,
description: null, description: null,
author: null, author: null,
narrarator: null, narrator: null,
series: null, series: null,
volumeNumber: null, volumeNumber: null,
publishYear: null, publishYear: null,
@ -208,7 +208,7 @@ export default {
this.details.subtitle = this.book.subtitle this.details.subtitle = this.book.subtitle
this.details.description = this.book.description this.details.description = this.book.description
this.details.author = this.book.author this.details.author = this.book.author
this.details.narrarator = this.book.narrarator this.details.narrator = this.book.narrator
this.details.genres = this.book.genres || [] this.details.genres = this.book.genres || []
this.details.series = this.book.series this.details.series = this.book.series
this.details.volumeNumber = this.book.volumeNumber this.details.volumeNumber = this.book.volumeNumber

View File

@ -1,8 +1,13 @@
<template> <template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6"> <div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div class="flex mb-4"> <div class="w-full bg-primary px-4 py-2 flex items-center">
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`"> <div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<ui-btn color="primary">Edit Track Order</ui-btn> <span class="text-sm font-mono">{{ tracks.length }}</span>
</div>
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`" class="mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link> </nuxt-link>
</div> </div>
<table class="text-sm tracksTable"> <table class="text-sm tracksTable">
@ -18,9 +23,7 @@
<td class="text-center"> <td class="text-center">
<p>{{ track.index }}</p> <p>{{ track.index }}</p>
</td> </td>
<td class="font-book"> <td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
{{ track.filename }}
</td>
<td class="font-mono"> <td class="font-mono">
{{ $bytesPretty(track.size) }} {{ $bytesPretty(track.size) }}
</td> </td>
@ -47,7 +50,8 @@ export default {
data() { data() {
return { return {
tracks: null, tracks: null,
audioFiles: null audioFiles: null,
showFullPath: false
} }
}, },
watch: { watch: {

View File

@ -14,7 +14,7 @@
<!-- <ui-text-input :value="folder.fullPath" type="text" class="w-full" /> --> <!-- <ui-text-input :value="folder.fullPath" type="text" class="w-full" /> -->
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span> <span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
<ui-editable-text v-model="folder.fullPath" type="text" class="w-full" /> <ui-editable-text v-model="folder.fullPath" type="text" class="w-full" />
<span class="material-icons ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span> <span v-show="folders.length > 1" class="material-icons ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
</div> </div>
<p v-if="!folders.length" class="text-sm text-gray-300 px-1 py-2">No folders</p> <p v-if="!folders.length" class="text-sm text-gray-300 px-1 py-2">No folders</p>
<ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn> <ui-btn class="w-full mt-2" color="primary" @click="showDirectoryPicker = true">Browse for Folder</ui-btn>
@ -22,7 +22,7 @@
<div class="absolute bottom-0 left-0 w-full py-4 px-4"> <div class="absolute bottom-0 left-0 w-full py-4 px-4">
<div class="flex items-center"> <div class="flex items-center">
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn color="success" @click="submit">{{ library ? 'Update Library' : 'Create Library' }}</ui-btn> <ui-btn v-show="!disableSubmit" color="success" :disabled="disableSubmit" @click="submit">{{ library ? 'Update Library' : 'Create Library' }}</ui-btn>
</div> </div>
</div> </div>
</div> </div>
@ -43,8 +43,7 @@ export default {
return { return {
name: '', name: '',
folders: [], folders: [],
showDirectoryPicker: false, showDirectoryPicker: false
newLibraryName: ''
} }
}, },
computed: { computed: {
@ -54,6 +53,18 @@ export default {
}, },
folderPaths() { folderPaths() {
return this.folders.map((f) => f.fullPath) return this.folders.map((f) => f.fullPath)
},
disableSubmit() {
if (!this.library) {
return false
}
var newfolderpaths = this.folderPaths.join(',')
var origfolderpaths = this.library.folders.map((f) => f.fullPath).join(',')
console.log(newfolderpaths)
console.log(origfolderpaths)
console.log(newfolderpaths === origfolderpaths)
return newfolderpaths === origfolderpaths && this.name === this.library.name
} }
}, },
methods: { methods: {

View File

@ -30,11 +30,12 @@
<p class="text-gray-300">Note: folders already mapped will not be shown</p> <p class="text-gray-300">Note: folders already mapped will not be shown</p>
</div> </div>
<div class="absolute bottom-0 left-0 w-full py-4 px-4"> <div class="absolute bottom-0 left-0 w-full py-4 px-8">
<div class="flex items-center"> <ui-btn :disabled="!selectedPath" color="primary" class="w-full mt-2" @click="selectFolder">Select Folder Path</ui-btn>
<!-- <div class="flex items-center">
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn color="success" @click="selectFolder">Select</ui-btn> <ui-btn color="success" @click="selectFolder">Select</ui-btn>
</div> </div> -->
</div> </div>
</div> </div>
</template> </template>

View File

@ -2,11 +2,14 @@
<div class="w-full my-2"> <div class="w-full my-2">
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar"> <div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-4">Other Files</p> <p class="pr-4">Other Files</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ files.length }}</span> <div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ files.length }}</span>
</div>
<div class="flex-grow" /> <div class="flex-grow" />
<!-- <nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4"> <!-- <nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn> <ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link> --> </nuxt-link> -->
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''"> <div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
<span class="material-icons text-4xl">expand_more</span> <span class="material-icons text-4xl">expand_more</span>
</div> </div>
@ -15,13 +18,13 @@
<div class="w-full" v-show="showFiles"> <div class="w-full" v-show="showFiles">
<table class="text-sm tracksTable"> <table class="text-sm tracksTable">
<tr class="font-book"> <tr class="font-book">
<th class="text-left">Path</th> <th class="text-left px-4">Path</th>
<th class="text-left">Filetype</th> <th class="text-left px-4">Filetype</th>
</tr> </tr>
<template v-for="file in files"> <template v-for="file in files">
<tr :key="file.path"> <tr :key="file.path">
<td class="font-book pl-2"> <td class="font-book pl-2">
{{ file.path }} {{ showFullPath ? file.fullPath : file.path }}
</td> </td>
<td class="text-xs"> <td class="text-xs">
<p>{{ file.filetype }}</p> <p>{{ file.filetype }}</p>
@ -45,7 +48,8 @@ export default {
}, },
data() { data() {
return { return {
showFiles: false showFiles: false,
showFullPath: false
} }
}, },
computed: {}, computed: {},

View File

@ -2,8 +2,12 @@
<div class="w-full my-2"> <div class="w-full my-2">
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar"> <div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-4">Audio Tracks</p> <p class="pr-4">Audio Tracks</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> <div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
<span class="text-sm font-mono">{{ tracks.length }}</span>
</div>
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-4"> <nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn> <ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link> </nuxt-link>
@ -15,18 +19,18 @@
<div class="w-full" v-show="showTracks"> <div class="w-full" v-show="showTracks">
<table class="text-sm tracksTable"> <table class="text-sm tracksTable">
<tr class="font-book"> <tr class="font-book">
<th>#</th> <th class="w-10">#</th>
<th class="text-left">Filename</th> <th class="text-left">Filename</th>
<th class="text-left">Size</th> <th class="text-left w-20">Size</th>
<th class="text-left">Duration</th> <th class="text-left w-20">Duration</th>
<th v-if="userCanDownload" class="text-center">Download</th> <th v-if="userCanDownload" class="text-center w-20">Download</th>
</tr> </tr>
<template v-for="track in tracksCleaned"> <template v-for="track in tracksCleaned">
<tr :key="track.index"> <tr :key="track.index">
<td class="text-center"> <td class="text-center">
<p>{{ track.index }}</p> <p>{{ track.index }}</p>
</td> </td>
<td class="font-book">{{ track.filename }}</td> <td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
<td class="font-mono"> <td class="font-mono">
{{ $bytesPretty(track.size) }} {{ $bytesPretty(track.size) }}
</td> </td>
@ -58,7 +62,8 @@ export default {
}, },
data() { data() {
return { return {
showTracks: false showTracks: false,
showFullPath: false
} }
}, },
computed: { computed: {

View File

@ -56,6 +56,9 @@ export default {
if (this.paddingX !== undefined) { if (this.paddingX !== undefined) {
list.push(`px-${this.paddingX}`) list.push(`px-${this.paddingX}`)
} }
if (this.disabled) {
list.push('cursor-not-allowed')
}
return list return list
} }
}, },

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="relative w-full" v-click-outside="clickOutside"> <div class="relative w-full" v-click-outside="clickOutside">
<p class="text-sm text-opacity-75 mb-1">{{ label }}</p> <p class="text-sm text-opacity-75 mb-1">{{ label }}</p>
<button type="button" :disabled="disabled" class="relative h-10 w-full bg-fg border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu"> <button type="button" :disabled="disabled" class="relative h-10 w-full bg-fg border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer bg-primary" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
<span class="flex items-center"> <span class="flex items-center">
<span class="block truncate">{{ selectedText }}</span> <span class="block truncate">{{ selectedText }}</span>
</span> </span>

View File

@ -1,5 +1,10 @@
<template> <template>
<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" /> <div class="relative">
<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 h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
<span class="material-icons text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
</div>
</div>
</template> </template>
<script> <script>
@ -20,7 +25,8 @@ export default {
paddingX: { paddingX: {
type: Number, type: Number,
default: 3 default: 3
} },
clearable: Boolean
}, },
data() { data() {
return {} return {}
@ -42,6 +48,9 @@ export default {
} }
}, },
methods: { methods: {
clear() {
this.inputValue = ''
},
focused() { focused() {
this.$emit('focus') this.$emit('focus')
}, },

View File

@ -304,6 +304,12 @@ export default {
this.initializeSocket() this.initializeSocket()
this.$store.dispatch('libraries/load') this.$store.dispatch('libraries/load')
// If experimental features set in local storage
var experimentalFeaturesSaved = localStorage.getItem('experimental')
if (experimentalFeaturesSaved === '1') {
this.$store.commit('setExperimentalFeatures', true)
}
this.$store this.$store
.dispatch('checkForUpdate') .dispatch('checkForUpdate')
.then((res) => { .then((res) => {

View File

@ -35,7 +35,7 @@ module.exports = {
], ],
link: [ link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }, { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Fira+Mono&family=Ubuntu+Mono&family=Open+Sans:wght@400;600&family=Gentium+Book+Basic' }, { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Fira+Mono&family=Ubuntu+Mono&family=Gentium+Book+Basic&&family=Source+Sans+Pro:wght@300;400;600' },
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/icon?family=Material+Icons' } { rel: 'stylesheet', href: 'https://fonts.googleapis.com/icon?family=Material+Icons' }
] ]
}, },

View File

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "1.4.0", "version": "1.4.1",
"description": "Audiobook manager and player", "description": "Audiobook manager and player",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -23,7 +23,7 @@
<div class="flex items-center py-2"> <div class="flex items-center py-2">
<p v-if="isRoot" class="text-error py-2 text-xs">* Root user is the only user that can have an empty password</p> <p v-if="isRoot" class="text-error py-2 text-xs">* Root user is the only user that can have an empty password</p>
<div class="flex-grow" /> <div class="flex-grow" />
<ui-btn type="submit" :loading="changingPassword" color="success">Submit</ui-btn> <ui-btn v-show="password && newPassword && confirmPassword" type="submit" :loading="changingPassword" color="success">Submit</ui-btn>
</div> </div>
</form> </form>
</div> </div>

View File

@ -10,22 +10,79 @@
</div> </div>
<div class="flex-grow px-10"> <div class="flex-grow px-10">
<div class="flex"> <div class="flex">
<div class="mb-2"> <div class="mb-4">
<h1 class="text-2xl font-book leading-7"> <div class="flex items-end">
{{ title }}<span v-if="isDeveloperMode"> ({{ audiobook.ino }})</span> <h1 class="text-3xl font-sans">
</h1> {{ title }}<span v-if="isDeveloperMode"> ({{ audiobook.ino }})</span>
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3> </h1>
<div class="w-min"> <p v-if="subtitle" class="ml-4 text-gray-400 text-2xl">{{ subtitle }}</p>
<ui-tooltip :text="authorTooltipText" direction="bottom">
<span class="text-sm text-gray-100 leading-7 whitespace-nowrap">by {{ author }}</span>
</ui-tooltip>
</div> </div>
<p class="mb-2 mt-0.5 text-gray-100 text-xl">
by <nuxt-link v-if="author" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}</nuxt-link
><span v-else>Unknown</span>
</p>
<h3 v-if="series" class="font-sans text-gray-300 text-lg leading-7 mb-4">{{ seriesText }}</h3>
<!-- <div class="w-min">
<ui-tooltip :text="authorTooltipText" direction="bottom">
<span class="text-base text-gray-100 leading-8 whitespace-nowrap"><span class="text-white text-opacity-60">By:</span> {{ author }}</span>
</ui-tooltip>
</div> -->
<div v-if="narrator" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Narrated By</span>
</div>
<div>
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link>
</div>
</div>
<div v-if="publishYear" class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Publish Year</span>
</div>
<div>
{{ publishYear }}
</div>
</div>
<div class="flex py-0.5" v-if="genres.length">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Genres</span>
</div>
<div>
<template v-for="(genre, index) in genres">
<nuxt-link :key="genre" :to="`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
><span :key="index" v-if="index < genres.length - 1">,&nbsp;</span>
</template>
</div>
</div>
<div class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Duration</span>
</div>
<div>
{{ durationPretty }}
</div>
</div>
<div class="flex py-0.5">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Size</span>
</div>
<div>
{{ sizePretty }}
</div>
</div>
<!--
<p v-if="narrator" class="text-base">
<span class="text-white text-opacity-60">By:</span> <nuxt-link :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}</nuxt-link>
</p> -->
<!-- <p v-if="narrator" class="text-base"><span class="text-white text-opacity-60">Narrated by:</span> {{ narrator }}</p>
<p v-if="publishYear" class="text-base"><span class="text-white text-opacity-60">Publish year:</span> {{ publishYear }}</p>
<p v-if="genres.length" class="text-base"><span class="text-white text-opacity-60">Genres:</span> {{ genres.join(', ') }}</p> -->
</div> </div>
<div class="flex-grow" /> <div class="flex-grow" />
</div> </div>
<p class="text-gray-300 text-sm my-1">
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span>
</p>
<div v-if="progressPercent > 0 && progressPercent < 1" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-200 relative max-w-max" :class="resettingProgress ? 'opacity-25' : ''"> <div v-if="progressPercent > 0 && progressPercent < 1" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-200 relative max-w-max" :class="resettingProgress ? 'opacity-25' : ''">
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p> <p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p> <p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
@ -62,12 +119,10 @@
</ui-tooltip> </ui-tooltip>
<ui-btn v-if="isDeveloperMode" class="mx-2" @click="openRssFeed">Open RSS Feed</ui-btn> <ui-btn v-if="isDeveloperMode" class="mx-2" @click="openRssFeed">Open RSS Feed</ui-btn>
<div class="flex-grow" />
</div> </div>
<div class="my-4"> <div class="my-4 max-w-2xl">
<p class="text-sm text-gray-100">{{ description }}</p> <p class="text-base text-gray-100">{{ description }}</p>
</div> </div>
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4"> <div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
@ -181,12 +236,27 @@ export default {
invalidParts() { invalidParts() {
return this.audiobook.invalidParts || [] return this.audiobook.invalidParts || []
}, },
libraryId() {
return this.audiobook.libraryId
},
audiobookId() { audiobookId() {
return this.audiobook.id return this.audiobook.id
}, },
title() { title() {
return this.book.title || 'No Title' return this.book.title || 'No Title'
}, },
publishYear() {
return this.book.publishYear
},
narrator() {
return this.book.narrator
},
subtitle() {
return this.book.subtitle
},
genres() {
return this.book.genres || []
},
author() { author() {
return this.book.author || 'Unknown' return this.book.author || 'Unknown'
}, },
@ -338,6 +408,7 @@ export default {
this.$axios this.$axios
.$get(`/api/audiobook/${this.audiobookId}`) .$get(`/api/audiobook/${this.audiobookId}`)
.then((audiobook) => { .then((audiobook) => {
console.log('Updated audiobook', audiobook)
this.audiobook = audiobook this.audiobook = audiobook
}) })
.catch((error) => { .catch((error) => {

View File

@ -42,7 +42,7 @@
<div class="flex mt-2 -mx-1"> <div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1"> <div class="w-1/2 px-1">
<ui-text-input-with-label v-model="audiobook.book.narrarator" label="Narrarator" /> <ui-text-input-with-label v-model="audiobook.book.narrator" label="Narrator" />
</div> </div>
</div> </div>
</div> </div>
@ -94,6 +94,9 @@ export default {
}, },
seriesItems() { seriesItems() {
return [...this.series, ...this.newSeriesItems] return [...this.series, ...this.newSeriesItems]
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
} }
}, },
methods: { methods: {
@ -130,7 +133,7 @@ export default {
this.isProcessing = false this.isProcessing = false
if (data.updates) { if (data.updates) {
this.$toast.success(`Successfully updated ${data.updates} audiobooks`) this.$toast.success(`Successfully updated ${data.updates} audiobooks`)
this.$router.replace('/library') this.$router.replace(`/library/${this.currentLibraryId}`)
} else { } else {
this.$toast.warning('No updates were necessary') this.$toast.warning('No updates were necessary')
} }

View File

@ -33,52 +33,6 @@
</div> </div>
</div> </div>
<!-- <div class="py-4">
<p class="text-2xl">Scanner</p>
<div class="flex items-start py-2">
<div class="py-2">
<div class="flex items-center">
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="updateScannerParseSubtitle" />
<ui-tooltip :text="parseSubtitleTooltip">
<p class="pl-4 text-lg">Parse subtitles <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
</div>
<div class="flex-grow" />
<div class="w-40 flex flex-col">
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
<div class="w-full mb-4">
<ui-tooltip direction="bottom" text="(Warning: Long running task!) Attempts to lookup and match a cover with all audiobooks that don't have one." class="w-full">
<ui-btn color="primary" class="w-full" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
</ui-tooltip>
</div>
</div>
</div>
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="py-4 mb-4">
<p class="text-2xl">Metadata</p>
<div class="flex items-start py-2">
<div class="py-2">
<div class="flex items-center">
<ui-toggle-switch v-model="storeCoversInAudiobookDir" :disabled="updatingServerSettings" @input="updateCoverStorageDestination" />
<ui-tooltip :text="coverDestinationTooltip">
<p class="pl-4 text-lg">Store covers with audiobook <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
</div>
<div class="flex-grow" />
<div class="w-40 flex flex-col">
<ui-tooltip :text="saveMetadataTooltip" direction="bottom" class="w-full">
<ui-btn color="primary" small class="w-full" @click="saveMetadataFiles">Save Metadata</ui-btn>
</ui-tooltip>
</div>
</div>
</div> -->
<div class="h-0.5 bg-primary bg-opacity-50 w-full" /> <div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="flex items-center py-4"> <div class="flex items-center py-4">
@ -108,7 +62,7 @@
<div class="flex items-center"> <div class="flex items-center">
<div> <div>
<div class="flex items-center"> <div class="flex items-center">
<ui-toggle-switch v-model="showExperimentalFeatures" @input="toggleShowExperimentalFeatures" /> <ui-toggle-switch v-model="showExperimentalFeatures" />
<ui-tooltip :text="experimentalFeaturesTooltip"> <ui-tooltip :text="experimentalFeaturesTooltip">
<p class="pl-4 text-lg">Experimental Features <span class="material-icons icon-text">info_outlined</span></p> <p class="pl-4 text-lg">Experimental Features <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip> </ui-tooltip>
@ -177,14 +131,20 @@ export default {
isScanningCovers() { isScanningCovers() {
return this.$store.state.isScanningCovers return this.$store.state.isScanningCovers
}, },
showExperimentalFeatures() { showExperimentalFeatures: {
return this.$store.state.showExperimentalFeatures get() {
return this.$store.state.showExperimentalFeatures
},
set(val) {
this.$store.commit('setExperimentalFeatures', val)
}
} }
}, },
methods: { methods: {
toggleShowExperimentalFeatures() { // toggleShowExperimentalFeatures() {
this.$store.commit('setExperimentalFeatures', !this.showExperimentalFeatures) // var newExperimentalValue = !this.showExperimentalFeatures
}, // this.$store.commit('setExperimentalFeatures', newExperimentalValue)
// },
updateScannerFindCovers(val) { updateScannerFindCovers(val) {
this.updateServerSettings({ this.updateServerSettings({
scannerFindCovers: !!val scannerFindCovers: !!val
@ -222,9 +182,6 @@ export default {
scan() { scan() {
this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId) this.$root.socket.emit('scan', this.$store.state.libraries.currentLibraryId)
}, },
scanCovers() {
this.$root.socket.emit('scan_covers')
},
saveMetadataComplete(result) { saveMetadataComplete(result) {
this.savingMetadata = false this.savingMetadata = false
if (!result) return if (!result) return

View File

@ -42,14 +42,19 @@ export const getters = {
var settings = rootState.user.settings || {} var settings = rootState.user.settings || {}
var filterBy = settings.filterBy || '' var filterBy = settings.filterBy || ''
var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress'] var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.')) var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) { if (group) {
var filter = decode(filterBy.replace(`${group}.`, '')) var filterVal = filterBy.replace(`${group}.`, '')
var filter = decode(filterVal)
if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter)) if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter)) else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter) else if (group === 'series') {
if (filter === 'No Series') filtered = filtered.filter(ab => ab.book && !ab.book.series)
else filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
}
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter) else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
else if (group === 'narrators') filtered = filtered.filter(ab => ab.book && ab.book.narrator === filter)
else if (group === 'progress') { else if (group === 'progress') {
filtered = filtered.filter(ab => { filtered = filtered.filter(ab => {
var userAudiobook = rootGetters['user/getUserAudiobook'](ab.id) var userAudiobook = rootGetters['user/getUserAudiobook'](ab.id)
@ -62,7 +67,7 @@ export const getters = {
} }
} }
if (state.keywordFilter) { if (state.keywordFilter) {
const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrarator'] const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrator']
const keyworkFilter = state.keywordFilter.toLowerCase() const keyworkFilter = state.keywordFilter.toLowerCase()
return filtered.filter(ab => { return filtered.filter(ab => {
if (!ab.book) return false if (!ab.book) return false
@ -76,6 +81,7 @@ export const getters = {
var direction = settings.orderDesc ? 'desc' : 'asc' var direction = settings.orderDesc ? 'desc' : 'asc'
var filtered = getters.getFiltered() var filtered = getters.getFiltered()
var orderByNumber = settings.orderBy === 'book.volumeNumber' var orderByNumber = settings.orderBy === 'book.volumeNumber'
return sort(filtered)[direction]((ab) => { return sort(filtered)[direction]((ab) => {
// Supports dot notation strings i.e. "book.title" // Supports dot notation strings i.e. "book.title"
@ -118,6 +124,10 @@ export const getters = {
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author) var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
return [...new Set(_authors)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1) return [...new Set(_authors)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
}, },
getUniqueNarrators: (state) => {
var _narrators = state.audiobooks.filter(ab => !!(ab.book && ab.book.narrator)).map(ab => ab.book.narrator)
return [...new Set(_narrators)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
},
getGenresUsed: (state) => { getGenresUsed: (state) => {
var _genres = [] var _genres = []
state.audiobooks.filter(ab => !!(ab.book && ab.book.genres)).forEach(ab => _genres = _genres.concat(ab.book.genres)) state.audiobooks.filter(ab => !!(ab.book && ab.book.genres)).forEach(ab => _genres = _genres.concat(ab.book.genres))
@ -246,8 +256,8 @@ export const mutations = {
}) })
}, },
addUpdate(state, audiobook) { addUpdate(state, audiobook) {
if (audiobook.libraryId !== state.loadedLibraryId) { if (state.loadedLibraryId && audiobook.libraryId !== state.loadedLibraryId) {
console.warn('Invalid library', audiobook) console.warn('Invalid library', audiobook, 'loaded library', state.loadedLibraryId, '"')
return return
} }

View File

@ -9,10 +9,6 @@ export const state = () => ({
showEditModal: false, showEditModal: false,
selectedAudiobook: null, selectedAudiobook: null,
playOnLoad: false, playOnLoad: false,
// isScanning: false,
// isScanningCovers: false,
// scanProgress: null,
// coverScanProgress: null,
developerMode: false, developerMode: false,
selectedAudiobooks: [], selectedAudiobooks: [],
processingBatch: false, processingBatch: false,
@ -114,36 +110,18 @@ export const mutations = {
setShowEditModal(state, val) { setShowEditModal(state, val) {
state.showEditModal = val state.showEditModal = val
}, },
// setIsScanning(state, isScanning) {
// state.isScanning = isScanning
// },
// setScanProgress(state, scanProgress) {
// if (scanProgress && scanProgress.progress > 0) state.isScanning = true
// state.scanProgress = scanProgress
// },
// setIsScanningCovers(state, isScanningCovers) {
// state.isScanningCovers = isScanningCovers
// },
// setCoverScanProgress(state, coverScanProgress) {
// if (coverScanProgress && coverScanProgress.progress > 0) state.isScanningCovers = true
// state.coverScanProgress = coverScanProgress
// },
setDeveloperMode(state, val) { setDeveloperMode(state, val) {
state.developerMode = val state.developerMode = val
}, },
setSelectedAudiobooks(state, audiobooks) { setSelectedAudiobooks(state, audiobooks) {
Vue.set(state, 'selectedAudiobooks', audiobooks) Vue.set(state, 'selectedAudiobooks', audiobooks)
// state.selectedAudiobooks = audiobooks
}, },
toggleAudiobookSelected(state, audiobookId) { toggleAudiobookSelected(state, audiobookId) {
if (state.selectedAudiobooks.includes(audiobookId)) { if (state.selectedAudiobooks.includes(audiobookId)) {
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId) state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
} else { } else {
var newSel = state.selectedAudiobooks.concat([audiobookId]) var newSel = state.selectedAudiobooks.concat([audiobookId])
// state.selectedAudiobooks = newSel
console.log('Setting toggle on sel', newSel)
Vue.set(state, 'selectedAudiobooks', newSel) Vue.set(state, 'selectedAudiobooks', newSel)
// state.selectedAudiobooks.push(audiobookId)
} }
}, },
setProcessingBatch(state, val) { setProcessingBatch(state, val) {
@ -151,5 +129,6 @@ export const mutations = {
}, },
setExperimentalFeatures(state, val) { setExperimentalFeatures(state, val) {
state.showExperimentalFeatures = val state.showExperimentalFeatures = val
localStorage.setItem('experimental', val ? 1 : 0)
} }
} }

View File

@ -22,9 +22,6 @@ export const getters = {
getUserSetting: (state) => (key) => { getUserSetting: (state) => (key) => {
return state.settings ? state.settings[key] || null : null return state.settings ? state.settings[key] || null : null
}, },
getFilterOrderKey: (state) => {
return Object.values(state.settings).join('-')
},
getUserCanUpdate: (state) => { getUserCanUpdate: (state) => {
return state.user && state.user.permissions ? !!state.user.permissions.update : false return state.user && state.user.permissions ? !!state.user.permissions.update : false
}, },

View File

@ -45,7 +45,7 @@ module.exports = {
none: 'none' none: 'none'
}, },
fontFamily: { fontFamily: {
sans: ['Open Sans', ...defaultTheme.fontFamily.sans], sans: ['Source Sans Pro', ...defaultTheme.fontFamily.sans],
mono: ['Ubuntu Mono', ...defaultTheme.fontFamily.mono], mono: ['Ubuntu Mono', ...defaultTheme.fontFamily.mono],
book: ['Gentium Book Basic', 'serif'] book: ['Gentium Book Basic', 'serif']
} }

View File

@ -9,7 +9,7 @@
<Privileged>false</Privileged> <Privileged>false</Privileged>
<Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support> <Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support>
<Project>https://github.com/advplyr/audiobookshelf</Project> <Project>https://github.com/advplyr/audiobookshelf</Project>
<Overview>**(Android app in beta, try it out)** Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free &amp; open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview> <Overview>**(Android app is live)** Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free &amp; open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview>
<Category>MediaApp:Books MediaServer:Books</Category> <Category>MediaApp:Books MediaServer:Books</Category>
<WebUI>http://[IP]:[PORT:80]</WebUI> <WebUI>http://[IP]:[PORT:80]</WebUI>
<TemplateURL>https://raw.githubusercontent.com/advplyr/docker-templates/master/audiobookshelf.xml</TemplateURL> <TemplateURL>https://raw.githubusercontent.com/advplyr/docker-templates/master/audiobookshelf.xml</TemplateURL>
@ -48,7 +48,18 @@
<Mode>rw</Mode> <Mode>rw</Mode>
</Volume> </Volume>
</Data> </Data>
<Environment/> <Environment>
<Variable>
<Value>99</Value>
<Name>AUDIOBOOKSHELF_UID</Name>
<Mode/>
</Variable>
<Variable>
<Value>100</Value>
<Name>AUDIOBOOKSHELF_GID</Name>
<Mode/>
</Variable>
</Environment>
<Labels/> <Labels/>
<Config Name="Audiobooks" Target="/audiobooks" Default="" Mode="rw" Description="Container Path: /audiobooks" Type="Path" Display="always" Required="true" Mask="false" /> <Config Name="Audiobooks" Target="/audiobooks" Default="" Mode="rw" Description="Container Path: /audiobooks" Type="Path" Display="always" Required="true" Mask="false" />
<Config Name="Config" Target="/config" Default="/mnt/user/appdata/audiobookshelf/config/" Mode="rw" Description="Container Path: /config" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/audiobookshelf/config/</Config> <Config Name="Config" Target="/config" Default="/mnt/user/appdata/audiobookshelf/config/" Mode="rw" Description="Container Path: /config" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/audiobookshelf/config/</Config>

View File

@ -18,8 +18,10 @@ const PORT = process.env.PORT || 80
const CONFIG_PATH = process.env.CONFIG_PATH || '/config' const CONFIG_PATH = process.env.CONFIG_PATH || '/config'
const AUDIOBOOK_PATH = process.env.AUDIOBOOK_PATH || '/audiobooks' const AUDIOBOOK_PATH = process.env.AUDIOBOOK_PATH || '/audiobooks'
const METADATA_PATH = process.env.METADATA_PATH || '/metadata' const METADATA_PATH = process.env.METADATA_PATH || '/metadata'
const UID = process.env.AUDIOBOOKSHELF_UID || 99
const GID = process.env.AUDIOBOOKSHELF_GID || 100
console.log('Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) console.log('Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
const Server = new server(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) const Server = new server(PORT, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
Server.start() Server.start()

View File

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "1.4.0", "version": "1.4.1",
"description": "Self-hosted audiobook server for managing and playing audiobooks", "description": "Self-hosted audiobook server for managing and playing audiobooks",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -24,8 +24,10 @@ const PORT = options.port || process.env.PORT || 3333
const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config') const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('config')
const AUDIOBOOK_PATH = inputAudiobook || process.env.AUDIOBOOK_PATH || Path.resolve('audiobooks') const AUDIOBOOK_PATH = inputAudiobook || process.env.AUDIOBOOK_PATH || Path.resolve('audiobooks')
const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata') const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata')
const UID = 99
const GID = 100
console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
const Server = new server(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) const Server = new server(PORT, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH)
Server.start() Server.start()

View File

@ -724,55 +724,55 @@ class Scanner {
return libraryScanResults return libraryScanResults
} }
async scanCovers() { // async scanCovers() {
var audiobooksNeedingCover = this.audiobooks.filter(ab => !ab.cover && ab.author) // var audiobooksNeedingCover = this.audiobooks.filter(ab => !ab.cover && ab.author)
var found = 0 // var found = 0
var notFound = 0 // var notFound = 0
var failed = 0 // var failed = 0
for (let i = 0; i < audiobooksNeedingCover.length; i++) { // for (let i = 0; i < audiobooksNeedingCover.length; i++) {
var audiobook = audiobooksNeedingCover[i] // var audiobook = audiobooksNeedingCover[i]
var options = { // var options = {
titleDistance: 2, // titleDistance: 2,
authorDistance: 2 // authorDistance: 2
} // }
var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.author, options) // var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.author, options)
if (results.length) { // if (results.length) {
Logger.debug(`[Scanner] Found best cover for "${audiobook.title}"`) // Logger.debug(`[Scanner] Found best cover for "${audiobook.title}"`)
var coverUrl = results[0] // var coverUrl = results[0]
var result = await this.coverController.downloadCoverFromUrl(audiobook, coverUrl) // var result = await this.coverController.downloadCoverFromUrl(audiobook, coverUrl)
if (result.error) { // if (result.error) {
failed++ // failed++
} else { // } else {
found++ // found++
await this.db.updateAudiobook(audiobook) // await this.db.updateAudiobook(audiobook)
this.emitter('audiobook_updated', audiobook.toJSONMinified()) // this.emitter('audiobook_updated', audiobook.toJSONMinified())
} // }
} else { // } else {
notFound++ // notFound++
} // }
var progress = Math.round(100 * (i + 1) / audiobooksNeedingCover.length) // var progress = Math.round(100 * (i + 1) / audiobooksNeedingCover.length)
this.emitter('scan_progress', { // this.emitter('scan_progress', {
scanType: 'covers', // scanType: 'covers',
progress: { // progress: {
total: audiobooksNeedingCover.length, // total: audiobooksNeedingCover.length,
done: i + 1, // done: i + 1,
progress // progress
} // }
}) // })
if (this.cancelScan) { // if (this.cancelScan) {
this.cancelScan = false // this.cancelScan = false
break // break
} // }
} // }
return { // return {
found, // found,
notFound, // notFound,
failed // failed
} // }
} // }
async saveMetadata(audiobookId) { async saveMetadata(audiobookId) {
if (audiobookId) { if (audiobookId) {

View File

@ -10,6 +10,7 @@ const { version } = require('../package.json')
// Utils // Utils
const { ScanResult } = require('./utils/constants') const { ScanResult } = require('./utils/constants')
const filePerms = require('./utils/filePerms')
const Logger = require('./Logger') const Logger = require('./Logger')
// Classes // Classes
@ -26,16 +27,18 @@ const CoverController = require('./CoverController')
class Server { class Server {
constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) { constructor(PORT, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
this.Port = PORT this.Port = PORT
this.Uid = isNaN(UID) ? 0 : Number(UID)
this.Gid = isNaN(GID) ? 0 : Number(GID)
this.Host = '0.0.0.0' this.Host = '0.0.0.0'
this.ConfigPath = Path.normalize(CONFIG_PATH) this.ConfigPath = Path.normalize(CONFIG_PATH)
this.AudiobookPath = Path.normalize(AUDIOBOOK_PATH) this.AudiobookPath = Path.normalize(AUDIOBOOK_PATH)
this.MetadataPath = Path.normalize(METADATA_PATH) this.MetadataPath = Path.normalize(METADATA_PATH)
fs.ensureDirSync(CONFIG_PATH) fs.ensureDirSync(CONFIG_PATH, 0o774)
fs.ensureDirSync(METADATA_PATH) fs.ensureDirSync(METADATA_PATH, 0o774)
fs.ensureDirSync(AUDIOBOOK_PATH) fs.ensureDirSync(AUDIOBOOK_PATH, 0o774)
this.db = new Db(this.ConfigPath, this.AudiobookPath) this.db = new Db(this.ConfigPath, this.AudiobookPath)
this.auth = new Auth(this.db) this.auth = new Auth(this.db)
@ -217,7 +220,6 @@ class Server {
// Scanning // Scanning
socket.on('scan', this.scan.bind(this)) socket.on('scan', this.scan.bind(this))
socket.on('scan_covers', this.scanCovers.bind(this))
socket.on('cancel_scan', this.cancelScan.bind(this)) socket.on('cancel_scan', this.cancelScan.bind(this))
socket.on('scan_audiobook', (audiobookId) => this.scanAudiobook(socket, audiobookId)) socket.on('scan_audiobook', (audiobookId) => this.scanAudiobook(socket, audiobookId))
socket.on('save_metadata', (audiobookId) => this.saveMetadata(socket, audiobookId)) socket.on('save_metadata', (audiobookId) => this.saveMetadata(socket, audiobookId))
@ -280,16 +282,6 @@ class Server {
socket.emit('audiobook_scan_complete', scanResultName) socket.emit('audiobook_scan_complete', scanResultName)
} }
async scanCovers() {
Logger.info('[Server] Start cover scan')
this.isScanningCovers = true
// this.emitter('scan_start', 'covers')
var results = await this.scanner.scanCovers()
this.isScanningCovers = false
// this.emitter('scan_complete', { scanType: 'covers', results })
Logger.info('[Server] Cover scan complete')
}
cancelScan(id) { cancelScan(id) {
Logger.debug('[Server] Cancel scan', id) Logger.debug('[Server] Cancel scan', id)
this.scanner.cancelLibraryScan[id] = true this.scanner.cancelLibraryScan[id] = true
@ -359,6 +351,9 @@ class Server {
return res.status(500).error(`Invalid post data`) return res.status(500).error(`Invalid post data`)
} }
// For setting permissions recursively
var firstDirPath = Path.join(folder.fullPath, author)
var outputDirectory = '' var outputDirectory = ''
if (series && series.length && series !== 'null') { if (series && series.length && series !== 'null') {
outputDirectory = Path.join(folder.fullPath, author, series, title) outputDirectory = Path.join(folder.fullPath, author, series, title)
@ -373,16 +368,24 @@ class Server {
} }
await fs.ensureDir(outputDirectory) await fs.ensureDir(outputDirectory)
Logger.info(`Uploading ${files.length} files to`, outputDirectory) Logger.info(`Uploading ${files.length} files to`, outputDirectory)
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
var file = files[i] var file = files[i]
var path = Path.join(outputDirectory, file.name) var path = Path.join(outputDirectory, file.name)
await file.mv(path).catch((error) => { await file.mv(path).then(() => {
return true
}).catch((error) => {
Logger.error('Failed to move file', path, error) Logger.error('Failed to move file', path, error)
return false
}) })
} }
Logger.info(`[Server] Setting owner/perms for first dir "${firstDirPath}"`)
await filePerms(firstDirPath, 0o774, this.Uid, this.Gid)
res.sendStatus(200) res.sendStatus(200)
} }

View File

@ -498,13 +498,13 @@ class Audiobook {
hasUpdates = true hasUpdates = true
} }
} }
// If reader.txt is new or forcing rescan then read it and update narrarator (will overwrite) // If reader.txt is new or forcing rescan then read it and update narrator (will overwrite)
var readerTxt = newOtherFiles.find(file => file.filename === 'reader.txt') var readerTxt = newOtherFiles.find(file => file.filename === 'reader.txt')
if (readerTxt && (!alreadyHasReaderTxt || forceRescan)) { if (readerTxt && (!alreadyHasReaderTxt || forceRescan)) {
var newReader = await readTextFile(readerTxt.fullPath) var newReader = await readTextFile(readerTxt.fullPath)
if (newReader) { if (newReader) {
Logger.debug(`[Audiobook] Sync Other File reader.txt: ${newReader}`) Logger.debug(`[Audiobook] Sync Other File reader.txt: ${newReader}`)
this.update({ book: { narrarator: newReader } }) this.update({ book: { narrator: newReader } })
hasUpdates = true hasUpdates = true
} }
} }
@ -712,8 +712,8 @@ class Audiobook {
} }
var readerText = await this.fetchTextFromTextFile('reader.txt') var readerText = await this.fetchTextFromTextFile('reader.txt')
if (readerText) { if (readerText) {
Logger.debug(`[Audiobook] "${this.title}" found reader.txt updating narrarator with "${readerText}"`) Logger.debug(`[Audiobook] "${this.title}" found reader.txt updating narrator with "${readerText}"`)
bookUpdatePayload.narrarator = readerText bookUpdatePayload.narrator = readerText
} }
if (Object.keys(bookUpdatePayload).length) { if (Object.keys(bookUpdatePayload).length) {
return this.update({ book: bookUpdatePayload }) return this.update({ book: bookUpdatePayload })

View File

@ -11,7 +11,7 @@ class Book {
this.author = null this.author = null
this.authorFL = null this.authorFL = null
this.authorLF = null this.authorLF = null
this.narrarator = null this.narrator = null
this.series = null this.series = null
this.volumeNumber = null this.volumeNumber = null
this.publishYear = null this.publishYear = null
@ -35,7 +35,7 @@ class Book {
get _title() { return this.title || '' } get _title() { return this.title || '' }
get _subtitle() { return this.subtitle || '' } get _subtitle() { return this.subtitle || '' }
get _narrarator() { return this.narrarator || '' } get _narrator() { return this.narrator || '' }
get _author() { return this.author || '' } get _author() { return this.author || '' }
get _series() { return this.series || '' } get _series() { return this.series || '' }
@ -52,7 +52,7 @@ class Book {
this.author = book.author this.author = book.author
this.authorFL = book.authorFL || null this.authorFL = book.authorFL || null
this.authorLF = book.authorLF || null this.authorLF = book.authorLF || null
this.narrarator = book.narrarator || null this.narrator = book.narrator || book.narrarator || null // Mispelled initially... need to catch those
this.series = book.series this.series = book.series
this.volumeNumber = book.volumeNumber || null this.volumeNumber = book.volumeNumber || null
this.publishYear = book.publishYear this.publishYear = book.publishYear
@ -75,7 +75,7 @@ class Book {
author: this.author, author: this.author,
authorFL: this.authorFL, authorFL: this.authorFL,
authorLF: this.authorLF, authorLF: this.authorLF,
narrarator: this.narrarator, narrator: this.narrator,
series: this.series, series: this.series,
volumeNumber: this.volumeNumber, volumeNumber: this.volumeNumber,
publishYear: this.publishYear, publishYear: this.publishYear,
@ -115,7 +115,7 @@ class Book {
this.title = data.title || null this.title = data.title || null
this.subtitle = data.subtitle || null this.subtitle = data.subtitle || null
this.author = data.author || null this.author = data.author || null
this.narrarator = data.narrarator || null this.narrator = data.narrator || data.narrarator || null
this.series = data.series || null this.series = data.series || null
this.volumeNumber = data.volumeNumber || null this.volumeNumber = data.volumeNumber || null
this.publishYear = data.publishYear || null this.publishYear = data.publishYear || null
@ -221,7 +221,7 @@ class Book {
const MetadataMapArray = [ const MetadataMapArray = [
{ {
tag: 'tagComposer', tag: 'tagComposer',
key: 'narrarator' key: 'narrator'
}, },
{ {
tag: 'tagDescription', tag: 'tagDescription',

85
server/utils/filePerms.js Normal file
View File

@ -0,0 +1,85 @@
const fs = require('fs-extra')
const Path = require('path')
const Logger = require('../Logger')
// Modified from:
// https://github.com/isaacs/chmodr/blob/master/chmodr.js
// If a party has r, add x
// so that dirs are listable
const dirMode = mode => {
if (mode & 0o400)
mode |= 0o100
if (mode & 0o40)
mode |= 0o10
if (mode & 0o4)
mode |= 0o1
return mode
}
const chmodrKid = (p, child, mode, uid, gid, cb) => {
if (typeof child === 'string')
return fs.lstat(Path.resolve(p, child), (er, stats) => {
if (er)
return cb(er)
stats.name = child
chmodrKid(p, stats, mode, uid, gid, cb)
})
if (child.isDirectory()) {
chmodr(Path.resolve(p, child.name), mode, uid, gid, er => {
if (er)
return cb(er)
var _path = Path.resolve(p, child.name)
fs.chmod(_path, dirMode(mode)).then(() => {
fs.chown(_path, uid, gid, cb)
})
})
} else {
var _path = Path.resolve(p, child.name)
fs.chmod(_path, mode).then(() => {
fs.chown(_path, uid, gid, cb)
})
}
}
const chmodr = (p, mode, uid, gid, cb) => {
fs.readdir(p, { withFileTypes: true }, (er, children) => {
// any error other than ENOTDIR means it's not readable, or
// doesn't exist. give up.
if (er && er.code !== 'ENOTDIR') return cb(er)
if (er) {
return fs.chmod(p, mode).then(() => {
fs.chown(p, uid, gid, cb)
})
}
if (!children.length) {
return fs.chmod(p, dirMode(mode)).then(() => {
fs.chown(p, uid, gid, cb)
})
}
let len = children.length
let errState = null
const then = er => {
if (errState) return
if (er) return cb(errState = er)
if (--len === 0) {
return fs.chmod(p, dirMode(mode)).then(() => {
fs.chown(p, uid, gid, cb)
})
}
}
children.forEach(child => chmodrKid(p, child, mode, uid, gid, then))
})
}
module.exports = (p, mode, uid, gid) => {
return new Promise((resolve) => {
Logger.debug(`[FilePerms] Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${p}"`)
chmodr(p, mode, uid, gid, resolve)
})
}

View File

@ -75,4 +75,14 @@ function secondsToTimestamp(seconds) {
} }
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}` return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
} }
module.exports.secondsToTimestamp = secondsToTimestamp module.exports.secondsToTimestamp = secondsToTimestamp
function setFileOwner(path, uid, gid) {
try {
return fs.chown(path, uid, gid).then(() => true)
} catch (err) {
console.error('Failed set file owner', err)
return false
}
}
module.exports.setFileOwner = setFileOwner

View File

@ -26,7 +26,7 @@ async function generate(audiobook, nfoFilename = 'metadata.nfo') {
'Title': book.title, 'Title': book.title,
'Subtitle': book.subtitle, 'Subtitle': book.subtitle,
'Author': book.author, 'Author': book.author,
'Narrator': book.narrarator, 'Narrator': book.narrator,
'Series': book.series, 'Series': book.series,
'Volume Number': book.volumeNumber, 'Volume Number': book.volumeNumber,
'Publish Year': book.publishYear, 'Publish Year': book.publishYear,