From cc1181b301dc48d8da18ba7dc2b3734abeec32f8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 10 May 2022 17:03:41 -0500 Subject: [PATCH] Add:Chapter editor, lookup chapters via audnexus, chapters table on audiobook landing page #435 --- client/components/app/Appbar.vue | 6 - .../components/modals/item/tabs/Chapters.vue | 32 +- client/components/tables/ChaptersTable.vue | 74 +++ client/middleware/routed.js | 30 -- client/nuxt.config.js | 4 +- client/pages/audiobook/_id/chapters.vue | 422 ++++++++++++++++++ client/pages/audiobook/_id/edit.vue | 3 - client/pages/item/_id/index.vue | 5 + client/pages/library/_library/search.vue | 9 - client/store/index.js | 17 - server/controllers/LibraryItemController.js | 32 +- server/controllers/MiscController.js | 9 + server/finders/BookFinder.js | 6 + server/objects/mediaTypes/Book.js | 56 ++- server/providers/Audnexus.js | 10 + server/routers/ApiRouter.js | 6 +- 16 files changed, 613 insertions(+), 108 deletions(-) create mode 100644 client/components/tables/ChaptersTable.vue delete mode 100644 client/middleware/routed.js create mode 100644 client/pages/audiobook/_id/chapters.vue diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index f30a4840..ce891573 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -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', []) diff --git a/client/components/modals/item/tabs/Chapters.vue b/client/components/modals/item/tabs/Chapters.vue index a367d86e..779c3e86 100644 --- a/client/components/modals/item/tabs/Chapters.vue +++ b/client/components/modals/item/tabs/Chapters.vue @@ -1,32 +1,11 @@ @@ -48,6 +27,9 @@ export default { }, chapters() { return this.media.chapters || [] + }, + userCanUpdate() { + return this.$store.getters['user/getUserCanUpdate'] } }, methods: {} diff --git a/client/components/tables/ChaptersTable.vue b/client/components/tables/ChaptersTable.vue new file mode 100644 index 00000000..5be62d04 --- /dev/null +++ b/client/components/tables/ChaptersTable.vue @@ -0,0 +1,74 @@ + + + \ No newline at end of file diff --git a/client/middleware/routed.js b/client/middleware/routed.js deleted file mode 100644 index c5eac0be..00000000 --- a/client/middleware/routed.js +++ /dev/null @@ -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) - } - } - } - } -} diff --git a/client/nuxt.config.js b/client/nuxt.config.js index 84dbc03e..004bb7f0 100644 --- a/client/nuxt.config.js +++ b/client/nuxt.config.js @@ -36,9 +36,7 @@ module.exports = { ] }, - router: { - middleware: ['routed'] - }, + router: {}, // Global CSS: https://go.nuxtjs.dev/config-css css: [ diff --git a/client/pages/audiobook/_id/chapters.vue b/client/pages/audiobook/_id/chapters.vue new file mode 100644 index 00000000..d5351dd5 --- /dev/null +++ b/client/pages/audiobook/_id/chapters.vue @@ -0,0 +1,422 @@ + + + \ No newline at end of file diff --git a/client/pages/audiobook/_id/edit.vue b/client/pages/audiobook/_id/edit.vue index 2a8dc816..d63e4eb8 100644 --- a/client/pages/audiobook/_id/edit.vue +++ b/client/pages/audiobook/_id/edit.vue @@ -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') } diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 93860fb6..473e32d8 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -177,6 +177,8 @@ + + @@ -275,6 +277,9 @@ export default { mediaMetadata() { return this.media.metadata || {} }, + chapters() { + return this.media.chapters || [] + }, tracks() { return this.media.tracks || [] }, diff --git a/client/pages/library/_library/search.vue b/client/pages/library/_library/search.vue index 5ecf1031..9a69a25a 100644 --- a/client/pages/library/_library/search.vue +++ b/client/pages/library/_library/search.vue @@ -7,9 +7,6 @@

No Search results for "{{ query }}"

-
- Back -
@@ -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() {}, diff --git a/client/store/index.js b/client/store/index.js index 70082099..3799e493 100644 --- a/client/store/index.js +++ b/client/store/index.js @@ -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 }, diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index ddf4e1e2..88600323 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -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) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 8bbdafdf..852af27d 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -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') diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index fa0214a6..60645747 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -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 \ No newline at end of file diff --git a/server/objects/mediaTypes/Book.js b/server/objects/mediaTypes/Book.js index 29f552fb..864dd15a 100644 --- a/server/objects/mediaTypes/Book.js +++ b/server/objects/mediaTypes/Book.js @@ -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,19 +405,27 @@ class Book { // If audio file has chapters use chapters if (file.chapters && file.chapters.length) { file.chapters.forEach((chapter) => { - var chapterDuration = chapter.end - chapter.start - if (chapterDuration > 0) { - var title = `Chapter ${currChapterId}` - if (chapter.title) { - title += ` (${chapter.title})` + 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: endTime, + title + }) + currStartTime += chapterDuration + } } - this.chapters.push({ - id: currChapterId++, - start: currStartTime, - end: currStartTime + chapterDuration, - title - }) - currStartTime += chapterDuration } }) } else if (file.duration) { diff --git a/server/providers/Audnexus.js b/server/providers/Audnexus.js index 10cf03b4..23f9ba4f 100644 --- a/server/providers/Audnexus.js +++ b/server/providers/Audnexus.js @@ -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 \ No newline at end of file diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 286039cf..7b0a3451 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -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)) }