mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-11-07 16:44:16 +01:00
Add:Chapter editor, lookup chapters via audnexus, chapters table on audiobook landing page #435
This commit is contained in:
parent
095f49824e
commit
cc1181b301
@ -150,12 +150,6 @@ export default {
|
||||
toggleBookshelfTexture() {
|
||||
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
|
||||
},
|
||||
async back() {
|
||||
var popped = await this.$store.dispatch('popRoute')
|
||||
if (popped) this.$store.commit('setIsRoutingBack', true)
|
||||
var backTo = popped || '/'
|
||||
this.$router.push(backTo)
|
||||
},
|
||||
cancelSelectionMode() {
|
||||
if (this.processingBatchDelete) return
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
|
@ -1,32 +1,11 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div class="w-full mb-4">
|
||||
<div v-if="chapters.length" class="w-full p-4 bg-primary">
|
||||
<p>Audiobook Chapters</p>
|
||||
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" keep-open />
|
||||
<div v-if="!chapters.length" class="py-4 text-center">
|
||||
<p class="mb-8 text-xl">No Chapters</p>
|
||||
<ui-btn v-if="userCanUpdate" :to="`/audiobook/${libraryItem.id}/chapters`">Add Chapters</ui-btn>
|
||||
</div>
|
||||
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
|
||||
<table v-else class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
||||
<th class="text-left">Title</th>
|
||||
<th class="text-center">Start</th>
|
||||
<th class="text-center">End</th>
|
||||
</tr>
|
||||
<tr v-for="chapter in chapters" :key="chapter.id">
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ chapter.id }}</p>
|
||||
</td>
|
||||
<td class="font-book">
|
||||
{{ chapter.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.end) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -48,6 +27,9 @@ export default {
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {}
|
||||
|
74
client/components/tables/ChaptersTable.vue
Normal file
74
client/components/tables/ChaptersTable.vue
Normal file
@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="w-full my-2">
|
||||
<div class="w-full bg-primary px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||
<p class="pr-4">Chapters</p>
|
||||
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ chapters.length }}</span>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="userCanUpdate" small :to="`/audiobook/${libraryItemId}/chapters`" color="primary" class="mr-2">Edit Chapters</ui-btn>
|
||||
<div v-if="!keepOpen" class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="expanded ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-4xl">expand_more</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
<table class="text-sm tracksTable" v-show="expanded || keepOpen">
|
||||
<tr class="font-book">
|
||||
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
||||
<th class="text-left">Title</th>
|
||||
<th class="text-center">Start</th>
|
||||
<th class="text-center">End</th>
|
||||
</tr>
|
||||
<tr v-for="chapter in chapters" :key="chapter.id">
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ chapter.id }}</p>
|
||||
</td>
|
||||
<td class="font-book">
|
||||
{{ chapter.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(chapter.end) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
keepOpen: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
expanded: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.expanded = !this.expanded
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
@ -1,30 +0,0 @@
|
||||
export default function (context) {
|
||||
if (process.client) {
|
||||
var route = context.route
|
||||
var from = context.from
|
||||
var store = context.store
|
||||
|
||||
if (route.name === 'login' || from.name === 'login') return
|
||||
|
||||
if (!route.name) {
|
||||
console.warn('No Route name', route)
|
||||
return
|
||||
}
|
||||
|
||||
if (store.state.isRoutingBack) {
|
||||
// pressing back button in appbar do not add to route history
|
||||
store.commit('setIsRoutingBack', false)
|
||||
return
|
||||
}
|
||||
|
||||
if (route.name.startsWith('config') || route.name === 'upload' || route.name === 'account' || route.name.startsWith('audiobook-id') || route.name.startsWith('collection-id')) {
|
||||
if (from.name !== route.name && from.name !== 'audiobook-id-edit' && !from.name.startsWith('config') && from.name !== 'upload' && from.name !== 'account') {
|
||||
var _history = [...store.state.routeHistory]
|
||||
if (!_history.length || _history[_history.length - 1] !== from.fullPath) {
|
||||
_history.push(from.fullPath)
|
||||
store.commit('setRouteHistory', _history)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -36,9 +36,7 @@ module.exports = {
|
||||
]
|
||||
},
|
||||
|
||||
router: {
|
||||
middleware: ['routed']
|
||||
},
|
||||
router: {},
|
||||
|
||||
// Global CSS: https://go.nuxtjs.dev/config-css
|
||||
css: [
|
||||
|
422
client/pages/audiobook/_id/chapters.vue
Normal file
422
client/pages/audiobook/_id/chapters.vue
Normal file
@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<div id="page-wrapper" class="bg-bg page overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
|
||||
<div class="flex items-center py-4 max-w-7xl mx-auto">
|
||||
<nuxt-link :to="`/item/${libraryItem.id}`" class="hover:underline">
|
||||
<h1 class="text-xl">{{ title }}</h1>
|
||||
</nuxt-link>
|
||||
<button class="w-7 h-7 flex items-center justify-center mx-4 hover:scale-110 duration-100 transform text-gray-200 hover:text-white" @click="editItem">
|
||||
<span class="material-icons text-base">edit</span>
|
||||
</button>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-base">Duration:</p>
|
||||
<p class="text-base font-mono ml-8">{{ mediaDuration }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap-reverse justify-center py-4">
|
||||
<div class="w-full max-w-3xl py-4">
|
||||
<div class="flex items-center">
|
||||
<p class="text-lg mb-4 font-semibold">Audiobook Chapters</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="primary" small class="mx-2" @click="showFindChaptersModal = true">Lookup</ui-btn>
|
||||
<ui-btn color="success" small @click="saveChapters">Save</ui-btn>
|
||||
<div class="w-40" />
|
||||
</div>
|
||||
|
||||
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
||||
<div class="w-12"></div>
|
||||
<div class="w-32 px-2">Start</div>
|
||||
<div class="flex-grow px-2">Title</div>
|
||||
<div class="w-40"></div>
|
||||
</div>
|
||||
<template v-for="chapter in newChapters">
|
||||
<div :key="chapter.id" class="flex py-1">
|
||||
<div class="w-12">#{{ chapter.id + 1 }}</div>
|
||||
<div class="w-32 px-1">
|
||||
<ui-text-input v-model="chapter.start" type="number" class="text-xs" @change="checkChapters" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input v-model="chapter.title" class="text-xs" />
|
||||
</div>
|
||||
<div class="w-40 px-2 py-1">
|
||||
<div class="flex items-center">
|
||||
<button v-if="newChapters.length > 1" class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-error transform hover:scale-110 duration-150" @click="removeChapter(chapter)">
|
||||
<span class="material-icons-outlined text-base">remove</span>
|
||||
</button>
|
||||
|
||||
<ui-tooltip text="Insert chapter below" direction="bottom">
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-success transform hover:scale-110 duration-150" @click="addChapter(chapter)">
|
||||
<span class="material-icons text-lg">add</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center text-gray-300 hover:text-white transform hover:scale-110 duration-150" @click="playChapter(chapter)">
|
||||
<widgets-loading-spinner v-if="selectedChapterId === chapter.id && isLoadingChapter" />
|
||||
<span v-else-if="selectedChapterId === chapter.id && isPlayingChapter" class="material-icons-outlined text-base">pause</span>
|
||||
<span v-else class="material-icons-outlined text-base">play_arrow</span>
|
||||
</button>
|
||||
|
||||
<ui-tooltip v-if="chapter.error" :text="chapter.error" direction="left">
|
||||
<button class="w-7 h-7 rounded-full flex items-center justify-center text-error">
|
||||
<span class="material-icons-outlined text-lg">error_outline</span>
|
||||
</button>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-xl py-4">
|
||||
<p class="text-lg mb-4 font-semibold py-1">Audio Tracks</p>
|
||||
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
||||
<div class="flex-grow">Filename</div>
|
||||
<div class="w-20">Duration</div>
|
||||
<div class="w-20 text-center">Chapters</div>
|
||||
</div>
|
||||
<template v-for="track in audioTracks">
|
||||
<div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success bg-opacity-10' : ''">
|
||||
<div class="flex-grow">
|
||||
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
|
||||
</div>
|
||||
<div class="w-20" style="min-width: 80px">
|
||||
<p class="text-xs font-mono text-gray-200">{{ track.duration }}</p>
|
||||
</div>
|
||||
<div class="w-20 flex justify-center" style="min-width: 80px">
|
||||
<span v-if="(track.chapters || []).length" class="material-icons text-success text-sm">check</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="saving" class="w-full h-full absolute top-0 left-0 bottom-0 right-0 z-30 bg-black bg-opacity-25 flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
|
||||
<modals-modal v-model="showFindChaptersModal" name="edit-book" :width="500" :processing="findingChapters">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||
<p class="font-book text-3xl text-white truncate pointer-events-none">Find Chapters</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full max-h-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative">
|
||||
<div v-if="!chapterData" class="flex p-20">
|
||||
<ui-text-input-with-label v-model="asinInput" label="ASIN" />
|
||||
<ui-btn small color="primary" class="mt-5 ml-2" @click="findChapters">Find</ui-btn>
|
||||
</div>
|
||||
<div v-else class="w-full p-4">
|
||||
<p class="mb-4">Duration found: {{ chapterData.runtimeLengthSec }}</p>
|
||||
<div v-if="chapterData.runtimeLengthSec > mediaDuration" class="w-full bg-error bg-opacity-25 p-4 text-center mb-2 rounded border border-white border-opacity-10 text-gray-100 text-sm">
|
||||
<p>Chapter data invalid duration<br />Your media duration is shorter than duration found</p>
|
||||
</div>
|
||||
|
||||
<div class="flex py-0.5 text-xs font-semibold uppercase text-gray-300 mb-1">
|
||||
<div class="w-24 px-2">Start</div>
|
||||
<div class="flex-grow px-2">Title</div>
|
||||
</div>
|
||||
<div class="w-full max-h-80 overflow-y-auto my-2">
|
||||
<div v-for="(chapter, index) in chapterData.chapters" :key="index" class="flex py-0.5 text-xs" :class="chapter.startOffsetSec > mediaDuration ? 'bg-error bg-opacity-20' : chapter.startOffsetSec + chapter.lengthMs / 1000 > mediaDuration ? 'bg-warning bg-opacity-20' : index % 2 === 0 ? 'bg-primary bg-opacity-30' : ''">
|
||||
<div class="w-24 min-w-24 px-2">
|
||||
<p class="font-mono">{{ $secondsToTimestamp(chapter.startOffsetSec) }}</p>
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<p class="truncate max-w-sm">{{ chapter.title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex pt-2">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small color="success" @click="applyChapterData">Apply Chapters</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, params, app, redirect, route }) {
|
||||
if (!store.getters['user/getUserCanUpdate']) {
|
||||
return redirect('/?error=unauthorized')
|
||||
}
|
||||
var libraryItem = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
if (!libraryItem) {
|
||||
console.error('Not found...', params.id)
|
||||
return redirect('/')
|
||||
}
|
||||
if (libraryItem.mediaType != 'book') {
|
||||
console.error('Invalid media type')
|
||||
return redirect('/')
|
||||
}
|
||||
return {
|
||||
libraryItem
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newChapters: [],
|
||||
selectedChapter: null,
|
||||
audioEl: null,
|
||||
isPlayingChapter: false,
|
||||
isLoadingChapter: false,
|
||||
currentTrackIndex: 0,
|
||||
saving: false,
|
||||
asinInput: null,
|
||||
findingChapters: false,
|
||||
showFindChaptersModal: false,
|
||||
chapterData: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
title() {
|
||||
return this.mediaMetadata.title
|
||||
},
|
||||
mediaDuration() {
|
||||
return this.media.duration
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
tracks() {
|
||||
return this.media.tracks || []
|
||||
},
|
||||
audioFiles() {
|
||||
return this.media.audioFiles || []
|
||||
},
|
||||
audioTracks() {
|
||||
return this.audioFiles.filter((af) => !af.exclude && !af.invalid)
|
||||
},
|
||||
selectedChapterId() {
|
||||
return this.selectedChapter ? this.selectedChapter.id : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editItem() {
|
||||
this.$store.commit('showEditModal', this.libraryItem)
|
||||
},
|
||||
addChapter(chapter) {
|
||||
console.log('Add chapter', chapter)
|
||||
const newChapter = {
|
||||
id: chapter.id + 1,
|
||||
start: chapter.start,
|
||||
end: chapter.end,
|
||||
title: ''
|
||||
}
|
||||
this.newChapters.splice(chapter.id + 1, 0, newChapter)
|
||||
this.checkChapters()
|
||||
},
|
||||
removeChapter(chapter) {
|
||||
this.newChapters = this.newChapters.filter((ch) => ch.id !== chapter.id)
|
||||
this.checkChapters()
|
||||
},
|
||||
checkChapters() {
|
||||
var previousStart = 0
|
||||
for (let i = 0; i < this.newChapters.length; i++) {
|
||||
this.newChapters[i].id = i
|
||||
this.newChapters[i].start = Number(this.newChapters[i].start)
|
||||
|
||||
if (i === 0 && this.newChapters[i].start !== 0) {
|
||||
this.newChapters[i].error = 'First chapter must start at 0'
|
||||
} else if (this.newChapters[i].start <= previousStart && i > 0) {
|
||||
this.newChapters[i].error = 'Invalid start time must be >= previous chapter start time'
|
||||
} else if (this.newChapters[i].start >= this.mediaDuration) {
|
||||
this.newChapters[i].error = 'Invalid start time must be < duration'
|
||||
} else {
|
||||
this.newChapters[i].error = null
|
||||
}
|
||||
previousStart = this.newChapters[i].start
|
||||
}
|
||||
},
|
||||
playChapter(chapter) {
|
||||
console.log('Play Chapter', chapter.id)
|
||||
if (this.selectedChapterId === chapter.id) {
|
||||
console.log('Chapter already playing', this.isLoadingChapter, this.isPlayingChapter)
|
||||
if (this.isLoadingChapter) return
|
||||
if (this.isPlayingChapter) {
|
||||
console.log('Destroying chapter')
|
||||
this.destroyAudioEl()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.selectedChapterId) {
|
||||
this.destroyAudioEl()
|
||||
}
|
||||
|
||||
const audioTrack = this.tracks.find((at) => {
|
||||
return chapter.start >= at.startOffset && chapter.start < at.startOffset + at.duration
|
||||
})
|
||||
console.log('audio track', audioTrack)
|
||||
|
||||
this.selectedChapter = chapter
|
||||
this.isLoadingChapter = true
|
||||
|
||||
const trackOffset = chapter.start - audioTrack.startOffset
|
||||
this.playTrackAtTime(audioTrack, trackOffset)
|
||||
},
|
||||
playTrackAtTime(audioTrack, trackOffset) {
|
||||
this.currentTrackIndex = audioTrack.index
|
||||
|
||||
const audioEl = this.audioEl || document.createElement('audio')
|
||||
var src = audioTrack.contentUrl + `?token=${this.userToken}`
|
||||
if (this.$isDev) {
|
||||
src = `http://localhost:3333${src}`
|
||||
}
|
||||
console.log('src', src)
|
||||
|
||||
audioEl.src = src
|
||||
audioEl.id = 'chapter-audio'
|
||||
document.body.appendChild(audioEl)
|
||||
|
||||
audioEl.addEventListener('loadeddata', () => {
|
||||
console.log('Audio loaded data', audioEl.duration)
|
||||
audioEl.currentTime = trackOffset
|
||||
audioEl.play()
|
||||
console.log('Playing audio at current time', trackOffset)
|
||||
})
|
||||
audioEl.addEventListener('play', () => {
|
||||
console.log('Audio playing')
|
||||
this.isLoadingChapter = false
|
||||
this.isPlayingChapter = true
|
||||
})
|
||||
audioEl.addEventListener('ended', () => {
|
||||
console.log('Audio ended')
|
||||
const nextTrack = this.tracks.find((t) => t.index === this.currentTrackIndex + 1)
|
||||
if (nextTrack) {
|
||||
console.log('Playing next track', nextTrack.index)
|
||||
this.currentTrackIndex = nextTrack.index
|
||||
this.playTrackAtTime(nextTrack, 0)
|
||||
} else {
|
||||
console.log('No next track')
|
||||
this.destroyAudioEl()
|
||||
}
|
||||
})
|
||||
this.audioEl = audioEl
|
||||
},
|
||||
destroyAudioEl() {
|
||||
if (!this.audioEl) return
|
||||
this.audioEl.remove()
|
||||
this.audioEl = null
|
||||
this.selectedChapter = null
|
||||
this.isPlayingChapter = false
|
||||
this.isLoadingChapter = false
|
||||
},
|
||||
saveChapters() {
|
||||
this.checkChapters()
|
||||
|
||||
for (let i = 0; i < this.newChapters.length; i++) {
|
||||
if (this.newChapters[i].error) {
|
||||
this.$toast.error('Chapters have errors')
|
||||
return
|
||||
}
|
||||
if (!this.newChapters[i].title) {
|
||||
this.$toast.error('Chapters must have titles')
|
||||
return
|
||||
}
|
||||
|
||||
const nextChapter = this.newChapters[i + 1]
|
||||
if (nextChapter) {
|
||||
this.newChapters[i].end = nextChapter.start
|
||||
} else {
|
||||
this.newChapters[i].end = this.mediaDuration
|
||||
}
|
||||
}
|
||||
|
||||
this.saving = true
|
||||
|
||||
console.log('udpated chapters', this.newChapters)
|
||||
const payload = {
|
||||
chapters: this.newChapters
|
||||
}
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItem.id}/chapters`, payload)
|
||||
.then((data) => {
|
||||
this.saving = false
|
||||
if (data.updated) {
|
||||
this.$toast.success('Chapters updated')
|
||||
this.$router.push(`/item/${this.libraryItem.id}`)
|
||||
} else {
|
||||
this.$toast.info('No changes needed updating')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.saving = false
|
||||
console.error('Failed to update chapters', error)
|
||||
this.$toast.error('Failed to update chapters')
|
||||
})
|
||||
},
|
||||
applyChapterData() {
|
||||
var index = 0
|
||||
this.newChapters = this.chapterData.chapters
|
||||
.filter((chap) => chap.startOffsetSec < this.mediaDuration)
|
||||
.map((chap) => {
|
||||
var chapEnd = Math.min(this.mediaDuration, (chap.startOffsetMs + chap.lengthMs) / 1000)
|
||||
return {
|
||||
id: index++,
|
||||
start: chap.startOffsetMs / 1000,
|
||||
end: chapEnd,
|
||||
title: chap.title
|
||||
}
|
||||
})
|
||||
this.showFindChaptersModal = false
|
||||
this.chapterData = null
|
||||
},
|
||||
findChapters() {
|
||||
if (!this.asinInput) {
|
||||
this.$toast.error('Must input an ASIN')
|
||||
return
|
||||
}
|
||||
this.findingChapters = true
|
||||
this.chapterData = null
|
||||
this.$axios
|
||||
.$get(`/api/search/chapters?asin=${this.asinInput}`)
|
||||
.then((data) => {
|
||||
this.findingChapters = false
|
||||
|
||||
if (data.error) {
|
||||
this.$toast.error(data.error)
|
||||
this.showFindChaptersModal = false
|
||||
} else {
|
||||
console.log('Chapter data', data)
|
||||
this.chapterData = data
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.findingChapters = false
|
||||
console.error('Failed to get chapter data', error)
|
||||
this.$toast.error('Failed to find chapters')
|
||||
this.showFindChaptersModal = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.asinInput = this.mediaMetadata.asin || null
|
||||
this.newChapters = this.chapters.map((c) => ({ ...c }))
|
||||
if (!this.newChapters.length) {
|
||||
this.newChapters = [
|
||||
{
|
||||
id: 0,
|
||||
start: 0,
|
||||
end: this.mediaDuration,
|
||||
title: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@ -89,9 +89,6 @@ export default {
|
||||
draggable
|
||||
},
|
||||
async asyncData({ store, params, app, redirect, route }) {
|
||||
if (!store.state.user.user) {
|
||||
return redirect(`/login?redirect=${route.path}`)
|
||||
}
|
||||
if (!store.getters['user/getUserCanUpdate']) {
|
||||
return redirect('/?error=unauthorized')
|
||||
}
|
||||
|
@ -177,6 +177,8 @@
|
||||
|
||||
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" />
|
||||
|
||||
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" 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>
|
||||
@ -275,6 +277,9 @@ export default {
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
},
|
||||
tracks() {
|
||||
return this.media.tracks || []
|
||||
},
|
||||
|
@ -7,9 +7,6 @@
|
||||
<app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
|
||||
<div v-else class="w-full py-16">
|
||||
<p class="text-xl text-center">No Search results for "{{ query }}"</p>
|
||||
<div class="flex justify-center">
|
||||
<ui-btn class="w-52 my-4" @click="back">Back</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -79,12 +76,6 @@ export default {
|
||||
this.$refs.bookshelf.setShelvesFromSearch()
|
||||
}
|
||||
})
|
||||
},
|
||||
async back() {
|
||||
var popped = await this.$store.dispatch('popRoute')
|
||||
if (popped) this.$store.commit('setIsRoutingBack', true)
|
||||
var backTo = popped || '/'
|
||||
this.$router.push(backTo)
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
|
@ -15,8 +15,6 @@ export const state = () => ({
|
||||
selectedLibraryItems: [],
|
||||
processingBatch: false,
|
||||
previousPath: '/',
|
||||
routeHistory: [],
|
||||
isRoutingBack: false,
|
||||
showExperimentalFeatures: false,
|
||||
backups: [],
|
||||
bookshelfBookIds: [],
|
||||
@ -74,15 +72,6 @@ export const actions = {
|
||||
return false
|
||||
})
|
||||
},
|
||||
popRoute({ commit, state }) {
|
||||
if (!state.routeHistory.length) {
|
||||
return null
|
||||
}
|
||||
var _history = [...state.routeHistory]
|
||||
var last = _history.pop()
|
||||
commit('setRouteHistory', _history)
|
||||
return last
|
||||
},
|
||||
setBookshelfTexture({ commit, state }, img) {
|
||||
let root = document.documentElement;
|
||||
commit('setBookshelfTexture', img)
|
||||
@ -94,12 +83,6 @@ export const mutations = {
|
||||
setBookshelfBookIds(state, val) {
|
||||
state.bookshelfBookIds = val || []
|
||||
},
|
||||
setRouteHistory(state, val) {
|
||||
state.routeHistory = val
|
||||
},
|
||||
setIsRoutingBack(state, val) {
|
||||
state.isRoutingBack = val
|
||||
},
|
||||
setPreviousPath(state, val) {
|
||||
state.previousPath = val
|
||||
},
|
||||
|
@ -359,7 +359,7 @@ class LibraryItemController {
|
||||
})
|
||||
}
|
||||
|
||||
// POST: api/items/:id/audio-metadata
|
||||
// GET: api/items/:id/audio-metadata
|
||||
async updateAudioFileMetadata(req, res) {
|
||||
if (!req.user.isAdminOrUp) {
|
||||
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
|
||||
@ -375,6 +375,36 @@ class LibraryItemController {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
// POST: api/items/:id/chapters
|
||||
async updateMediaChapters(req, res) {
|
||||
if (!req.user.canUpdate) {
|
||||
Logger.error(`[LibraryItemController] User attempted to update chapters with invalid permissions`, req.user.username)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) {
|
||||
Logger.error(`[LibraryItemController] Invalid library item`)
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
const chapters = req.body.chapters || []
|
||||
if (!chapters.length) {
|
||||
Logger.error(`[LibraryItemController] Invalid payload`)
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
const wasUpdated = req.libraryItem.media.updateChapters(chapters)
|
||||
if (wasUpdated) {
|
||||
await this.db.updateLibraryItem(req.libraryItem)
|
||||
this.emitter('item_updated', req.libraryItem.toJSONExpanded())
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
updated: wasUpdated
|
||||
})
|
||||
}
|
||||
|
||||
middleware(req, res, next) {
|
||||
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||
if (!item || !item.media) return res.sendStatus(404)
|
||||
|
@ -225,6 +225,15 @@ class MiscController {
|
||||
res.json(author)
|
||||
}
|
||||
|
||||
async findChapters(req, res) {
|
||||
var asin = req.query.asin
|
||||
var chapterData = await this.bookFinder.findChapters(asin)
|
||||
if (!chapterData) {
|
||||
return res.json({ error: 'Chapters not found' })
|
||||
}
|
||||
res.json(chapterData)
|
||||
}
|
||||
|
||||
authorize(req, res) {
|
||||
if (!req.user) {
|
||||
Logger.error('Invalid user in authorize')
|
||||
|
@ -3,6 +3,7 @@ const LibGen = require('../providers/LibGen')
|
||||
const GoogleBooks = require('../providers/GoogleBooks')
|
||||
const Audible = require('../providers/Audible')
|
||||
const iTunes = require('../providers/iTunes')
|
||||
const Audnexus = require('../providers/Audnexus')
|
||||
const Logger = require('../Logger')
|
||||
const { levenshteinDistance } = require('../utils/index')
|
||||
|
||||
@ -13,6 +14,7 @@ class BookFinder {
|
||||
this.googleBooks = new GoogleBooks()
|
||||
this.audible = new Audible()
|
||||
this.iTunesApi = new iTunes()
|
||||
this.audnexus = new Audnexus()
|
||||
|
||||
this.verbose = false
|
||||
}
|
||||
@ -226,5 +228,9 @@ class BookFinder {
|
||||
})
|
||||
return covers
|
||||
}
|
||||
|
||||
findChapters(asin) {
|
||||
return this.audnexus.getChaptersByASIN(asin)
|
||||
}
|
||||
}
|
||||
module.exports = BookFinder
|
@ -153,6 +153,30 @@ class Book {
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
updateChapters(chapters) {
|
||||
var hasUpdates = this.chapters.length !== chapters.length
|
||||
if (hasUpdates) {
|
||||
this.chapters = chapters.map(ch => ({
|
||||
id: ch.id,
|
||||
start: ch.start,
|
||||
end: ch.end,
|
||||
title: ch.title
|
||||
}))
|
||||
} else {
|
||||
for (let i = 0; i < this.chapters.length; i++) {
|
||||
const currChapter = this.chapters[i]
|
||||
const newChapter = chapters[i]
|
||||
if (!hasUpdates && (currChapter.title !== newChapter.title || currChapter.start !== newChapter.start || currChapter.end !== newChapter.end)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
this.chapters[i].title = newChapter.title
|
||||
this.chapters[i].start = newChapter.start
|
||||
this.chapters[i].end = newChapter.end
|
||||
}
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
updateCover(coverPath) {
|
||||
coverPath = coverPath.replace(/\\/g, '/')
|
||||
if (this.coverPath === coverPath) return false
|
||||
@ -381,20 +405,28 @@ class Book {
|
||||
// If audio file has chapters use chapters
|
||||
if (file.chapters && file.chapters.length) {
|
||||
file.chapters.forEach((chapter) => {
|
||||
if (chapter.start > this.duration) {
|
||||
Logger.warn(`[Book] Invalid chapter start time > duration`)
|
||||
} else {
|
||||
var chapterAlreadyExists = this.chapters.find(ch => ch.start === chapter.start)
|
||||
if (!chapterAlreadyExists) {
|
||||
var chapterDuration = chapter.end - chapter.start
|
||||
if (chapterDuration > 0) {
|
||||
var title = `Chapter ${currChapterId}`
|
||||
if (chapter.title) {
|
||||
title += ` (${chapter.title})`
|
||||
}
|
||||
var endTime = Math.min(this.duration, currStartTime + chapterDuration)
|
||||
this.chapters.push({
|
||||
id: currChapterId++,
|
||||
start: currStartTime,
|
||||
end: currStartTime + chapterDuration,
|
||||
end: endTime,
|
||||
title
|
||||
})
|
||||
currStartTime += chapterDuration
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (file.duration) {
|
||||
// Otherwise just use track has chapter
|
||||
|
@ -45,5 +45,15 @@ class Audnexus {
|
||||
name: author.name
|
||||
}
|
||||
}
|
||||
|
||||
async getChaptersByASIN(asin) {
|
||||
Logger.debug(`[Audnexus] Get chapters for ASIN ${asin}`)
|
||||
return axios.get(`${this.baseUrl}/books/${asin}/chapters`).then((res) => {
|
||||
return res.data
|
||||
}).catch((error) => {
|
||||
Logger.error(`[Audnexus] Chapter ASIN request failed for ${asin}`, error)
|
||||
return null
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = Audnexus
|
@ -92,8 +92,9 @@ class ApiRouter {
|
||||
this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
|
||||
this.router.patch('/items/:id/episodes', LibraryItemController.middleware.bind(this), LibraryItemController.updateEpisodes.bind(this))
|
||||
this.router.delete('/items/:id/episode/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.removeEpisode.bind(this))
|
||||
this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this)) // Root only
|
||||
this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this)) // Root only
|
||||
this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
|
||||
this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this))
|
||||
this.router.post('/items/:id/chapters', LibraryItemController.middleware.bind(this), LibraryItemController.updateMediaChapters.bind(this))
|
||||
|
||||
this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
|
||||
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))
|
||||
@ -204,6 +205,7 @@ class ApiRouter {
|
||||
this.router.get('/search/books', MiscController.findBooks.bind(this))
|
||||
this.router.get('/search/podcast', MiscController.findPodcasts.bind(this))
|
||||
this.router.get('/search/authors', MiscController.findAuthor.bind(this))
|
||||
this.router.get('/search/chapters', MiscController.findChapters.bind(this))
|
||||
this.router.get('/tags', MiscController.getAllTags.bind(this))
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user