New api routes, updating web client pages, audiobooks to libraryItem migration

This commit is contained in:
advplyr 2022-03-10 18:45:02 -06:00
parent b97ed953f7
commit 2a30cc428f
51 changed files with 1225 additions and 654 deletions

View File

@ -85,8 +85,9 @@ export default {
},
async fetchCategories() {
var categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/categories?minified=1`)
.$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`)
.then((data) => {
console.log('Personalized data', data)
return data
})
.catch((error) => {

View File

@ -239,7 +239,7 @@ export default {
this.currentSFQueryString = this.buildSearchParams()
}
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `books/all` : this.entityName
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`

View File

@ -8,7 +8,7 @@
<!-- Alternative bookshelf title/author/sort -->
<div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
<span v-if="volumeNumber">#{{ volumeNumber }}&nbsp;</span>{{ displayTitle }}
{{ displayTitle }}
</p>
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
@ -21,7 +21,7 @@
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p>
</div>
<img v-show="audiobook" ref="cover" :src="bookCoverSrc" class="w-full h-full object-contain transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<img v-show="audiobook" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<!-- Placeholder Cover Title & Author -->
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
@ -136,42 +136,45 @@ export default {
showExperimentalFeatures() {
return this.store.state.showExperimentalFeatures
},
_audiobook() {
_libraryItem() {
return this.audiobook || {}
},
book() {
return this._audiobook.book || {}
media() {
return this._libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
placeholderUrl() {
return '/book_placeholder.jpg'
},
bookCoverSrc() {
return this.store.getters['audiobooks/getBookCoverSrc'](this._audiobook, this.placeholderUrl)
return this.store.getters['audiobooks/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
},
audiobookId() {
return this._audiobook.id
libraryItemId() {
return this._libraryItem.id
},
series() {
return this.book.series
return this.mediaMetadata.series
},
libraryId() {
return this._audiobook.libraryId
return this._libraryItem.libraryId
},
hasEbook() {
return this._audiobook.numEbooks
return this.media.numEbooks
},
hasTracks() {
return this._audiobook.numTracks
return this.media.numTracks
},
processingBatch() {
return this.store.state.processingBatch
},
booksInSeries() {
// Only added to audiobook object when collapseSeries is enabled
return this._audiobook.booksInSeries
return this._libraryItem.booksInSeries
},
hasCover() {
return !!this.book.cover
return !!this.media.coverPath
},
squareAspectRatio() {
return this.bookCoverAspectRatio === 1
@ -181,43 +184,49 @@ export default {
return this.width / baseSize
},
title() {
return this.book.title || ''
return this.mediaMetadata.title || ''
},
playIconFontSize() {
return Math.max(2, 3 * this.sizeMultiplier)
},
author() {
return this.book.author
authors() {
return this.mediaMetadata.authors || []
},
authorFL() {
return this.book.authorFL || this.author
author() {
return this.authors.map((au) => au.name).join(', ')
},
authorLF() {
return this.book.authorLF || this.author
return this.authors
.map((au) => {
var parts = au.name.split(' ')
if (parts.length === 1) return parts[0]
return `${parts[1]}, ${parts[0]}`
})
.join(', ')
},
volumeNumber() {
return this.book.volumeNumber || null
return this.mediaMetadata.volumeNumber || null
},
displayTitle() {
if (this.orderBy === 'book.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
return this.title.substr(4) + ', The'
}
return this.title
},
displayAuthor() {
if (this.orderBy === 'book.authorLF') return this.authorLF
return this.authorFL
if (this.orderBy === 'media.metadata.authorLF') return this.authorLF
return this.author
},
displaySortLine() {
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._audiobook.mtimeMs)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._audiobook.birthtimeMs)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._audiobook.addedAt)
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this._audiobook.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._audiobook.size)
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
return null
},
userProgress() {
return this.store.getters['user/getUserAudiobook'](this.audiobookId)
return this.store.getters['user/getUserAudiobook'](this.libraryItemId)
},
userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0
@ -229,7 +238,7 @@ export default {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
},
isStreaming() {
return this.store.getters['getAudiobookIdStreaming'] === this.audiobookId
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
},
showReadButton() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
@ -241,16 +250,16 @@ export default {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
},
isMissing() {
return this._audiobook.isMissing
return this._libraryItem.isMissing
},
isInvalid() {
return this._audiobook.isInvalid
return this._libraryItem.isInvalid
},
hasMissingParts() {
return this._audiobook.hasMissingParts
return this._libraryItem.hasMissingParts
},
hasInvalidParts() {
return this._audiobook.hasInvalidParts
return this._libraryItem.hasInvalidParts
},
errorText() {
if (this.isMissing) return 'Audiobook directory is missing!'
@ -349,11 +358,11 @@ export default {
return this.title
},
authorCleaned() {
if (!this.authorFL) return ''
if (this.authorFL.length > 30) {
return this.authorFL.slice(0, 27) + '...'
if (!this.author) return ''
if (this.author.length > 30) {
return this.author.slice(0, 27) + '...'
}
return this.authorFL
return this.author
},
isAlternativeBookshelfView() {
var constants = this.$constants || this.$nuxt.$constants
@ -382,7 +391,7 @@ export default {
var router = this.$router || this.$nuxt.$router
if (router) {
if (this.booksInSeries) router.push(`/library/${this.libraryId}/series/${this.$encode(this.series)}`)
else router.push(`/audiobook/${this.audiobookId}`)
else router.push(`/item/${this.libraryItemId}`)
}
}
},
@ -398,7 +407,7 @@ export default {
var toast = this.$toast || this.$nuxt.$toast
var axios = this.$axios || this.$nuxt.$axios
axios
.$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload)
.$patch(`/api/me/audiobook/${this.libraryItemId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
@ -425,7 +434,7 @@ export default {
rescan() {
this.rescanning = true
this._socket.once('audiobook_scan_complete', this.audiobookScanComplete)
this._socket.emit('scan_audiobook', this.audiobookId)
this._socket.emit('scan_libraryItem', this.libraryItemId)
},
showEditModalTracks() {
// More menu func
@ -503,7 +512,7 @@ export default {
},
play() {
var eventBus = this.$eventBus || this.$nuxt.$eventBus
eventBus.$emit('play-audiobook', this.audiobookId)
eventBus.$emit('play-audiobook', this.libraryItemId)
},
mouseover() {
this.isHovering = true

View File

@ -34,27 +34,23 @@ export default {
items: [
{
text: 'Title',
value: 'book.title'
value: 'media.metadata.title'
},
{
text: 'Author (First Last)',
value: 'book.authorFL'
value: 'media.metadata.author'
},
{
text: 'Author (Last, First)',
value: 'book.authorLF'
value: 'media.metadata.authorLF'
},
{
text: 'Added At',
value: 'addedAt'
},
{
text: 'Volume #',
value: 'book.volumeNumber'
},
{
text: 'Duration',
value: 'duration'
value: 'media.duration'
},
{
text: 'Size',
@ -89,7 +85,8 @@ export default {
}
},
selectedText() {
var _selected = this.selected === 'book.author' ? 'book.authorFL' : this.selected
var _selected = this.selected
if (this.selected.startsWith('book.')) _selected = _selected.replace('book.', 'media.metadata.')
var _sel = this.items.find((i) => i.value === _selected)
if (!_sel) return ''
return _sel.text

View File

@ -5,8 +5,8 @@
<div class="absolute cover-bg" ref="coverBg" />
</div>
<img v-if="audiobook" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
<div v-show="loading && audiobook" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
<div class="absolute top-2 right-2">
<div class="la-ball-spin-clockwise la-sm">
@ -44,11 +44,10 @@
<script>
export default {
props: {
audiobook: {
libraryItem: {
type: Object,
default: () => {}
},
authorOverride: String,
width: {
type: Number,
default: 120
@ -75,12 +74,15 @@ export default {
height() {
return this.width * this.bookCoverAspectRatio
},
book() {
if (!this.audiobook) return {}
return this.audiobook.book || {}
media() {
if (!this.libraryItem) return {}
return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
title() {
return this.book.title || 'No Title'
return this.mediaMetadata.title || 'No Title'
},
titleCleaned() {
if (this.title.length > 60) {
@ -88,9 +90,11 @@ export default {
}
return this.title
},
authors() {
return this.mediaMetadata.authors || []
},
author() {
if (this.authorOverride) return this.authorOverride
return this.book.author || 'Unknown'
return this.authors.map((au) => au.name).join(', ')
},
authorCleaned() {
if (this.author.length > 30) {
@ -102,15 +106,15 @@ export default {
return '/book_placeholder.jpg'
},
fullCoverUrl() {
if (!this.audiobook) return null
if (!this.libraryItem) return null
var store = this.$store || this.$nuxt.$store
return store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
return store.getters['audiobooks/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)
},
cover() {
return this.book.cover || this.placeholderUrl
return this.media.coverPath || this.placeholderUrl
},
hasCover() {
return !!this.book.cover
return !!this.media.coverPath
},
sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 192 : 120
@ -138,7 +142,6 @@ export default {
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
}
},
hideCoverBg() {},
imageLoaded() {
this.loading = false
this.$nextTick(() => {

View File

@ -63,7 +63,7 @@ export default {
},
methods: {
getCoverUrl(book) {
return this.store.getters['audiobooks/getBookCoverSrc'](book, '')
return this.store.getters['audiobooks/getLibraryItemCoverSrc'](book, '')
},
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
var src = coverData.coverUrl

View File

@ -22,7 +22,7 @@ export default {
return '/book_placeholder.jpg'
},
fullCoverUrl() {
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
return this.$store.getters['audiobooks/getLibraryItemCoverSrc'](this.audiobook, this.placeholderUrl)
},
hasCover() {
return !!this.audiobook.book.cover

View File

@ -19,7 +19,7 @@
</div>
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
<component v-if="audiobook && show" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
<component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
</div>
</modals-modal>
</template>
@ -29,7 +29,7 @@ export default {
data() {
return {
processing: false,
audiobook: null,
libraryItem: null,
fetchOnShow: false,
tabs: [
{
@ -42,11 +42,6 @@ export default {
title: 'Cover',
component: 'modals-edit-tabs-cover'
},
// {
// id: 'tracks',
// title: 'Tracks',
// component: 'modals-edit-tabs-tracks'
// },
{
id: 'chapters',
title: 'Chapters',
@ -89,12 +84,12 @@ export default {
this.selectedTab = availableTabIds[0]
}
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) {
if (this.libraryItem && this.libraryItem.id === this.selectedLibraryItemId) {
if (this.fetchOnShow) this.fetchFull()
return
}
this.fetchOnShow = false
this.audiobook = null
this.libraryItem = null
this.init()
this.registerListeners()
} else {
@ -148,26 +143,29 @@ export default {
return _tab ? _tab.component : ''
},
isMissing() {
return this.selectedAudiobook.isMissing
return this.selectedLibraryItem.isMissing
},
selectedAudiobook() {
return this.$store.state.selectedAudiobook || {}
selectedLibraryItem() {
return this.$store.state.selectedLibraryItem || {}
},
selectedAudiobookId() {
return this.selectedAudiobook.id
selectedLibraryItemId() {
return this.selectedLibraryItem.id
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
title() {
return this.book.title || 'No Title'
return this.mediaMetadata.title || 'No Title'
},
bookshelfBookIds() {
return this.$store.state.bookshelfBookIds || []
},
currentBookshelfIndex() {
if (!this.bookshelfBookIds.length) return 0
return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedAudiobookId)
return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedLibraryItemId)
},
canGoPrev() {
return this.bookshelfBookIds.length && this.currentBookshelfIndex > 0
@ -188,7 +186,7 @@ export default {
})
this.processing = false
if (prevBook) {
this.$store.commit('showEditModalOnTab', { audiobook: prevBook, tab: this.selectedTab })
this.$store.commit('showEditModalOnTab', { libraryItem: prevBook, tab: this.selectedTab })
this.$nextTick(this.init)
} else {
console.error('Book not found', prevBookId)
@ -205,7 +203,7 @@ export default {
})
this.processing = false
if (nextBook) {
this.$store.commit('showEditModalOnTab', { audiobook: nextBook, tab: this.selectedTab })
this.$store.commit('showEditModalOnTab', { libraryItem: nextBook, tab: this.selectedTab })
this.$nextTick(this.init)
} else {
console.error('Book not found', nextBookId)
@ -223,16 +221,16 @@ export default {
}
},
init() {
this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedAudiobookId })
this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedLibraryItemId })
this.fetchFull()
},
async fetchFull() {
try {
this.processing = true
this.audiobook = await this.$axios.$get(`/api/books/${this.selectedAudiobookId}`)
this.libraryItem = await this.$axios.$get(`/api/items/${this.selectedLibraryItemId}?expanded=1`)
this.processing = false
} catch (error) {
console.error('Failed to fetch audiobook', this.selectedAudiobookId, error)
console.error('Failed to fetch audiobook', this.selectedLibraryItemId, error)
this.processing = false
this.show = false
}

View File

@ -31,7 +31,7 @@
<script>
export default {
props: {
audiobook: {
libraryItem: {
type: Object,
default: () => {}
}
@ -42,7 +42,7 @@ export default {
}
},
watch: {
audiobook: {
libraryItem: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
@ -52,7 +52,7 @@ export default {
computed: {},
methods: {
init() {
this.chapters = this.audiobook.chapters || []
this.chapters = this.libraryItem.chapters || []
}
}
}

View File

@ -2,9 +2,9 @@
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative">
<div class="flex">
<div class="relative">
<covers-book-cover :audiobook="audiobook" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- book cover overlay -->
<div v-if="book.cover" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
<span class="material-icons">delete</span>
@ -82,7 +82,7 @@
export default {
props: {
processing: Boolean,
audiobook: {
libraryItem: {
type: Object,
default: () => {}
}
@ -102,7 +102,7 @@ export default {
}
},
watch: {
audiobook: {
libraryItem: {
immediate: true,
handler(newVal) {
if (newVal) {
@ -134,17 +134,17 @@ export default {
bookCoverAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
audiobookPath() {
return this.audiobook ? this.audiobook.path : null
mediaMetadata() {
return this.media.metadata || {}
},
otherFiles() {
return this.audiobook ? this.audiobook.otherFiles || [] : []
libraryFiles() {
return this.libraryItem ? this.libraryItem.libraryFiles || [] : []
},
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
@ -153,12 +153,11 @@ export default {
return this.$store.getters['user/getToken']
},
localCovers() {
return this.otherFiles
.filter((f) => f.filetype === 'image')
return this.libraryFiles
.filter((f) => f.fileType === 'image')
.map((file) => {
var _file = { ...file }
var imgRelPath = _file.path.replace(this.audiobookPath, '')
_file.localPath = `/s/book/${this.audiobookId}/${imgRelPath}`
_file.localPath = `/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath)}`
return _file
})
}
@ -170,7 +169,7 @@ export default {
form.set('cover', this.selectedFile)
this.$axios
.$post(`/api/books/${this.audiobook.id}/cover`, form)
.$post(`/api/books/${this.libraryItemId}/cover`, form)
.then((data) => {
if (data.error) {
this.$toast.error(data.error)
@ -203,17 +202,17 @@ export default {
},
init() {
this.showLocalCovers = false
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.authorFL)) {
if (this.coversFound.length && (this.searchTitle !== this.mediaMetadata.title || this.searchAuthor !== this.mediaMetadata.authorName)) {
this.coversFound = []
this.hasSearched = false
}
this.imageUrl = this.book.cover || ''
this.searchTitle = this.book.title || ''
this.searchAuthor = this.book.authorFL || ''
this.imageUrl = this.media.coverPath || ''
this.searchTitle = this.mediaMetadata.title || ''
this.searchAuthor = this.mediaMetadata.authorName || ''
this.provider = localStorage.getItem('book-provider') || 'openlibrary'
},
removeCover() {
if (!this.book.cover) {
if (!this.media.coverPath) {
this.imageUrl = ''
return
}
@ -223,7 +222,7 @@ export default {
this.updateCover(this.imageUrl)
},
async updateCover(cover) {
if (cover === this.book.cover) {
if (cover === this.media.coverPath) {
console.warn('Cover has not changed..', cover)
return
}
@ -233,7 +232,7 @@ export default {
// Download cover from url and use
if (cover.startsWith('http:') || cover.startsWith('https:')) {
success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, { url: cover }).catch((error) => {
success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover }).catch((error) => {
console.error('Failed to download cover from url', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
@ -247,7 +246,7 @@ export default {
cover: cover
}
}
success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, updatePayload).catch((error) => {
success = await this.$axios.$patch(`/api/books/${this.libraryItemId}`, updatePayload).catch((error) => {
console.error('Failed to update', error)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
@ -259,7 +258,7 @@ export default {
this.$toast.success('Update Successful')
this.$emit('close')
} else {
this.imageUrl = this.book.cover || ''
this.imageUrl = this.media.coverPath || ''
}
this.isProcessing = false
},
@ -292,7 +291,7 @@ export default {
setCover(coverFile) {
this.isProcessing = true
this.$axios
.$patch(`/api/books/${this.audiobook.id}/coverfile`, coverFile)
.$patch(`/api/books/${this.libraryItemId}/coverfile`, coverFile)
.then((data) => {
console.log('response data', data)
if (data && typeof data === 'string') {

View File

@ -13,7 +13,8 @@
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<ui-text-input-with-label v-model="details.author" label="Author" />
<!-- <ui-text-input-with-label v-model="details.authors" label="Author" /> -->
<p>Authors placeholder</p>
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
@ -22,10 +23,11 @@
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<ui-input-dropdown ref="seriesDropdown" v-model="details.series" label="Series" :items="series" />
<p>Series placeholder</p>
<!-- <ui-input-dropdown ref="seriesDropdown" v-model="details.series" label="Series" :items="series" /> -->
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" />
<!-- <ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" /> -->
</div>
</div>
@ -64,7 +66,7 @@
<div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
<div class="flex items-center px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn>
<div class="flex-grow" />
@ -91,7 +93,7 @@
export default {
props: {
processing: Boolean,
audiobook: {
libraryItem: {
type: Object,
default: () => {}
}
@ -105,7 +107,6 @@ export default {
author: null,
narrator: null,
series: null,
volumeNumber: null,
publishYear: null,
publisher: null,
language: null,
@ -122,7 +123,7 @@ export default {
}
},
watch: {
audiobook: {
libraryItem: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
@ -142,13 +143,16 @@ export default {
return this.$store.getters['user/getIsRoot']
},
isMissing() {
return !!this.audiobook && !!this.audiobook.isMissing
return !!this.libraryItem && !!this.libraryItem.isMissing
},
audiobookId() {
return this.audiobook ? this.audiobook.id : null
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
@ -166,7 +170,7 @@ export default {
return this.$store.state.libraries.filterData || {}
},
libraryId() {
return this.audiobook ? this.audiobook.libraryId : null
return this.libraryItem ? this.libraryItem.libraryId : null
},
libraryProvider() {
return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google'
@ -185,13 +189,13 @@ export default {
author: this.details.author !== this.book.author ? this.details.author : null
}
this.$axios
.$post(`/api/books/${this.audiobookId}/match`, matchOptions)
.$post(`/api/books/${this.libraryItemId}/match`, matchOptions)
.then((res) => {
this.quickMatching = false
if (res.warning) {
this.$toast.warning(res.warning)
} else if (res.updated) {
this.$toast.success('Audiobook details updated')
this.$toast.success('Item details updated')
} else {
this.$toast.info('No updates were made')
}
@ -236,7 +240,7 @@ export default {
saveMetadata() {
this.savingMetadata = true
this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
this.$root.socket.emit('save_metadata', this.audiobookId)
this.$root.socket.emit('save_metadata', this.libraryItemId)
},
submitForm() {
if (this.isProcessing) {
@ -260,7 +264,7 @@ export default {
tags: this.newTags
}
var updatedAudiobook = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, updatePayload).catch((error) => {
var updatedAudiobook = await this.$axios.$patch(`/api/books/${this.libraryItemId}`, updatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
@ -271,35 +275,34 @@ export default {
}
},
init() {
this.details.title = this.book.title
this.details.subtitle = this.book.subtitle
this.details.description = this.book.description
this.details.author = this.book.author
this.details.narrator = this.book.narrator
this.details.genres = this.book.genres || []
this.details.series = this.book.series
this.details.volumeNumber = this.book.volumeNumber
this.details.publishYear = this.book.publishYear
this.details.publisher = this.book.publisher || null
this.details.language = this.book.language || null
this.details.isbn = this.book.isbn || null
this.details.asin = this.book.asin || null
this.details.title = this.mediaMetadata.title
this.details.subtitle = this.mediaMetadata.subtitle
this.details.description = this.mediaMetadata.description
this.details.authors = this.mediaMetadata.authors
this.details.narrator = this.mediaMetadata.narrator
this.details.genres = this.mediaMetadata.genres || []
this.details.series = this.mediaMetadata.series
this.details.publishYear = this.mediaMetadata.publishYear
this.details.publisher = this.mediaMetadata.publisher || null
this.details.language = this.mediaMetadata.language || null
this.details.isbn = this.mediaMetadata.isbn || null
this.details.asin = this.mediaMetadata.asin || null
this.newTags = this.audiobook.tags || []
this.newTags = this.media.tags || []
},
deleteAudiobook() {
if (confirm(`Are you sure you want to remove this audiobook?\n\n*Does not delete your files, only removes the audiobook from AudioBookshelf`)) {
removeItem() {
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
this.isProcessing = true
this.$axios
.$delete(`/api/books/${this.audiobookId}`)
.$delete(`/api/books/${this.libraryItemId}`)
.then(() => {
console.log('Audiobook removed')
this.$toast.success('Audiobook Removed')
console.log('Item removed')
this.$toast.success('Item Removed')
this.$emit('close')
this.isProcessing = false
})
.catch((error) => {
console.error('Remove Audiobook failed', error)
console.error('Remove item failed', error)
this.isProcessing = false
})
}

View File

@ -65,7 +65,7 @@
export default {
props: {
processing: Boolean,
audiobook: {
libraryItem: {
type: Object,
default: () => {}
}
@ -86,14 +86,14 @@ export default {
}
},
computed: {
audiobookId() {
return this.audiobook ? this.audiobook.id : null
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
_audiobook() {
return this.audiobook || {}
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
downloads() {
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
return this.$store.getters['downloads/getDownloads'](this.libraryItemId)
},
singleAudioDownload() {
return this.downloads.find((d) => d.type === 'singleAudio')
@ -108,24 +108,21 @@ export default {
return this.zipDownload ? this.zipDownload.status : false
},
isSingleTrack() {
if (!this.audiobook.tracks) return false
return this.audiobook.tracks.length === 1
if (!this.libraryItem.tracks) return false
return this.libraryItem.tracks.length === 1
},
singleTrackPath() {
if (!this.isSingleTrack) return null
return this.audiobook.tracks[0].path
},
audioFiles() {
return this.audiobook ? this.audiobook.audioFiles || [] : []
},
otherFiles() {
return this.audiobook ? this.audiobook.otherFiles || [] : []
libraryFiles() {
return this.libraryItem.libraryFiles
},
totalFiles() {
return this.audioFiles.length + this.otherFiles.length
return this.libraryFiles.length
},
showM4bDownload() {
return !this._audiobook.isMissing && !this._audiobook.isInvalid && this._audiobook.tracks.length
return !this.libraryItem.isMissing && this.media.tracks.length
}
},
methods: {

View File

@ -9,7 +9,7 @@
</div>
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`">
<nuxt-link v-if="userCanUpdate" :to="`/item/${libraryItem.id}/edit`">
<ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link>
</div>
@ -21,20 +21,20 @@
<th class="text-left">Duration</th>
<th v-if="showDownload" class="text-center">Download</th>
</tr>
<template v-for="track in tracksCleaned">
<template v-for="track in tracks">
<tr :key="track.index">
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
<td class="font-sans">{{ showFullPath ? track.path : track.filename }}</td>
<td class="font-mono">
{{ $bytesPretty(track.size) }}
{{ $bytesPretty(track.metadata.size) }}
</td>
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="showDownload" class="font-mono text-center">
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
<a :href="`/s/item/${libraryItem.id}/${track.metadata.relPath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
@ -43,26 +43,26 @@
<div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div>
</div>
<tables-all-files-table :audiobook="audiobook" />
<tables-library-files-table :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" />
</div>
</template>
<script>
export default {
props: {
audiobook: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
tracks: null,
tracks: [],
showFullPath: false
}
},
watch: {
audiobook: {
libraryItem: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
@ -70,22 +70,11 @@ export default {
}
},
computed: {
audiobookPath() {
return this.audiobook.path
media() {
return this.libraryItem.media || {}
},
tracksCleaned() {
return this.tracks.map((track) => {
var trackPath = track.path.replace(/\\/g, '/')
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
return {
...track,
relativePath: trackPath
.replace(audiobookPath + '/', '')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
}
})
libraryFiles() {
return this.libraryItem.libraryFiles || []
},
userToken() {
return this.$store.getters['user/getToken']
@ -97,18 +86,18 @@ export default {
return this.$store.getters['user/getUserCanDownload']
},
isMissing() {
return this.audiobook.isMissing
return this.libraryItem.isMissing
},
showDownload() {
return this.userCanDownload && !this.isMissing
},
hasTracks() {
return this.audiobook.tracks.length
return this.tracks.length
}
},
methods: {
init() {
this.tracks = this.audiobook.tracks
this.tracks = this.media.tracks || []
}
}
}

View File

@ -94,14 +94,14 @@
export default {
props: {
processing: Boolean,
audiobook: {
libraryItem: {
type: Object,
default: () => {}
}
},
data() {
return {
audiobookId: null,
libraryItemId: null,
searchTitle: null,
searchAuthor: null,
lastSearch: null,
@ -126,7 +126,7 @@ export default {
}
},
watch: {
audiobook: {
libraryItem: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
@ -209,19 +209,19 @@ export default {
isbn: true
}
if (this.audiobook.id !== this.audiobookId) {
if (this.libraryItem.id !== this.libraryItemId) {
this.searchResults = []
this.hasSearched = false
this.audiobookId = this.audiobook.id
this.libraryItemId = this.libraryItem.id
}
if (!this.audiobook.book || !this.audiobook.book.title) {
if (!this.libraryItem.media || !this.libraryItem.media.metadata.title) {
this.searchTitle = null
this.searchAuthor = null
return
}
this.searchTitle = this.audiobook.book.title
this.searchAuthor = this.audiobook.book.authorFL || ''
this.searchTitle = this.libraryItem.media.metadata.title
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
this.provider = localStorage.getItem('book-provider') || 'google'
},
selectMatch(match) {
@ -247,7 +247,7 @@ export default {
var coverPayload = {
url: updatePayload.cover
}
var success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, coverPayload).catch((error) => {
var success = await this.$axios.$post(`/api/books/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
console.error('Failed to update', error)
return false
})
@ -264,7 +264,7 @@ export default {
var bookUpdatePayload = {
book: updatePayload
}
var success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, bookUpdatePayload).catch((error) => {
var success = await this.$axios.$patch(`/api/books/${this.libraryItemId}`, bookUpdatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})

View File

@ -1,110 +0,0 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<template v-if="hasTracks">
<div class="w-full bg-primary px-4 py-2 flex items-center">
<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>
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @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>
</div>
<table class="text-sm tracksTable">
<tr class="font-book">
<th>#</th>
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
<th v-if="showDownload" class="text-center">Download</th>
</tr>
<template v-for="track in tracksCleaned">
<tr :key="track.index">
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
<td class="font-mono">
{{ $bytesPretty(track.size) }}
</td>
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="showDownload" class="font-mono text-center">
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
</table>
</template>
<div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
tracks: null,
showFullPath: false
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {
audiobookPath() {
return this.audiobook.path
},
tracksCleaned() {
return this.tracks.map((track) => {
var trackPath = track.path.replace(/\\/g, '/')
var audiobookPath = this.audiobookPath.replace(/\\/g, '/')
return {
...track,
relativePath: trackPath
.replace(audiobookPath + '/', '')
.replace(/%/g, '%25')
.replace(/#/g, '%23')
}
})
},
userToken() {
return this.$store.getters['user/getToken']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
isMissing() {
return this.audiobook.isMissing
},
showDownload() {
return this.userCanDownload && !this.isMissing
},
hasTracks() {
return this.audiobook.tracks.length
}
},
methods: {
init() {
this.tracks = this.audiobook.tracks
}
}
}
</script>

View File

@ -1,14 +1,11 @@
<template>
<div class="w-full my-2">
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
<p class="pr-2 md:pr-4">Other Files</p>
<p class="pr-2 md:pr-4">Library Files</p>
<div class="h-5 md:h-7 w-5 md: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" />
<!-- <nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link> -->
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @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' : ''">
<span class="material-icons text-4xl">expand_more</span>
@ -22,19 +19,19 @@
<th class="text-left px-4 w-24">Filetype</th>
<th v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th>
</tr>
<template v-for="file in otherFilesCleaned">
<template v-for="file in files">
<tr :key="file.path">
<td class="font-book pl-2">
{{ showFullPath ? file.fullPath : file.path }}
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
</td>
<td class="text-xs">
<div class="flex items-center">
<span v-if="file.filetype === 'ebook'" class="material-icons text-base mr-1 cursor-pointer text-white text-opacity-60 hover:text-opacity-100" @click="readEbookClick(file)">auto_stories </span>
<p>{{ file.filetype }}</p>
<p>{{ file.metadata.ext }}</p>
</div>
</td>
<td v-if="userCanDownload && !isMissing" class="text-center">
<a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
<a :href="`/s/item/${libraryItemId}${$encodeUriPath(file.metadata.relPath)}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
@ -51,10 +48,8 @@ export default {
type: Array,
default: () => []
},
audiobook: {
type: Object,
default: () => null
}
libraryItemId: String,
isMissing: Boolean
},
data() {
return {
@ -63,40 +58,19 @@ export default {
}
},
computed: {
audiobookId() {
return this.audiobook.id
},
audiobookPath() {
return this.audiobook.path
},
otherFilesCleaned() {
return this.files.map((file) => {
return {
...file,
relativePath: this.getRelativePath(file.path)
}
})
},
userToken() {
return this.$store.getters['user/getToken']
},
isMissing() {
return this.audiobook.isMissing
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
}
},
methods: {
readEbookClick(file) {
this.$store.commit('showEReaderForFile', { audiobook: this.audiobook, file })
// this.$store.commit('showEReaderForFile', { audiobook: this.audiobook, file })
},
clickBar() {
this.showFiles = !this.showFiles
},
getRelativePath(path) {
var relativePath = path.replace(/\\/g, '/').replace(this.audiobookPath.replace(/\\/g, '/') + '/', '')
return this.$encodeUriPath(relativePath)
}
},
mounted() {}

View File

@ -8,7 +8,7 @@
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
<div class="flex-grow" />
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn>
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-2 md:mr-4">
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
@ -25,20 +25,20 @@
<th class="text-left w-20">Duration</th>
<th v-if="userCanDownload" class="text-center w-20">Download</th>
</tr>
<template v-for="track in tracksCleaned">
<template v-for="track in tracks">
<tr :key="track.index">
<td class="text-center">
<p>{{ track.index }}</p>
</td>
<td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td>
<td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
<td class="font-mono">
{{ $bytesPretty(track.size) }}
{{ $bytesPretty(track.metadata.size) }}
</td>
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="userCanDownload" class="text-center">
<a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
<a :href="`/s/item/${libraryItemId}${$encodeUriPath(track.metadata.relPath)}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
@ -55,10 +55,7 @@ export default {
type: Array,
default: () => []
},
audiobook: {
type: Object,
default: () => null
}
libraryItemId: String
},
data() {
return {
@ -67,20 +64,6 @@ export default {
}
},
computed: {
audiobookId() {
return this.audiobook.id
},
audiobookPath() {
return this.audiobook.path
},
tracksCleaned() {
return this.tracks.map((track) => {
return {
...track,
relativePath: this.getRelativePath(track.path)
}
})
},
userToken() {
return this.$store.getters['user/getToken']
},
@ -94,10 +77,6 @@ export default {
methods: {
clickBar() {
this.showTracks = !this.showTracks
},
getRelativePath(path) {
var relativePath = path.replace(/\\/g, '/').replace(this.audiobookPath.replace(/\\/g, '/') + '/', '')
return this.$encodeUriPath(relativePath)
}
},
mounted() {}

View File

@ -74,7 +74,6 @@ module.exports = {
proxy: {
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
'/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/lib/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/ebook/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },

View File

@ -164,7 +164,7 @@
<tables-audio-files-table v-if="otherAudioFiles.length" :audiobook-id="audiobook.id" :files="otherAudioFiles" class="mt-6" />
<tables-other-files-table v-if="otherFiles.length" :audiobook="audiobook" :files="otherFiles" class="mt-6" />
<tables-library-files-table v-if="otherFiles.length" :audiobook="audiobook" :files="otherFiles" class="mt-6" />
</div>
</div>
</div>

View File

@ -0,0 +1,475 @@
<template>
<div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamAudiobook ? 'streaming' : ''">
<div class="w-full h-full overflow-y-auto px-2 py-6 md:p-8">
<div class="flex flex-col md:flex-row max-w-6xl mx-auto">
<div class="w-full flex justify-center md:block md:w-52" style="min-width: 208px">
<div class="relative" style="height: fit-content">
<covers-book-cover :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<!-- Book Progress Bar -->
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm z-10" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div>
<!-- Book Cover Overlay -->
<div class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-30 opacity-0 hover:opacity-100 transition-opacity" @mousedown.prevent @mouseup.prevent>
<div v-show="showPlayButton && !streaming" class="h-full flex items-center justify-center pointer-events-none">
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="startStream">
<span class="material-icons text-4xl">play_circle_filled</span>
</div>
</div>
<span class="absolute bottom-2.5 right-2.5 z-10 material-icons text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200" @click="showEditCover">edit</span>
</div>
</div>
</div>
<div class="flex-grow px-2 py-6 md:py-0 md:px-10">
<div class="flex justify-center">
<div class="mb-4">
<div class="flex sm:items-end flex-col sm:flex-row">
<h1 class="text-2xl md:text-3xl font-sans">
{{ title }}
</h1>
<p v-if="subtitle" class="sm:ml-4 text-gray-400 text-xl md:text-2xl">{{ subtitle }}</p>
</div>
<!-- <p v-if="subtitle" class="ml-4 text-gray-400 text-2xl block sm:hidden">{{ subtitle }}</p> -->
<p v-if="authorsList.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl">
by <nuxt-link v-for="(author, index) in authorsList" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}<span v-if="index < authorsList.length - 1">,&nbsp;</span></nuxt-link>
</p>
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}}`" class="hover:underline font-sans text-gray-300 text-lg leading-7"> {{ _series.text }}</nuxt-link>
<div v-if="narrator" class="flex py-0.5 mt-4">
<div class="w-32">
<span class="text-white text-opacity-60 uppercase text-sm">Narrated By</span>
</div>
<div>
<template v-for="(narrator, index) in narrators">
<nuxt-link :key="narrator" :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link
><span :key="index" v-if="index < narrators.length - 1">,&nbsp;</span>
</template>
</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 v-if="tracks.length" 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 v-if="tracks.length" 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>
</div>
<div class="hidden md:block flex-grow" />
</div>
<!-- Alerts -->
<div v-show="showExperimentalReadAlert" class="bg-error p-4 rounded-xl flex items-center">
<span class="material-icons text-2xl">warning_amber</span>
<p class="ml-4">Book has no audio tracks but has valid ebook files. The e-reader is experimental and can be turned on in config.</p>
</div>
<!-- Progress -->
<div v-if="progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
<p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
<p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, 'MM/dd/yyyy') }}</p>
<p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
<p class="text-gray-400 text-xs pt-1">Started {{ $formatDate(userProgressStartedAt, 'MM/dd/yyyy') }}</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
<span class="material-icons text-sm">close</span>
</div>
</div>
<div class="flex items-center justify-center md:justify-start pt-4">
<ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
{{ streaming ? 'Streaming' : 'Play' }}
</ui-btn>
<ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">error</span>
{{ isMissing ? 'Missing' : 'Incomplete' }}
</ui-btn>
<ui-btn v-if="showExperimentalFeatures && numEbooks" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
Read
</ui-btn>
<ui-tooltip v-if="userCanUpdate" text="Edit" direction="top">
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
</ui-tooltip>
<ui-tooltip v-if="userCanDownload" :disabled="isMissing" text="Download" direction="top">
<ui-icon-btn icon="download" :disabled="isMissing" class="mx-0.5" @click="downloadClick" />
</ui-tooltip>
<ui-tooltip :text="isRead ? 'Mark as Not Read' : 'Mark as Read'" direction="top">
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="isRead" class="mx-0.5" @click="toggleRead" />
</ui-tooltip>
<ui-tooltip text="Collections" direction="top">
<ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" />
</ui-tooltip>
</div>
<div class="my-4 max-w-2xl">
<p class="text-base text-gray-100 whitespace-pre-line">{{ description }}</p>
</div>
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
<p class="text-sm mb-2">
Missing Parts <span class="text-sm">({{ missingParts.length }})</span>
</p>
<p class="text-sm font-mono">{{ missingPartChunks.join(', ') }}</p>
</div>
<div v-if="invalidParts.length" class="bg-error border-red-800 shadow-md p-4">
<p class="text-sm mb-2">
Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span>
</p>
<div>
<p v-for="part in invalidParts" :key="part.filename" class="text-sm font-mono">{{ part.filename }}: {{ part.error }}</p>
</div>
</div>
<tables-tracks-table v-if="tracks.length" :tracks="tracks" :library-item-id="libraryItemId" class="mt-6" />
<!-- <tables-audio-files-table v-if="otherAudioFiles.length" :library-item-id="libraryItemId" :files="otherAudioFiles" class="mt-6" /> -->
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" />
</div>
</div>
</div>
</div>
</template>
<script>
export default {
async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`)
}
var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {
console.error('Failed', error)
return false
})
if (!item) {
console.error('No item...', params.id)
return redirect('/')
}
return {
libraryItem: item
}
},
data() {
return {
isRead: false,
resettingProgress: false,
isProcessingReadUpdate: false
}
},
watch: {
userIsRead: {
immediate: true,
handler(newVal) {
this.isRead = newVal
}
}
},
computed: {
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
},
bookCoverAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6
},
bookCoverWidth() {
return 208
},
isDeveloperMode() {
return this.$store.state.developerMode
},
showExperimentalFeatures() {
return this.$store.state.showExperimentalFeatures
},
missingPartChunks() {
if (this.missingParts === 1) return this.missingParts[0]
var chunks = []
var currentIndex = this.missingParts[0]
var currentChunk = [this.missingParts[0]]
for (let i = 1; i < this.missingParts.length; i++) {
var partIndex = this.missingParts[i]
if (currentIndex === partIndex - 1) {
currentChunk.push(partIndex)
currentIndex = partIndex
} else {
// console.log('Chunk ended', currentChunk.join(', '), currentIndex, partIndex)
if (currentChunk.length === 0) {
console.error('How is current chunk 0?', currentChunk.join(', '))
}
chunks.push(currentChunk)
currentChunk = [partIndex]
currentIndex = partIndex
}
}
if (currentChunk.length) {
chunks.push(currentChunk)
}
chunks = chunks.map((chunk) => {
if (chunk.length === 1) return chunk[0]
else return `${chunk[0]}-${chunk[chunk.length - 1]}`
})
return chunks
},
isMissing() {
return this.libraryItem.isMissing
},
isInvalid() {
return this.libraryItem.isInvalid
},
showPlayButton() {
return !this.isMissing && !this.isInvalid && this.tracks.length
},
missingParts() {
return this.libraryItem.missingParts || []
},
invalidParts() {
return this.libraryItem.invalidParts || []
},
libraryId() {
return this.libraryItem.libraryId
},
folderId() {
return this.libraryItem.folderId
},
libraryItemId() {
return this.libraryItem.id
},
media() {
return this.libraryItem.media || {}
},
mediaMetadata() {
return this.media.metadata || {}
},
title() {
return this.mediaMetadata.title || 'No Title'
},
publishYear() {
return this.mediaMetadata.publishYear
},
narrator() {
return this.mediaMetadata.narratorName
},
subtitle() {
return this.mediaMetadata.subtitle
},
genres() {
return this.mediaMetadata.genres || []
},
authors() {
return this.mediaMetadata.authors || []
},
authorsList() {
return this.authors.map((au) => au.name)
},
narrators() {
return this.mediaMetadata.narrators || []
},
series() {
return this.media.series || []
},
seriesList() {
return this.series.map((se) => {
var text = se.name
if (se.sequence) text += ` #${se.sequence}`
return {
...se,
text
}
})
},
durationPretty() {
return this.$elapsedPretty(this.media.duration)
},
duration() {
return this.media.duration
},
sizePretty() {
return this.$bytesPretty(this.media.size)
},
libraryFiles() {
return this.libraryItem.libraryFiles || []
},
otherAudioFiles() {
return this.audioFiles.filter((af) => {
return !this.tracks.find((t) => t.path === af.path)
})
},
tracks() {
return this.media.tracks || []
},
audioFiles() {
return this.media.audioFiles || []
},
ebooks() {
return this.media.ebookFiles
},
showExperimentalReadAlert() {
return !this.tracks.length && this.ebooks.length && !this.showExperimentalFeatures
},
numEbooks() {
return this.media.numEbooks
},
description() {
return this.mediaMetadata.description || ''
},
userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
},
userAudiobook() {
return this.userAudiobooks[this.libraryItemId] || null
},
userCurrentTime() {
return this.userAudiobook ? this.userAudiobook.currentTime : 0
},
userIsRead() {
return this.userAudiobook ? !!this.userAudiobook.isRead : false
},
userTimeRemaining() {
return this.duration - this.userCurrentTime
},
progressPercent() {
return this.userAudiobook ? Math.max(Math.min(1, this.userAudiobook.progress), 0) : 0
},
userProgressStartedAt() {
return this.userAudiobook ? this.userAudiobook.startedAt : 0
},
userProgressFinishedAt() {
return this.userAudiobook ? this.userAudiobook.finishedAt : 0
},
streamAudiobook() {
return this.$store.state.streamAudiobook
},
streaming() {
return this.streamAudiobook && this.streamAudiobook.id === this.libraryItemId
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
}
},
methods: {
showEditCover() {
this.$store.commit('setBookshelfBookIds', [])
this.$store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'cover' })
},
openEbook() {
this.$store.commit('showEReader', this.libraryItem)
},
toggleRead() {
var updatePayload = {
isRead: !this.isRead
}
this.isProcessingReadUpdate = true
this.$axios
.$patch(`/api/me/audiobook/${this.libraryItemId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
})
},
startStream() {
this.$eventBus.$emit('play-audiobook', this.libraryItem.id)
},
editClick() {
this.$store.commit('setBookshelfBookIds', [])
this.$store.commit('showEditModal', this.libraryItem)
},
audiobookUpdated() {
console.log('Audiobook Updated - Fetch full audiobook')
this.$axios
.$get(`/api/books/${this.libraryItemId}`)
.then((audiobook) => {
console.log('Updated audiobook', audiobook)
this.libraryItem = audiobook
})
.catch((error) => {
console.error('Failed', error)
})
},
clearProgressClick() {
if (confirm(`Are you sure you want to reset your progress?`)) {
this.resettingProgress = true
this.$axios
.$patch(`/api/me/audiobook/${this.libraryItemId}/reset-progress`)
.then(() => {
console.log('Progress reset complete')
this.$toast.success(`Your progress was reset`)
this.resettingProgress = false
})
.catch((error) => {
console.error('Progress reset failed', error)
this.resettingProgress = false
})
}
},
downloadClick() {
this.$store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'download' })
},
collectionsClick() {
this.$store.commit('setSelectedAudiobook', this.libraryItem)
this.$store.commit('globals/setShowUserCollectionsModal', true)
}
},
mounted() {
this.$store.commit('audiobooks/addListener', { id: 'audiobook', audiobookId: this.libraryItemId, meth: this.libraryItemUpdated })
// use this audiobooks library id as the current
if (this.libraryId) {
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
}
},
beforeDestroy() {
this.$store.commit('audiobooks/removeListener', 'audiobook')
}
}
</script>

View File

@ -76,7 +76,7 @@ export default class CastPlayer extends EventEmitter {
this.currentTime = startTime
var coverImg = this.ctx.$store.getters['audiobooks/getBookCoverSrc'](audiobook)
var coverImg = this.ctx.$store.getters['audiobooks/getLibraryItemCoverSrc'](audiobook)
if (process.env.NODE_ENV === 'development') {
this.coverUrl = coverImg
} else {

View File

@ -3,7 +3,7 @@ export default (ctx) => {
// Fetch background covers for chromecast (temp)
var covers = await ctx.$axios.$get(`/api/libraries/${ctx.$store.state.libraries.currentLibraryId}/books/all?limit=40&minified=1`).then((data) => {
return data.results.filter((b) => b.book.cover).map((ab) => {
var coverUrl = ctx.$store.getters['audiobooks/getBookCoverSrc'](ab)
var coverUrl = ctx.$store.getters['audiobooks/getLibraryItemCoverSrc'](ab)
if (process.env.NODE_ENV === 'development') return coverUrl
return `${window.location.origin}${coverUrl}`
})

View File

@ -12,25 +12,21 @@ export const state = () => ({
})
export const getters = {
getBookCoverSrc: (state, getters, rootState, rootGetters) => (bookItem, placeholder = '/book_placeholder.jpg') => {
if (!bookItem) return placeholder
var book = bookItem.book
if (!book || !book.cover || book.cover === placeholder) return placeholder
getLibraryItemCoverSrc: (state, getters, rootState, rootGetters) => (libraryItem, placeholder = '/book_placeholder.jpg') => {
if (!libraryItem) return placeholder
var media = libraryItem.media
if (!media || !media.coverPath || media.coverPath === placeholder) return placeholder
// Absolute URL covers (should no longer be used)
if (book.cover.startsWith('http:') || book.cover.startsWith('https:')) return book.cover
if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath
var userToken = rootGetters['user/getToken']
var bookLastUpdate = book.lastUpdate || Date.now()
if (!bookItem.id) {
console.error('No book item id', bookItem)
}
var lastUpdate = libraryItem.updatedAt || Date.now()
if (process.env.NODE_ENV !== 'production') { // Testing
return `http://localhost:3333/api/books/${bookItem.id}/cover?token=${userToken}&ts=${bookLastUpdate}`
return `http://localhost:3333/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}`
}
return `/api/books/${bookItem.id}/cover?token=${userToken}&ts=${bookLastUpdate}`
return `/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}`
}
}

View File

@ -9,6 +9,7 @@ export const state = () => ({
showEditModal: false,
showEReader: false,
selectedAudiobook: null,
selectedLibraryItem: null,
selectedAudiobookFile: null,
developerMode: false,
selectedAudiobooks: [],
@ -108,14 +109,14 @@ export const mutations = {
setStreamAudiobook(state, audiobook) {
state.streamAudiobook = audiobook
},
showEditModal(state, audiobook) {
showEditModal(state, libraryItem) {
state.editModalTab = 'details'
state.selectedAudiobook = audiobook
state.selectedLibraryItem = libraryItem
state.showEditModal = true
},
showEditModalOnTab(state, { audiobook, tab }) {
showEditModalOnTab(state, { libraryItem, tab }) {
state.editModalTab = tab
state.selectedAudiobook = audiobook
state.selectedLibraryItem = libraryItem
state.showEditModal = true
},
setEditModalTab(state, tab) {
@ -124,15 +125,15 @@ export const mutations = {
setShowEditModal(state, val) {
state.showEditModal = val
},
showEReader(state, audiobook) {
showEReader(state, libraryItem) {
state.selectedAudiobookFile = null
state.selectedAudiobook = audiobook
state.selectedLibraryItem = libraryItem
state.showEReader = true
},
showEReaderForFile(state, { audiobook, file }) {
showEReaderForFile(state, { libraryItem, file }) {
state.selectedAudiobookFile = file
state.selectedAudiobook = audiobook
state.selectedLibraryItem = libraryItem
state.showEReader = true
},
@ -142,8 +143,8 @@ export const mutations = {
setDeveloperMode(state, val) {
state.developerMode = val
},
setSelectedAudiobook(state, val) {
Vue.set(state, 'selectedAudiobook', val)
setSelectedLibraryItem(state, val) {
Vue.set(state, 'selectedLibraryItem', val)
},
setSelectedAudiobooks(state, audiobooks) {
Vue.set(state, 'selectedAudiobooks', audiobooks)

View File

@ -124,7 +124,7 @@ export const actions = {
}
this.$axios
.$get(`/api/libraries/${state.currentLibraryId}/filters`)
.$get(`/api/libraries/${state.currentLibraryId}/filterdata`)
.then((data) => {
commit('setLibraryFilterData', data)
})

View File

@ -19,8 +19,9 @@ new LibraryItem({
lastScan: 1646784672127,
scanVersion: 1.72,
isMissing: false,
entityType: 'book',
entity: { // Book.js
mediaType: 'book',
media: { // Book.js
coverPath: '/metadata/books/li_abai123wir/cover.webp',
metadata: { // BookMetadata.js
title: 'Wizards First Rule',
subtitle: null,

View File

@ -14,6 +14,7 @@ const UserController = require('./controllers/UserController')
const CollectionController = require('./controllers/CollectionController')
const MeController = require('./controllers/MeController')
const BackupController = require('./controllers/BackupController')
const LibraryItemController = require('./controllers/LibraryItemController')
const BookFinder = require('./finders/BookFinder')
const AuthorFinder = require('./finders/AuthorFinder')
@ -53,19 +54,29 @@ class ApiController {
this.router.patch('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.update.bind(this))
this.router.delete('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.delete.bind(this))
this.router.get('/libraries/:id/books/all', LibraryController.middleware.bind(this), LibraryController.getBooksForLibrary2.bind(this))
this.router.get('/libraries/:id/books', LibraryController.middleware.bind(this), LibraryController.getBooksForLibrary.bind(this))
this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this))
this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
this.router.get('/libraries/:id/series/:series', LibraryController.middleware.bind(this), LibraryController.getSeriesForLibrary.bind(this))
this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this))
this.router.get('/libraries/:id/categories', LibraryController.middleware.bind(this), LibraryController.getLibraryCategories.bind(this))
this.router.get('/libraries/:id/filters', LibraryController.middleware.bind(this), LibraryController.getLibraryFilters.bind(this))
this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalized.bind(this))
this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this))
this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
this.router.post('/libraries/:id/matchbooks', LibraryController.middleware.bind(this), LibraryController.matchBooks.bind(this))
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
// Legacy
this.router.get('/libraries/:id/books/all', LibraryController.middleware.bind(this), LibraryController.getBooksForLibrary.bind(this))
this.router.get('/libraries/:id/categories', LibraryController.middleware.bind(this), LibraryController.getLibraryCategories.bind(this))
this.router.get('/libraries/:id/filters', LibraryController.middleware.bind(this), LibraryController.getLibraryFilters.bind(this))
//
// Item Routes
//
this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this))
this.router.get('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.getCover.bind(this))
//
// Book Routes
//

View File

@ -10,14 +10,14 @@ class CacheManager {
this.CoverCachePath = Path.join(this.CachePath, 'covers')
}
async handleCoverCache(res, audiobook, options = {}) {
async handleCoverCache(res, libraryItem, options = {}) {
const format = options.format || 'webp'
const width = options.width || 400
const height = options.height || null
res.type(`image/${format}`)
var path = Path.join(this.CoverCachePath, `${audiobook.id}_${width}${height ? `x${height}` : ''}`) + '.' + format
var path = Path.join(this.CoverCachePath, `${libraryItem.id}_${width}${height ? `x${height}` : ''}`) + '.' + format
// Cache exists
if (await fs.pathExists(path)) {
@ -35,7 +35,7 @@ class CacheManager {
// Write cache
await fs.ensureDir(this.CoverCachePath)
let writtenFile = await resizeImage(audiobook.book.coverFullPath, path, width, height)
let writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height)
if (!writtenFile) return res.sendStatus(400)
var readStream = fs.createReadStream(writtenFile)

View File

@ -69,7 +69,6 @@ class Server {
Logger.logManager = this.logManager
this.expressApp = null
this.server = null
this.io = null
@ -154,8 +153,6 @@ class Server {
await this.init()
const app = express()
this.expressApp = app
this.server = http.createServer(app)
app.use(this.auth.cors)
@ -167,9 +164,6 @@ class Server {
const distPath = Path.join(global.appRoot, '/client/dist')
app.use(express.static(distPath))
// Old static path for covers
app.use('/local', this.authMiddleware.bind(this), express.static(global.AudiobookPath))
// Metadata folder static path
app.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath))
@ -179,6 +173,9 @@ class Server {
// Static folder
app.use(express.static(Path.join(global.appRoot, 'static')))
app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
// Static file routes
app.get('/lib/:library/:folder/*', this.authMiddleware.bind(this), (req, res) => {
var library = this.db.libraries.find(lib => lib.id === req.params.library)
@ -192,6 +189,7 @@ class Server {
})
// Book static file routes
// LEGACY
app.get('/s/book/:id/*', this.authMiddleware.bind(this), (req, res) => {
var audiobook = this.db.audiobooks.find(ab => ab.id === req.params.id)
if (!audiobook) return res.status(404).send('Book not found with id ' + req.params.id)
@ -201,6 +199,16 @@ class Server {
res.sendFile(fullPath)
})
// Library Item static file routes
app.get('/s/item/:id/*', this.authMiddleware.bind(this), (req, res) => {
var item = this.db.libraryItems.find(ab => ab.id === req.params.id)
if (!item) return res.status(404).send('Item not found with id ' + req.params.id)
var remainingPath = req.params['0']
var fullPath = Path.join(item.path, remainingPath)
res.sendFile(fullPath)
})
// EBook static file routes
app.get('/ebook/:library/:folder/*', (req, res) => {
var library = this.db.libraries.find(lib => lib.id === req.params.library)
@ -214,8 +222,10 @@ class Server {
})
// Client dynamic routes
app.get('/audiobook/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/audiobook/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) // LEGACY
app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) // LEGACY
app.get('/item/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/item/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/library/:library', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/library/:library/search', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/library/:library/bookshelf/:id?', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
@ -224,31 +234,16 @@ class Server {
app.get('/config/users/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.get('/collection/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
// Incomplete work in progress
// app.use('/feeds', this.rssFeeds.router)
app.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
app.post('/upload', this.authMiddleware.bind(this), this.handleUpload.bind(this))
var loginRateLimiter = this.getLoginRateLimiter()
app.post('/login', loginRateLimiter, (req, res) => this.auth.login(req, res))
app.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
app.get('/ping', (req, res) => {
Logger.info('Recieved ping')
res.json({ success: true })
})
// Used in development to set-up streams without authentication
if (process.env.NODE_ENV !== 'production') {
app.use('/test-hls', this.hlsController.router)
}
this.server.listen(this.Port, this.Host, () => {
Logger.info(`Running on http://${this.Host}:${this.Port}`)
Logger.info(`Listening on http://${this.Host}:${this.Port}`)
})
this.io = new SocketIO.Server(this.server, {
@ -265,21 +260,25 @@ class Server {
}
socket.sheepClient = this.clients[socket.id]
Logger.info('[SOCKET] Socket Connected', socket.id)
Logger.info('[Server] Socket Connected', socket.id)
socket.on('auth', (token) => this.authenticateSocket(socket, token))
// TODO: Most of these web socket listeners will be moved to API routes instead
// with the goal of the web socket connection being a nice-to-have not need-to-have
// Scanning
socket.on('scan', this.scan.bind(this))
socket.on('cancel_scan', this.cancelScan.bind(this))
socket.on('scan_audiobook', (audiobookId) => this.scanAudiobook(socket, audiobookId))
socket.on('save_metadata', (audiobookId) => this.saveMetadata(socket, audiobookId))
// Streaming
// Streaming (only still used in the mobile app)
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
socket.on('stream_sync', (syncData) => this.streamManager.streamSync(socket, syncData))
// Used to sync when playing local book on mobile, will be moved to API route
socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket, payload))
// Downloading
@ -299,10 +298,6 @@ class Server {
socket.on('update_bookmark', (payload) => this.updateBookmark(socket, payload))
socket.on('delete_bookmark', (payload) => this.deleteBookmark(socket, payload))
socket.on('test', () => {
socket.emit('test_received', socket.id)
})
socket.on('disconnect', () => {
Logger.removeSocketListener(socket.id)

View File

@ -39,10 +39,9 @@ class LibraryController {
async findOne(req, res) {
if (req.query.include && req.query.include === 'filterdata') {
var books = this.db.audiobooks.filter(ab => ab.libraryId === req.library.id)
return res.json({
filterdata: libraryHelpers.getDistinctFilterData(books),
issues: libraryHelpers.getNumIssues(books),
filterdata: libraryHelpers.getDistinctFilterDataNew(req.libraryItems),
issues: libraryHelpers.getNumIssues(req.libraryItems),
library: req.library
})
}
@ -91,38 +90,70 @@ class LibraryController {
return res.json(libraryJson)
}
// api/libraries/:id/books
getBooksForLibrary(req, res) {
// api/libraries/:id/items
// TODO: Optimize this method, audiobooks are iterated through several times but can be combined
getLibraryItems(req, res) {
var libraryId = req.library.id
var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId)
if (req.query.filter) {
audiobooks = libraryHelpers.getFiltered(audiobooks, req.query.filter, req.user)
var media = req.query.media || 'all'
var libraryItems = this.db.libraryItems.filter(li => {
if (li.libraryId !== libraryId) return false
if (media != 'all') return li.mediaType == media
return true
})
var payload = {
results: [],
total: libraryItems.length,
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0,
sortBy: req.query.sort,
sortDesc: req.query.desc === '1',
filterBy: req.query.filter,
media,
minified: req.query.minified === '1',
collapseseries: req.query.collapseseries === '1'
}
if (req.query.sort) {
var orderByNumber = req.query.sort === 'book.volumeNumber'
var direction = req.query.desc === '1' ? 'desc' : 'asc'
audiobooks = sort(audiobooks)[direction]((ab) => {
// Supports dot notation strings i.e. "book.title"
var value = req.query.sort.split('.').reduce((a, b) => a[b], ab)
if (orderByNumber && !isNaN(value)) return Number(value)
return value
if (payload.filterBy) {
libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user)
payload.total = libraryItems.length
}
if (payload.sortBy) {
var sortKey = payload.sortBy
// old sort key
if (sortKey.startsWith('book.')) {
sortKey = sortKey.replace('book.', 'media.metadata.')
}
// Handle server setting sortingIgnorePrefix
if (sortKey === 'media.metadata.title' && this.db.serverSettings.sortingIgnorePrefix) {
// BookMetadata.js has titleIgnorePrefix getter
sortKey += 'IgnorePrefix'
}
var direction = payload.sortDesc ? 'desc' : 'asc'
libraryItems = naturalSort(libraryItems)[direction]((li) => {
// Supports dot notation strings i.e. "media.metadata.title"
return sortKey.split('.').reduce((a, b) => a[b], li)
})
}
if (req.query.limit && !isNaN(req.query.limit)) {
var page = req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0
var limit = Number(req.query.limit)
var startIndex = page * limit
audiobooks = audiobooks.slice(startIndex, startIndex + limit)
// TODO: Potentially implement collapse series again
libraryItems = libraryItems.map(ab => payload.minified ? ab.toJSONMinified() : ab.toJSON())
if (payload.limit) {
var startIndex = payload.page * payload.limit
libraryItems = libraryItems.slice(startIndex, startIndex + payload.limit)
}
res.json(audiobooks)
payload.results = libraryItems
res.json(payload)
}
// api/libraries/:id/books/all
// TODO: Optimize this method, audiobooks are iterated through several times but can be combined
getBooksForLibrary2(req, res) {
getBooksForLibrary(req, res) {
var libraryId = req.library.id
var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId)
@ -274,6 +305,7 @@ class LibraryController {
res.json(payload)
}
// LEGACY
// api/libraries/:id/books/filters
async getLibraryFilters(req, res) {
var library = req.library
@ -281,6 +313,45 @@ class LibraryController {
res.json(libraryHelpers.getDistinctFilterData(books))
}
async getLibraryFilterData(req, res) {
res.json(libraryHelpers.getDistinctFilterDataNew(req.libraryItems))
}
// api/libraries/:id/books/personalized
async getLibraryUserPersonalized(req, res) {
var libraryItems = req.libraryItems
var limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
var minified = req.query.minified === '1'
var booksWithUserAb = libraryHelpers.getBooksWithUserAudiobook(req.user, libraryItems)
var categories = [
{
id: 'continue-reading',
label: 'Continue Reading',
type: 'books',
entities: libraryHelpers.getBooksMostRecentlyRead(booksWithUserAb, limitPerShelf, minified)
},
{
id: 'recently-added',
label: 'Recently Added',
type: 'books',
entities: libraryHelpers.getBooksMostRecentlyAdded(libraryItems, limitPerShelf, minified)
},
{
id: 'read-again',
label: 'Read Again',
type: 'books',
entities: libraryHelpers.getBooksMostRecentlyFinished(booksWithUserAb, limitPerShelf, minified)
}
].filter(cats => { // Remove categories with no items
return cats.entities.length
})
res.json(categories)
}
// LEGACY
// api/libraries/:id/books/categories
async getLibraryCategories(req, res) {
var library = req.library
@ -491,6 +562,7 @@ class LibraryController {
return res.status(404).send('Library not found')
}
req.library = library
req.libraryItems = this.db.libraryItems.filter(li => li.libraryId === library.id)
next()
}
}

View File

@ -0,0 +1,37 @@
const Logger = require('../Logger')
const { reqSupportsWebp } = require('../utils/index')
class LibraryItemController {
constructor() { }
findOne(req, res) {
if (req.query.expanded == 1) return res.json(req.libraryItem.toJSONExpanded())
res.json(req.libraryItem)
}
// GET api/items/:id/cover
async getCover(req, res) {
let { query: { width, height, format }, libraryItem } = req
const options = {
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
height: height ? parseInt(height) : null,
width: width ? parseInt(width) : null
}
return this.cacheManager.handleCoverCache(res, libraryItem, options)
}
middleware(req, res, next) {
var item = this.db.libraryItems.find(li => li.id === req.params.id)
if (!item || !item.media || !item.media.coverPath) return res.sendStatus(404)
// Check user can access this audiobooks library
if (!req.user.checkCanAccessLibrary(item.libraryId)) {
return res.sendStatus(403)
}
req.libraryItem = item
next()
}
}
module.exports = new LibraryItemController()

View File

@ -1,7 +1,7 @@
const fs = require('fs-extra')
const Logger = require('../Logger')
const Path = require('path')
const Author = require('../objects/Author')
const Author = require('../objects/legacy/Author')
const Audnexus = require('../providers/Audnexus')
const { downloadFile } = require('../utils/fileUtils')

View File

@ -1,129 +0,0 @@
class AudioFileMetadata {
constructor(metadata) {
this.tagAlbum = null
this.tagArtist = null
this.tagGenre = null
this.tagTitle = null
this.tagSeries = null
this.tagSeriesPart = null
this.tagTrack = null
this.tagDisc = null
this.tagSubtitle = null
this.tagAlbumArtist = null
this.tagDate = null
this.tagComposer = null
this.tagPublisher = null
this.tagComment = null
this.tagDescription = null
this.tagEncoder = null
this.tagEncodedBy = null
this.tagIsbn = null
this.tagLanguage = null
this.tagASIN = null
if (metadata) {
this.construct(metadata)
}
}
toJSON() {
// Only return the tags that are actually set
var json = {}
for (const key in this) {
if (key.startsWith('tag') && this[key]) {
json[key] = this[key]
}
}
return json
}
construct(metadata) {
this.tagAlbum = metadata.tagAlbum || null
this.tagArtist = metadata.tagArtist || null
this.tagGenre = metadata.tagGenre || null
this.tagTitle = metadata.tagTitle || null
this.tagSeries = metadata.tagSeries || null
this.tagSeriesPart = metadata.tagSeriesPart || null
this.tagTrack = metadata.tagTrack || null
this.tagDisc = metadata.tagDisc || null
this.tagSubtitle = metadata.tagSubtitle || null
this.tagAlbumArtist = metadata.tagAlbumArtist || null
this.tagDate = metadata.tagDate || null
this.tagComposer = metadata.tagComposer || null
this.tagPublisher = metadata.tagPublisher || null
this.tagComment = metadata.tagComment || null
this.tagDescription = metadata.tagDescription || null
this.tagEncoder = metadata.tagEncoder || null
this.tagEncodedBy = metadata.tagEncodedBy || null
this.tagIsbn = metadata.tagIsbn || null
this.tagLanguage = metadata.tagLanguage || null
this.tagASIN = metadata.tagASIN || null
}
// Data parsed in prober.js
setData(payload) {
this.tagAlbum = payload.file_tag_album || null
this.tagArtist = payload.file_tag_artist || null
this.tagGenre = payload.file_tag_genre || null
this.tagTitle = payload.file_tag_title || null
this.tagSeries = payload.file_tag_series || null
this.tagSeriesPart = payload.file_tag_seriespart || null
this.tagTrack = payload.file_tag_track || null
this.tagDisc = payload.file_tag_disc || null
this.tagSubtitle = payload.file_tag_subtitle || null
this.tagAlbumArtist = payload.file_tag_albumartist || null
this.tagDate = payload.file_tag_date || null
this.tagComposer = payload.file_tag_composer || null
this.tagPublisher = payload.file_tag_publisher || null
this.tagComment = payload.file_tag_comment || null
this.tagDescription = payload.file_tag_description || null
this.tagEncoder = payload.file_tag_encoder || null
this.tagEncodedBy = payload.file_tag_encodedby || null
this.tagIsbn = payload.file_tag_isbn || null
this.tagLanguage = payload.file_tag_language || null
this.tagASIN = payload.file_tag_asin || null
}
updateData(payload) {
const dataMap = {
tagAlbum: payload.file_tag_album || null,
tagArtist: payload.file_tag_artist || null,
tagGenre: payload.file_tag_genre || null,
tagTitle: payload.file_tag_title || null,
tagSeries: payload.file_tag_series || null,
tagSeriesPart: payload.file_tag_seriespart || null,
tagTrack: payload.file_tag_track || null,
tagDisc: payload.file_tag_disc || null,
tagSubtitle: payload.file_tag_subtitle || null,
tagAlbumArtist: payload.file_tag_albumartist || null,
tagDate: payload.file_tag_date || null,
tagComposer: payload.file_tag_composer || null,
tagPublisher: payload.file_tag_publisher || null,
tagComment: payload.file_tag_comment || null,
tagDescription: payload.file_tag_description || null,
tagEncoder: payload.file_tag_encoder || null,
tagEncodedBy: payload.file_tag_encodedby || null,
tagIsbn: payload.file_tag_isbn || null,
tagLanguage: payload.file_tag_language || null,
tagASIN: payload.file_tag_asin || null
}
var hasUpdates = false
for (const key in dataMap) {
if (dataMap[key] !== this[key]) {
this[key] = dataMap[key]
hasUpdates = true
}
}
return hasUpdates
}
isEqual(audioFileMetadata) {
if (!audioFileMetadata || !audioFileMetadata.toJSON) return false
for (const key in audioFileMetadata.toJSON()) {
if (audioFileMetadata[key] !== this[key]) return false
}
return true
}
}
module.exports = AudioFileMetadata

View File

@ -17,15 +17,15 @@ class LibraryItem {
this.ctimeMs = null
this.birthtimeMs = null
this.addedAt = null
this.lastUpdate = null
this.updatedAt = null
this.lastScan = null
this.scanVersion = null
// Entity was scanned and not found
this.isMissing = false
this.entityType = null
this.entity = null
this.mediaType = null
this.media = null
this.libraryFiles = []
@ -45,17 +45,17 @@ class LibraryItem {
this.ctimeMs = libraryItem.ctimeMs || 0
this.birthtimeMs = libraryItem.birthtimeMs || 0
this.addedAt = libraryItem.addedAt
this.lastUpdate = libraryItem.lastUpdate || this.addedAt
this.updatedAt = libraryItem.updatedAt || this.addedAt
this.lastScan = libraryItem.lastScan || null
this.scanVersion = libraryItem.scanVersion || null
this.isMissing = !!libraryItem.isMissing
this.entityType = libraryItem.entityType
if (this.entityType === 'book') {
this.entity = new Book(libraryItem.entity)
} else if (this.entityType === 'podcast') {
this.entity = new Podcast(libraryItem.entity)
this.mediaType = libraryItem.mediaType
if (this.mediaType === 'book') {
this.media = new Book(libraryItem.media)
} else if (this.mediaType === 'podcast') {
this.media = new Podcast(libraryItem.media)
}
this.libraryFiles = libraryItem.libraryFiles.map(f => new LibraryFile(f))
@ -73,14 +73,64 @@ class LibraryItem {
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt,
lastUpdate: this.lastUpdate,
updatedAt: this.updatedAt,
lastScan: this.lastScan,
scanVersion: this.scanVersion,
isMissing: !!this.isMissing,
entityType: this.entityType,
entity: this.entity.toJSON(),
mediaType: this.mediaType,
media: this.media.toJSON(),
libraryFiles: this.libraryFiles.map(f => f.toJSON())
}
}
toJSONMinified() {
return {
id: this.id,
ino: this.ino,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,
relPath: this.relPath,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt,
updatedAt: this.updatedAt,
isMissing: !!this.isMissing,
mediaType: this.mediaType,
media: this.media.toJSONMinified(),
numFiles: this.libraryFiles.length
}
}
// Adds additional helpful fields like media duration, tracks, etc.
toJSONExpanded() {
return {
id: this.id,
ino: this.ino,
libraryId: this.libraryId,
folderId: this.folderId,
path: this.path,
relPath: this.relPath,
mtimeMs: this.mtimeMs,
ctimeMs: this.ctimeMs,
birthtimeMs: this.birthtimeMs,
addedAt: this.addedAt,
updatedAt: this.updatedAt,
lastScan: this.lastScan,
scanVersion: this.scanVersion,
isMissing: !!this.isMissing,
mediaType: this.mediaType,
media: this.media.toJSONExpanded(),
libraryFiles: this.libraryFiles.map(f => f.toJSON()),
size: this.size
}
}
get size() {
var total = 0
this.libraryFiles.forEach((lf) => total += lf.metadata.size)
return total
}
}
module.exports = LibraryItem

View File

@ -7,7 +7,6 @@ class Book {
this.metadata = null
this.coverPath = null
this.relCoverPath = null
this.tags = []
this.audioFiles = []
this.ebookFiles = []
@ -21,7 +20,6 @@ class Book {
construct(book) {
this.metadata = new BookMetadata(book.metadata)
this.coverPath = book.coverPath
this.relCoverPath = book.relCoverPath
this.tags = [...book.tags]
this.audioFiles = book.audioFiles.map(f => new AudioFile(f))
this.ebookFiles = book.ebookFiles.map(f => new EBookFile(f))
@ -32,12 +30,53 @@ class Book {
return {
metadata: this.metadata.toJSON(),
coverPath: this.coverPath,
relCoverPath: this.relCoverPath,
tags: [...this.tags],
audioFiles: this.audioFiles.map(f => f.toJSON()),
ebookFiles: this.ebookFiles.map(f => f.toJSON()),
chapters: this.chapters.map(c => ({ ...c }))
}
}
toJSONMinified() {
return {
metadata: this.metadata.toJSON(),
coverPath: this.coverPath,
tags: [...this.tags],
numTracks: this.tracks.length,
numAudioFiles: this.audioFiles.length,
numEbooks: this.ebookFiles.length,
numChapters: this.chapters.length,
duration: this.duration,
size: this.size
}
}
toJSONExpanded() {
return {
metadata: this.metadata.toJSONExpanded(),
coverPath: this.coverPath,
tags: [...this.tags],
audioFiles: this.audioFiles.map(f => f.toJSON()),
ebookFiles: this.ebookFiles.map(f => f.toJSON()),
chapters: this.chapters.map(c => ({ ...c })),
duration: this.duration,
size: this.size,
tracks: this.tracks.map(t => t.toJSON())
}
}
get tracks() {
return this.audioFiles.filter(af => !af.exclude && !af.invalid)
}
get duration() {
var total = 0
this.tracks.forEach((track) => total += track.duration)
return total
}
get size() {
var total = 0
this.audioFiles.forEach((af) => total += af.metadata.size)
return total
}
}
module.exports = Book

View File

@ -6,8 +6,8 @@ class Podcast {
this.id = null
this.metadata = null
this.cover = null
this.coverFullPath = null
this.coverPath = null
this.tags = []
this.episodes = []
this.createdAt = null
@ -21,8 +21,8 @@ class Podcast {
construct(podcast) {
this.id = podcast.id
this.metadata = new PodcastMetadata(podcast.metadata)
this.cover = podcast.cover
this.coverFullPath = podcast.coverFullPath
this.coverPath = podcast.coverPath
this.tags = [...podcast.tags]
this.episodes = podcast.episodes.map((e) => new PodcastEpisode(e))
this.createdAt = podcast.createdAt
this.lastUpdate = podcast.lastUpdate
@ -32,8 +32,32 @@ class Podcast {
return {
id: this.id,
metadata: this.metadata.toJSON(),
cover: this.cover,
coverFullPath: this.coverFullPath,
coverPath: this.coverPath,
tags: [...this.tags],
episodes: this.episodes.map(e => e.toJSON()),
createdAt: this.createdAt,
lastUpdate: this.lastUpdate
}
}
toJSONMinified() {
return {
id: this.id,
metadata: this.metadata.toJSON(),
coverPath: this.coverPath,
tags: [...this.tags],
episodes: this.episodes.map(e => e.toJSON()),
createdAt: this.createdAt,
lastUpdate: this.lastUpdate
}
}
toJSONExpanded() {
return {
id: this.id,
metadata: this.metadata.toJSONExpanded(),
coverPath: this.coverPath,
tags: [...this.tags],
episodes: this.episodes.map(e => e.toJSON()),
createdAt: this.createdAt,
lastUpdate: this.lastUpdate

View File

@ -1,3 +1,4 @@
const globals = require('../../utils/globals')
const FileMetadata = require('../metadata/FileMetadata')
class LibraryFile {
@ -24,8 +25,18 @@ class LibraryFile {
ino: this.ino,
metadata: this.metadata.toJSON(),
addedAt: this.addedAt,
updatedAt: this.updatedAt
updatedAt: this.updatedAt,
fileType: this.fileType
}
}
get fileType() {
if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image'
if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio'
if (globals.SupportedEbookTypes.includes(this.metadata.format)) return 'ebook'
if (globals.TextFileTypes.includes(this.metadata.format)) return 'text'
if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata'
return 'unknown'
}
}
module.exports = LibraryFile

View File

@ -1,5 +1,5 @@
const { isNullOrNaN } = require('../utils/index')
const AudioFileMetadata = require('./metadata/AudioMetaTags')
const { isNullOrNaN } = require('../../utils/index')
const AudioFileMetadata = require('../metadata/AudioMetaTags')
class AudioFile {
constructor(data) {

View File

@ -1,4 +1,4 @@
var { bytesPretty } = require('../utils/fileUtils')
var { bytesPretty } = require('../../utils/fileUtils')
class AudioTrack {
constructor(audioTrack = null) {

View File

@ -1,12 +1,12 @@
const Path = require('path')
const fs = require('fs-extra')
const { bytesPretty, readTextFile, getIno } = require('../utils/fileUtils')
const { comparePaths, getId, elapsedPretty } = require('../utils/index')
const { parseOpfMetadataXML } = require('../utils/parseOpfMetadata')
const { extractCoverArt } = require('../utils/ffmpegHelpers')
const nfoGenerator = require('../utils/nfoGenerator')
const abmetadataGenerator = require('../utils/abmetadataGenerator')
const Logger = require('../Logger')
const { bytesPretty, readTextFile, getIno } = require('../../utils/fileUtils')
const { comparePaths, getId, elapsedPretty } = require('../../utils/index')
const { parseOpfMetadataXML } = require('../../utils/parseOpfMetadata')
const { extractCoverArt } = require('../../utils/ffmpegHelpers')
const nfoGenerator = require('../../utils/nfoGenerator')
const abmetadataGenerator = require('../../utils/abmetadataGenerator')
const Logger = require('../../Logger')
const Book = require('./Book')
const AudioTrack = require('./AudioTrack')
const AudioFile = require('./AudioFile')

View File

@ -1,5 +1,5 @@
const { getId } = require('../utils/index')
const Logger = require('../Logger')
const { getId } = require('../../utils/index')
const Logger = require('../../Logger')
class Author {
constructor(author = null) {

View File

@ -1,6 +1,6 @@
const Path = require('path')
const Logger = require('../Logger')
const parseAuthors = require('../utils/parseAuthors')
const Logger = require('../../Logger')
const parseAuthors = require('../../utils/parseAuthors')
class Book {
constructor(book = null) {

View File

@ -13,6 +13,7 @@ class BookMetadata {
this.isbn = null
this.asin = null
this.language = null
this.explicit = false
if (metadata) {
this.construct(metadata)
@ -33,6 +34,7 @@ class BookMetadata {
this.isbn = metadata.isbn
this.asin = metadata.asin
this.language = metadata.language
this.explicit = metadata.explicit
}
toJSON() {
@ -49,8 +51,54 @@ class BookMetadata {
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language
language: this.language,
explicit: this.explicit
}
}
toJSONExpanded() {
return {
title: this.title,
subtitle: this.subtitle,
authors: this.authors.map(a => ({ ...a })), // Author JSONMinimal with name and id
narrators: [...this.narrators],
series: this.series.map(s => ({ ...s })),
genres: [...this.genres],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
publisher: this.publisher,
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: this.explicit,
authorName: this.authorName,
narratorName: this.narratorName
}
}
get titleIgnorePrefix() {
if (!this.title) return ''
if (this.title.toLowerCase().startsWith('the ')) {
return this.title.substr(4) + ', The'
}
return this.title
}
get authorName() {
return this.authors.map(au => au.name).join(', ')
}
get narratorName() {
return this.narrators.join(', ')
}
hasAuthor(authorName) {
return !!this.authors.find(au => au.name == authorName)
}
hasSeries(seriesName) {
return !!this.series.find(se => se.name == seriesName)
}
hasNarrator(narratorName) {
return this.narrators.includes(narratorName)
}
}
module.exports = BookMetadata

View File

@ -41,5 +41,10 @@ class FileMetadata {
clone() {
return new FileMetadata(this.toJSON())
}
get format() {
if (!this.ext) return ''
return this.ext.slice(1)
}
}
module.exports = FileMetadata

View File

@ -9,6 +9,7 @@ class PodcastMetadata {
this.itunesPageUrl = null
this.itunesId = null
this.itunesArtistId = null
this.explicit = false
if (metadata) {
this.construct(metadata)
@ -25,6 +26,7 @@ class PodcastMetadata {
this.itunesPageUrl = metadata.itunesPageUrl
this.itunesId = metadata.itunesId
this.itunesArtistId = metadata.itunesArtistId
this.explicit = metadata.explicit
}
toJSON() {
@ -38,7 +40,12 @@ class PodcastMetadata {
itunesPageUrl: this.itunesPageUrl,
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
explicit: this.explicit
}
}
toJSONExpanded() {
return this.toJSON()
}
}
module.exports = PodcastMetadata

View File

@ -1,4 +1,4 @@
const AudioFileMetadata = require('../objects/AudioFileMetadata')
const AudioFileMetadata = require('../objects/metadata/AudioMetaTags')
class AudioProbeData {
constructor() {

View File

@ -10,7 +10,7 @@ const { ScanResult, LogLevel } = require('../utils/constants')
const AudioFileScanner = require('./AudioFileScanner')
const BookFinder = require('../finders/BookFinder')
const Audiobook = require('../objects/Audiobook')
const Audiobook = require('../objects/legacy/Audiobook')
const LibraryScan = require('./LibraryScan')
const ScanOptions = require('./ScanOptions')

View File

@ -3,7 +3,7 @@ const fs = require('fs-extra')
const njodb = require("njodb")
const { SupportedEbookTypes } = require('./globals')
const Audiobook = require('../objects/Audiobook')
const Audiobook = require('../objects/legacy/Audiobook')
const LibraryItem = require('../objects/LibraryItem')
const Logger = require('../Logger')
@ -142,7 +142,7 @@ function makeLibraryItemFromOldAb(audiobook) {
libraryItem.lastScan = audiobook.lastScan
libraryItem.scanVersion = audiobook.scanVersion
libraryItem.isMissing = audiobook.isMissing
libraryItem.entityType = 'book'
libraryItem.mediaType = 'book'
var bookEntity = new Book()
var bookMetadata = new BookMetadata(audiobook.book)
@ -159,8 +159,6 @@ function makeLibraryItemFromOldAb(audiobook) {
bookEntity.metadata = bookMetadata
bookEntity.coverPath = audiobook.book.coverFullPath
// Path relative to library item
bookEntity.relCoverPath = getRelativePath(audiobook.book.coverFullPath, audiobook.fullPath)
bookEntity.tags = [...audiobook.tags]
var payload = makeFilesFromOldAb(audiobook)
@ -171,7 +169,7 @@ function makeLibraryItemFromOldAb(audiobook) {
bookEntity.chapters = audiobook.chapters.map(c => ({ ...c }))
}
libraryItem.entity = bookEntity
libraryItem.media = bookEntity
libraryItem.libraryFiles = payload.libraryFiles
return libraryItem
}

View File

@ -1,7 +1,9 @@
const globals = {
SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'],
SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'mp4', 'aac'],
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz']
SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
TextFileTypes: ['txt', 'nfo'],
MetadataFileTypes: ['opf', 'abs']
}
module.exports = globals

View File

@ -8,6 +8,45 @@ module.exports = {
return Buffer.from(decodeURIComponent(text), 'base64').toString()
},
getFilteredLibraryItems(libraryItems, filterBy, user) {
var filtered = libraryItems
var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'languages']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) {
var filterVal = filterBy.replace(`${group}.`, '')
var filter = this.decode(filterVal)
if (group === 'genres') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.genres.includes(filter))
else if (group === 'tags') filtered = filtered.filter(li => li.media.tags.includes(filter))
else if (group === 'series') {
if (filter === 'No Series') filtered = filtered.filter(li => li.media.metadata && !li.media.metadata.series.length)
else filtered = filtered.filter(li => li.media.metadata && li.media.metadata.hasSeries(filter))
}
else if (group === 'authors') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.hasAuthor(filter))
else if (group === 'narrators') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.hasNarrator(filter))
else if (group === 'progress') {
filtered = filtered.filter(li => {
var userAudiobook = user.getAudiobookJSON(li.id)
var isRead = userAudiobook && userAudiobook.isRead
if (filter === 'Read' && isRead) return true
if (filter === 'Unread' && !isRead) return true
if (filter === 'In Progress' && (userAudiobook && !userAudiobook.isRead && userAudiobook.progress > 0)) return true
return false
})
} else if (group === 'languages') {
filtered = filtered.filter(li => li.media.metadata && li.media.metadata.language === filter)
}
} else if (filterBy === 'issues') {
filtered = filtered.filter(ab => {
// TODO: Update filter for issues
return ab.isMissing
// return ab.numMissingParts || ab.numInvalidParts || ab.isMissing || ab.isInvalid
})
}
return filtered
},
getFiltered(audiobooks, filterBy, user) {
var filtered = audiobooks
@ -45,6 +84,55 @@ module.exports = {
return filtered
},
getDistinctFilterDataNew(libraryItems) {
var data = {
authors: [],
genres: [],
tags: [],
series: [],
narrators: [],
languages: []
}
libraryItems.forEach((li) => {
var mediaMetadata = li.media.metadata
if (mediaMetadata.authors.length) {
mediaMetadata.authors.forEach((author) => {
if (author && !data.authors.includes(author.name)) data.authors.push(author.name)
})
}
if (mediaMetadata.series.length) {
mediaMetadata.series.forEach((series) => {
if (series && !data.series.includes(series.name)) data.series.push(series.name)
})
}
if (mediaMetadata.genres.length) {
mediaMetadata.genres.forEach((genre) => {
if (genre && !data.genres.includes(genre)) data.genres.push(genre)
})
}
if (li.media.tags.length) {
li.media.tags.forEach((tag) => {
if (tag && !data.tags.includes(tag)) data.tags.push(tag)
})
}
if (mediaMetadata.narrators.length) {
mediaMetadata.narrators.forEach((narrator) => {
if (narrator && !data.narrators.includes(narrator)) data.narrators.push(narrator)
})
}
if (mediaMetadata.language && !data.languages.includes(mediaMetadata.language)) data.languages.push(mediaMetadata.language)
})
data.authors = naturalSort(data.authors).asc()
data.genres = naturalSort(data.genres).asc()
data.tags = naturalSort(data.tags).asc()
data.series = naturalSort(data.series).asc()
data.narrators = naturalSort(data.narrators).asc()
data.languages = naturalSort(data.languages).asc()
return data
},
// TODO: Remove legacy
getDistinctFilterData(audiobooks) {
var data = {
authors: [],
@ -246,9 +334,11 @@ module.exports = {
return totalSize
},
getNumIssues(books) {
return books.filter(ab => {
return ab.numMissingParts || ab.numInvalidParts || ab.isMissing || ab.isInvalid
}).length
getNumIssues(libraryItems) {
// TODO: Implement issues
return libraryItems.filter(li => li.isMissing).length
// return books.filter(ab => {
// return ab.numMissingParts || ab.numInvalidParts || ab.isMissing || ab.isInvalid
// }).length
}
}