Add:Search for narrators #1495

This commit is contained in:
advplyr 2023-04-24 18:25:30 -05:00
parent 33f20d54cc
commit a5627a1b52
11 changed files with 271 additions and 33 deletions

View File

@ -28,6 +28,9 @@
<widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6"> <widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p> <p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
</widgets-authors-slider> </widgets-authors-slider>
<widgets-narrators-slider v-else-if="shelf.type === 'narrators'" :key="index + '.'" :items="shelf.entities" :height="100 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
</widgets-narrators-slider>
</template> </template>
</div> </div>
<!-- Regular bookshelf view --> <!-- Regular bookshelf view -->
@ -185,8 +188,8 @@ export default {
this.shelves = categories this.shelves = categories
}, },
async setShelvesFromSearch() { async setShelvesFromSearch() {
var shelves = [] const shelves = []
if (this.results.books && this.results.books.length) { if (this.results.books?.length) {
shelves.push({ shelves.push({
id: 'books', id: 'books',
label: 'Books', label: 'Books',
@ -196,7 +199,7 @@ export default {
}) })
} }
if (this.results.podcasts && this.results.podcasts.length) { if (this.results.podcasts?.length) {
shelves.push({ shelves.push({
id: 'podcasts', id: 'podcasts',
label: 'Podcasts', label: 'Podcasts',
@ -206,7 +209,7 @@ export default {
}) })
} }
if (this.results.series && this.results.series.length) { if (this.results.series?.length) {
shelves.push({ shelves.push({
id: 'series', id: 'series',
label: 'Series', label: 'Series',
@ -221,7 +224,7 @@ export default {
}) })
}) })
} }
if (this.results.tags && this.results.tags.length) { if (this.results.tags?.length) {
shelves.push({ shelves.push({
id: 'tags', id: 'tags',
label: 'Tags', label: 'Tags',
@ -236,7 +239,7 @@ export default {
}) })
}) })
} }
if (this.results.authors && this.results.authors.length) { if (this.results.authors?.length) {
shelves.push({ shelves.push({
id: 'authors', id: 'authors',
label: 'Authors', label: 'Authors',
@ -250,6 +253,20 @@ export default {
}) })
}) })
} }
if (this.results.narrators?.length) {
shelves.push({
id: 'narrators',
label: 'Narrators',
labelStringKey: 'LabelNarrators',
type: 'narrators',
entities: this.results.narrators.map((n) => {
return {
...n,
type: 'narrator'
}
})
})
}
this.shelves = shelves this.shelves = shelves
}, },
scan() { scan() {

View File

@ -41,6 +41,11 @@
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" /> <cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
</template> </template>
</div> </div>
<div v-if="shelf.type === 'narrators'" class="flex items-center">
<template v-for="entity in shelf.entities">
<cards-narrator-card :key="entity.name" :width="150" :height="100" :narrator="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" />
</template>
</div>
</div> </div>
</div> </div>
@ -88,6 +93,7 @@ export default {
return this.bookCoverWidth * this.bookCoverAspectRatio return this.bookCoverWidth * this.bookCoverAspectRatio
}, },
shelfHeight() { shelfHeight() {
if (this.shelf.type === 'narrators') return 148
return this.bookCoverHeight + 48 return this.bookCoverHeight + 48
}, },
paddingLeft() { paddingLeft() {

View File

@ -10,7 +10,7 @@
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p> <p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" /> <p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" /> <div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
</div> </div>
</div> </div>
</template> </template>
@ -67,12 +67,13 @@ export default {
// but with removing commas periods etc this is no longer plausible // but with removing commas periods etc this is no longer plausible
const html = this.matchText const html = this.matchText
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>` if (this.matchKey === 'episode') return `<p class="truncate">${this.$strings.LabelEpisode}: ${html}</p>`
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>` if (this.matchKey === 'tags') return `<p class="truncate">${this.$strings.LabelTags}: ${html}</p>`
if (this.matchKey === 'authors') return `by ${html}` if (this.matchKey === 'authors') return `by ${html}`
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>` if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>` if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>` if (this.matchKey === 'series') return `<p class="truncate">${this.$strings.LabelSeries}: ${html}</p>`
if (this.matchKey === 'narrators') return `<p class="truncate">${this.$strings.LabelNarrator}: ${html}</p>`
return `${html}` return `${html}`
} }
}, },

View File

@ -0,0 +1,50 @@
<template>
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
<div class="absolute inset-0 w-full h-full flex items-center justify-center pointer-events-none opacity-20">
<span class="material-icons-outlined text-8xl">record_voice_over</span>
</div>
<!-- Narrator name & num books overlay -->
<div class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
</div>
</div>
</nuxt-link>
</template>
<script>
export default {
props: {
narrator: {
type: Object,
default: () => {}
},
width: Number,
height: Number,
sizeMultiplier: {
type: Number,
default: 1
}
},
data() {
return {}
},
computed: {
name() {
return this.narrator?.name || ''
},
numBooks() {
return this.narrator?.books?.length || 0
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
}
},
methods: {}
}
</script>

View File

@ -0,0 +1,34 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<div class="w-10 h-10 flex items-center justify-center">
<span class="material-icons text-2xl text-gray-200">record_voice_over</span>
</div>
<div class="flex-grow px-2 narratorSearchCardContent h-full">
<p class="truncate text-sm">{{ narrator }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
narrator: String
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style scoped>
.narratorSearchCardContent {
width: calc(100% - 40px);
height: 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View File

@ -63,6 +63,15 @@
</nuxt-link> </nuxt-link>
</li> </li>
</template> </template>
<p v-if="narratorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelNarrators }}</p>
<template v-for="narrator in narratorResults">
<li :key="narrator.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
<cards-narrator-search-card :narrator="narrator.name" />
</nuxt-link>
</li>
</template>
</template> </template>
</ul> </ul>
</div> </div>
@ -84,6 +93,7 @@ export default {
authorResults: [], authorResults: [],
seriesResults: [], seriesResults: [],
tagResults: [], tagResults: [],
narratorResults: [],
searchTimeout: null, searchTimeout: null,
lastSearch: null lastSearch: null
} }
@ -114,6 +124,7 @@ export default {
this.authorResults = [] this.authorResults = []
this.seriesResults = [] this.seriesResults = []
this.tagResults = [] this.tagResults = []
this.narratorResults = []
this.showMenu = false this.showMenu = false
this.isFetching = false this.isFetching = false
this.isTyping = false this.isTyping = false
@ -142,7 +153,7 @@ export default {
} }
this.isFetching = true this.isFetching = true
var searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => { const searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
console.error('Search error', error) console.error('Search error', error)
return [] return []
}) })
@ -155,6 +166,7 @@ export default {
this.authorResults = searchResults.authors || [] this.authorResults = searchResults.authors || []
this.seriesResults = searchResults.series || [] this.seriesResults = searchResults.series || []
this.tagResults = searchResults.tags || [] this.tagResults = searchResults.tags || []
this.narratorResults = searchResults.narrators || []
this.isFetching = false this.isFetching = false
if (!this.showMenu) { if (!this.showMenu) {

View File

@ -0,0 +1,100 @@
<template>
<div class="w-full">
<div class="flex items-center py-3">
<slot />
<div class="flex-grow" />
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollLeft ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollLeft">
<span class="material-icons text-2xl">chevron_left</span>
</button>
<button v-if="isScrollable" class="w-8 h-8 mx-1 flex items-center justify-center rounded-full" :class="canScrollRight ? 'hover:bg-white hover:bg-opacity-5 text-gray-300 hover:text-white' : 'text-white text-opacity-40 cursor-text'" @click="scrollRight">
<span class="material-icons text-2xl">chevron_right</span>
</button>
</div>
<div ref="slider" class="w-full overflow-y-hidden overflow-x-auto no-scroll -mx-2" style="scroll-behavior: smooth" @scroll="scrolled">
<div class="flex" :style="{ height: height + 'px' }">
<template v-for="item in items">
<cards-narrator-card :key="item.name" :ref="`slider-item-${item.name}`" :narrator="item" :height="cardHeight" :width="cardWidth" class="relative mx-2" @hook:updated="setScrollVars" />
</template>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
items: {
type: Array,
default: () => []
},
height: {
type: Number,
default: 192
},
bookshelfView: {
type: Number,
default: 1
}
},
data() {
return {
isScrollable: false,
canScrollLeft: false,
canScrollRight: false,
clientWidth: 0
}
},
computed: {
cardHeight() {
return this.height
},
cardWidth() {
return this.cardHeight * 1.5
},
booksPerPage() {
return Math.floor(this.clientWidth / (this.cardWidth + 16))
}
},
methods: {
scrolled() {
this.setScrollVars()
},
scrollRight() {
if (!this.canScrollRight) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const maxScrollLeft = slider.scrollWidth - slider.clientWidth
const newScrollLeft = Math.min(maxScrollLeft, slider.scrollLeft + scrollAmount)
slider.scrollLeft = newScrollLeft
},
scrollLeft() {
if (!this.canScrollLeft) return
const slider = this.$refs.slider
if (!slider) return
const scrollAmount = this.booksPerPage * this.cardWidth
const newScrollLeft = Math.max(0, slider.scrollLeft - scrollAmount)
slider.scrollLeft = newScrollLeft
},
setScrollVars() {
const slider = this.$refs.slider
if (!slider) return
const { scrollLeft, scrollWidth, clientWidth } = slider
const scrollPercent = (scrollLeft + clientWidth) / scrollWidth
this.clientWidth = clientWidth
this.isScrollable = scrollWidth > clientWidth
this.canScrollRight = scrollPercent < 1
this.canScrollLeft = scrollLeft > 0
}
},
updated() {
this.setScrollVars()
},
mounted() {},
beforeDestroy() {}
}
</script>

View File

@ -11,27 +11,27 @@
<script> <script>
export default { export default {
async asyncData({ store, params, redirect, query, app }) { async asyncData({ store, params, redirect, query, app }) {
var libraryId = params.library const libraryId = params.library
var library = await store.dispatch('libraries/fetch', libraryId) const library = await store.dispatch('libraries/fetch', libraryId)
if (!library) { if (!library) {
return redirect('/oops?message=Library not found') return redirect('/oops?message=Library not found')
} }
var query = query.q let results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${query.q}`).catch((error) => {
var results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${query}`).catch((error) => {
console.error('Failed to search library', error) console.error('Failed to search library', error)
return null return null
}) })
results = { results = {
podcasts: results && results.podcast ? results.podcast : null, podcasts: results?.podcast || [],
books: results && results.book ? results.book : null, books: results?.book || [],
authors: results && results.authors.length ? results.authors : null, authors: results?.authors || [],
series: results && results.series.length ? results.series : null, series: results?.series || [],
tags: results && results.tags.length ? results.tags : null tags: results?.tags || [],
narrators: results?.narrators || []
} }
return { return {
libraryId, libraryId,
results, results,
query query: query.q
} }
}, },
data() { data() {
@ -55,16 +55,17 @@ export default {
}, },
methods: { methods: {
async search() { async search() {
var results = await this.$axios.$get(`/api/libraries/${this.libraryId}/search?q=${this.query}`).catch((error) => { const results = await this.$axios.$get(`/api/libraries/${this.libraryId}/search?q=${this.query}`).catch((error) => {
console.error('Failed to search library', error) console.error('Failed to search library', error)
return null return null
}) })
this.results = { this.results = {
podcasts: results && results.podcast ? results.podcast : null, podcasts: results?.podcast || [],
books: results && results.book ? results.book : null, books: results?.book || [],
authors: results && results.authors.length ? results.authors : null, authors: results?.authors || [],
series: results && results.series.length ? results.series : null, series: results?.series || [],
tags: results && results.tags.length ? results.tags : null tags: results?.tags || [],
narrators: results?.narrators || []
} }
this.$nextTick(() => { this.$nextTick(() => {
if (this.$refs.bookshelf) { if (this.$refs.bookshelf) {

View File

@ -596,6 +596,7 @@ class LibraryController {
const itemMatches = [] const itemMatches = []
const authorMatches = {} const authorMatches = {}
const narratorMatches = {}
const seriesMatches = {} const seriesMatches = {}
const tagMatches = {} const tagMatches = {}
@ -608,7 +609,7 @@ class LibraryController {
matchText: queryResult.matchText matchText: queryResult.matchText
}) })
} }
if (queryResult.series && queryResult.series.length) { if (queryResult.series?.length) {
queryResult.series.forEach((se) => { queryResult.series.forEach((se) => {
if (!seriesMatches[se.id]) { if (!seriesMatches[se.id]) {
const _series = this.db.series.find(_se => _se.id === se.id) const _series = this.db.series.find(_se => _se.id === se.id)
@ -618,7 +619,7 @@ class LibraryController {
} }
}) })
} }
if (queryResult.authors && queryResult.authors.length) { if (queryResult.authors?.length) {
queryResult.authors.forEach((au) => { queryResult.authors.forEach((au) => {
if (!authorMatches[au.id]) { if (!authorMatches[au.id]) {
const _author = this.db.authors.find(_au => _au.id === au.id) const _author = this.db.authors.find(_au => _au.id === au.id)
@ -631,7 +632,7 @@ class LibraryController {
} }
}) })
} }
if (queryResult.tags && queryResult.tags.length) { if (queryResult.tags?.length) {
queryResult.tags.forEach((tag) => { queryResult.tags.forEach((tag) => {
if (!tagMatches[tag]) { if (!tagMatches[tag]) {
tagMatches[tag] = { name: tag, books: [li.toJSON()] } tagMatches[tag] = { name: tag, books: [li.toJSON()] }
@ -640,13 +641,23 @@ class LibraryController {
} }
}) })
} }
if (queryResult.narrators?.length) {
queryResult.narrators.forEach((narrator) => {
if (!narratorMatches[narrator]) {
narratorMatches[narrator] = { name: narrator, books: [li.toJSON()] }
} else {
narratorMatches[narrator].books.push(li.toJSON())
}
})
}
}) })
const itemKey = req.library.mediaType const itemKey = req.library.mediaType
const results = { const results = {
[itemKey]: itemMatches.slice(0, maxResults), [itemKey]: itemMatches.slice(0, maxResults),
tags: Object.values(tagMatches).slice(0, maxResults), tags: Object.values(tagMatches).slice(0, maxResults),
authors: Object.values(authorMatches).slice(0, maxResults), authors: Object.values(authorMatches).slice(0, maxResults),
series: Object.values(seriesMatches).slice(0, maxResults) series: Object.values(seriesMatches).slice(0, maxResults),
narrators: Object.values(narratorMatches).slice(0, maxResults)
} }
res.json(results) res.json(results)
} }

View File

@ -322,6 +322,7 @@ class Book {
tags: this.tags.filter(t => cleanStringForSearch(t).includes(query)), tags: this.tags.filter(t => cleanStringForSearch(t).includes(query)),
series: this.metadata.searchSeries(query), series: this.metadata.searchSeries(query),
authors: this.metadata.searchAuthors(query), authors: this.metadata.searchAuthors(query),
narrators: this.metadata.searchNarrators(query),
matchKey: null, matchKey: null,
matchText: null matchText: null
} }
@ -336,10 +337,12 @@ class Book {
} else if (payload.series.length) { } else if (payload.series.length) {
payload.matchKey = 'series' payload.matchKey = 'series'
payload.matchText = this.metadata.seriesName payload.matchText = this.metadata.seriesName
} } else if (payload.tags.length) {
else if (payload.tags.length) {
payload.matchKey = 'tags' payload.matchKey = 'tags'
payload.matchText = this.tags.join(', ') payload.matchText = this.tags.join(', ')
} else if (payload.narrators.length) {
payload.matchKey = 'narrators'
payload.matchText = this.metadata.narratorName
} }
} }
return payload return payload

View File

@ -381,6 +381,9 @@ class BookMetadata {
searchAuthors(query) { searchAuthors(query) {
return this.authors.filter(au => cleanStringForSearch(au.name).includes(query)) return this.authors.filter(au => cleanStringForSearch(au.name).includes(query))
} }
searchNarrators(query) {
return this.narrators.filter(n => cleanStringForSearch(n).includes(query))
}
searchQuery(query) { // Returns key if match is found searchQuery(query) { // Returns key if match is found
const keysToCheck = ['title', 'asin', 'isbn'] const keysToCheck = ['title', 'asin', 'isbn']
for (const key of keysToCheck) { for (const key of keysToCheck) {