mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-01-22 00:07:52 +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() {
|
toggleBookshelfTexture() {
|
||||||
this.$store.dispatch('setBookshelfTexture', 'wood2.png')
|
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() {
|
cancelSelectionMode() {
|
||||||
if (this.processingBatchDelete) return
|
if (this.processingBatchDelete) return
|
||||||
this.$store.commit('setSelectedLibraryItems', [])
|
this.$store.commit('setSelectedLibraryItems', [])
|
||||||
|
@ -1,32 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||||
<div class="w-full mb-4">
|
<div class="w-full mb-4">
|
||||||
<div v-if="chapters.length" class="w-full p-4 bg-primary">
|
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" keep-open />
|
||||||
<p>Audiobook Chapters</p>
|
<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>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -48,6 +27,9 @@ export default {
|
|||||||
},
|
},
|
||||||
chapters() {
|
chapters() {
|
||||||
return this.media.chapters || []
|
return this.media.chapters || []
|
||||||
|
},
|
||||||
|
userCanUpdate() {
|
||||||
|
return this.$store.getters['user/getUserCanUpdate']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {}
|
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: {
|
router: {},
|
||||||
middleware: ['routed']
|
|
||||||
},
|
|
||||||
|
|
||||||
// Global CSS: https://go.nuxtjs.dev/config-css
|
// Global CSS: https://go.nuxtjs.dev/config-css
|
||||||
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
|
draggable
|
||||||
},
|
},
|
||||||
async asyncData({ store, params, app, redirect, route }) {
|
async asyncData({ store, params, app, redirect, route }) {
|
||||||
if (!store.state.user.user) {
|
|
||||||
return redirect(`/login?redirect=${route.path}`)
|
|
||||||
}
|
|
||||||
if (!store.getters['user/getUserCanUpdate']) {
|
if (!store.getters['user/getUserCanUpdate']) {
|
||||||
return redirect('/?error=unauthorized')
|
return redirect('/?error=unauthorized')
|
||||||
}
|
}
|
||||||
|
@ -177,6 +177,8 @@
|
|||||||
|
|
||||||
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" />
|
<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" />
|
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -275,6 +277,9 @@ export default {
|
|||||||
mediaMetadata() {
|
mediaMetadata() {
|
||||||
return this.media.metadata || {}
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
|
chapters() {
|
||||||
|
return this.media.chapters || []
|
||||||
|
},
|
||||||
tracks() {
|
tracks() {
|
||||||
return this.media.tracks || []
|
return this.media.tracks || []
|
||||||
},
|
},
|
||||||
|
@ -7,9 +7,6 @@
|
|||||||
<app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
|
<app-book-shelf-categorized v-if="hasResults" ref="bookshelf" search :results="results" />
|
||||||
<div v-else class="w-full py-16">
|
<div v-else class="w-full py-16">
|
||||||
<p class="text-xl text-center">No Search results for "{{ query }}"</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -79,12 +76,6 @@ export default {
|
|||||||
this.$refs.bookshelf.setShelvesFromSearch()
|
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() {},
|
mounted() {},
|
||||||
|
@ -15,8 +15,6 @@ export const state = () => ({
|
|||||||
selectedLibraryItems: [],
|
selectedLibraryItems: [],
|
||||||
processingBatch: false,
|
processingBatch: false,
|
||||||
previousPath: '/',
|
previousPath: '/',
|
||||||
routeHistory: [],
|
|
||||||
isRoutingBack: false,
|
|
||||||
showExperimentalFeatures: false,
|
showExperimentalFeatures: false,
|
||||||
backups: [],
|
backups: [],
|
||||||
bookshelfBookIds: [],
|
bookshelfBookIds: [],
|
||||||
@ -74,15 +72,6 @@ export const actions = {
|
|||||||
return false
|
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) {
|
setBookshelfTexture({ commit, state }, img) {
|
||||||
let root = document.documentElement;
|
let root = document.documentElement;
|
||||||
commit('setBookshelfTexture', img)
|
commit('setBookshelfTexture', img)
|
||||||
@ -94,12 +83,6 @@ export const mutations = {
|
|||||||
setBookshelfBookIds(state, val) {
|
setBookshelfBookIds(state, val) {
|
||||||
state.bookshelfBookIds = val || []
|
state.bookshelfBookIds = val || []
|
||||||
},
|
},
|
||||||
setRouteHistory(state, val) {
|
|
||||||
state.routeHistory = val
|
|
||||||
},
|
|
||||||
setIsRoutingBack(state, val) {
|
|
||||||
state.isRoutingBack = val
|
|
||||||
},
|
|
||||||
setPreviousPath(state, val) {
|
setPreviousPath(state, val) {
|
||||||
state.previousPath = 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) {
|
async updateAudioFileMetadata(req, res) {
|
||||||
if (!req.user.isAdminOrUp) {
|
if (!req.user.isAdminOrUp) {
|
||||||
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
|
Logger.error(`[LibraryItemController] Non-root user attempted to update audio metadata`, req.user)
|
||||||
@ -375,6 +375,36 @@ class LibraryItemController {
|
|||||||
res.sendStatus(200)
|
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) {
|
middleware(req, res, next) {
|
||||||
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
var item = this.db.libraryItems.find(li => li.id === req.params.id)
|
||||||
if (!item || !item.media) return res.sendStatus(404)
|
if (!item || !item.media) return res.sendStatus(404)
|
||||||
|
@ -225,6 +225,15 @@ class MiscController {
|
|||||||
res.json(author)
|
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) {
|
authorize(req, res) {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
Logger.error('Invalid user in authorize')
|
Logger.error('Invalid user in authorize')
|
||||||
|
@ -3,6 +3,7 @@ const LibGen = require('../providers/LibGen')
|
|||||||
const GoogleBooks = require('../providers/GoogleBooks')
|
const GoogleBooks = require('../providers/GoogleBooks')
|
||||||
const Audible = require('../providers/Audible')
|
const Audible = require('../providers/Audible')
|
||||||
const iTunes = require('../providers/iTunes')
|
const iTunes = require('../providers/iTunes')
|
||||||
|
const Audnexus = require('../providers/Audnexus')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const { levenshteinDistance } = require('../utils/index')
|
const { levenshteinDistance } = require('../utils/index')
|
||||||
|
|
||||||
@ -13,6 +14,7 @@ class BookFinder {
|
|||||||
this.googleBooks = new GoogleBooks()
|
this.googleBooks = new GoogleBooks()
|
||||||
this.audible = new Audible()
|
this.audible = new Audible()
|
||||||
this.iTunesApi = new iTunes()
|
this.iTunesApi = new iTunes()
|
||||||
|
this.audnexus = new Audnexus()
|
||||||
|
|
||||||
this.verbose = false
|
this.verbose = false
|
||||||
}
|
}
|
||||||
@ -226,5 +228,9 @@ class BookFinder {
|
|||||||
})
|
})
|
||||||
return covers
|
return covers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findChapters(asin) {
|
||||||
|
return this.audnexus.getChaptersByASIN(asin)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = BookFinder
|
module.exports = BookFinder
|
@ -153,6 +153,30 @@ class Book {
|
|||||||
return hasUpdates
|
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) {
|
updateCover(coverPath) {
|
||||||
coverPath = coverPath.replace(/\\/g, '/')
|
coverPath = coverPath.replace(/\\/g, '/')
|
||||||
if (this.coverPath === coverPath) return false
|
if (this.coverPath === coverPath) return false
|
||||||
@ -381,19 +405,27 @@ class Book {
|
|||||||
// If audio file has chapters use chapters
|
// If audio file has chapters use chapters
|
||||||
if (file.chapters && file.chapters.length) {
|
if (file.chapters && file.chapters.length) {
|
||||||
file.chapters.forEach((chapter) => {
|
file.chapters.forEach((chapter) => {
|
||||||
var chapterDuration = chapter.end - chapter.start
|
if (chapter.start > this.duration) {
|
||||||
if (chapterDuration > 0) {
|
Logger.warn(`[Book] Invalid chapter start time > duration`)
|
||||||
var title = `Chapter ${currChapterId}`
|
} else {
|
||||||
if (chapter.title) {
|
var chapterAlreadyExists = this.chapters.find(ch => ch.start === chapter.start)
|
||||||
title += ` (${chapter.title})`
|
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: endTime,
|
||||||
|
title
|
||||||
|
})
|
||||||
|
currStartTime += chapterDuration
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.chapters.push({
|
|
||||||
id: currChapterId++,
|
|
||||||
start: currStartTime,
|
|
||||||
end: currStartTime + chapterDuration,
|
|
||||||
title
|
|
||||||
})
|
|
||||||
currStartTime += chapterDuration
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else if (file.duration) {
|
} else if (file.duration) {
|
||||||
|
@ -45,5 +45,15 @@ class Audnexus {
|
|||||||
name: author.name
|
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
|
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/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.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.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/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this))
|
||||||
this.router.get('/items/:id/audio-metadata', LibraryItemController.middleware.bind(this), LibraryItemController.updateAudioFileMetadata.bind(this)) // Root only
|
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/delete', LibraryItemController.batchDelete.bind(this))
|
||||||
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.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/books', MiscController.findBooks.bind(this))
|
||||||
this.router.get('/search/podcast', MiscController.findPodcasts.bind(this))
|
this.router.get('/search/podcast', MiscController.findPodcasts.bind(this))
|
||||||
this.router.get('/search/authors', MiscController.findAuthor.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))
|
this.router.get('/tags', MiscController.getAllTags.bind(this))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user