mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01:00
Add:Search for narrators #1495
This commit is contained in:
parent
33f20d54cc
commit
a5627a1b52
@ -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">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
</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>
|
||||
</div>
|
||||
<!-- Regular bookshelf view -->
|
||||
@ -185,8 +188,8 @@ export default {
|
||||
this.shelves = categories
|
||||
},
|
||||
async setShelvesFromSearch() {
|
||||
var shelves = []
|
||||
if (this.results.books && this.results.books.length) {
|
||||
const shelves = []
|
||||
if (this.results.books?.length) {
|
||||
shelves.push({
|
||||
id: 'books',
|
||||
label: 'Books',
|
||||
@ -196,7 +199,7 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
if (this.results.podcasts && this.results.podcasts.length) {
|
||||
if (this.results.podcasts?.length) {
|
||||
shelves.push({
|
||||
id: 'podcasts',
|
||||
label: 'Podcasts',
|
||||
@ -206,7 +209,7 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
if (this.results.series && this.results.series.length) {
|
||||
if (this.results.series?.length) {
|
||||
shelves.push({
|
||||
id: 'series',
|
||||
label: 'Series',
|
||||
@ -221,7 +224,7 @@ export default {
|
||||
})
|
||||
})
|
||||
}
|
||||
if (this.results.tags && this.results.tags.length) {
|
||||
if (this.results.tags?.length) {
|
||||
shelves.push({
|
||||
id: 'tags',
|
||||
label: 'Tags',
|
||||
@ -236,7 +239,7 @@ export default {
|
||||
})
|
||||
})
|
||||
}
|
||||
if (this.results.authors && this.results.authors.length) {
|
||||
if (this.results.authors?.length) {
|
||||
shelves.push({
|
||||
id: '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
|
||||
},
|
||||
scan() {
|
||||
|
@ -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" />
|
||||
</template>
|
||||
</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>
|
||||
|
||||
@ -88,6 +93,7 @@ export default {
|
||||
return this.bookCoverWidth * this.bookCoverAspectRatio
|
||||
},
|
||||
shelfHeight() {
|
||||
if (this.shelf.type === 'narrators') return 148
|
||||
return this.bookCoverHeight + 48
|
||||
},
|
||||
paddingLeft() {
|
||||
|
@ -10,7 +10,7 @@
|
||||
<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" />
|
||||
|
||||
<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>
|
||||
</template>
|
||||
@ -67,12 +67,13 @@ export default {
|
||||
// but with removing commas periods etc this is no longer plausible
|
||||
const html = this.matchText
|
||||
|
||||
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||
if (this.matchKey === 'episode') return `<p class="truncate">${this.$strings.LabelEpisode}: ${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 === 'isbn') return `<p class="truncate">ISBN: ${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}`
|
||||
}
|
||||
},
|
||||
|
50
client/components/cards/NarratorCard.vue
Normal file
50
client/components/cards/NarratorCard.vue
Normal 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>
|
34
client/components/cards/NarratorSearchCard.vue
Normal file
34
client/components/cards/NarratorSearchCard.vue
Normal 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>
|
@ -63,6 +63,15 @@
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</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>
|
||||
</ul>
|
||||
</div>
|
||||
@ -84,6 +93,7 @@ export default {
|
||||
authorResults: [],
|
||||
seriesResults: [],
|
||||
tagResults: [],
|
||||
narratorResults: [],
|
||||
searchTimeout: null,
|
||||
lastSearch: null
|
||||
}
|
||||
@ -114,6 +124,7 @@ export default {
|
||||
this.authorResults = []
|
||||
this.seriesResults = []
|
||||
this.tagResults = []
|
||||
this.narratorResults = []
|
||||
this.showMenu = false
|
||||
this.isFetching = false
|
||||
this.isTyping = false
|
||||
@ -142,7 +153,7 @@ export default {
|
||||
}
|
||||
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)
|
||||
return []
|
||||
})
|
||||
@ -155,6 +166,7 @@ export default {
|
||||
this.authorResults = searchResults.authors || []
|
||||
this.seriesResults = searchResults.series || []
|
||||
this.tagResults = searchResults.tags || []
|
||||
this.narratorResults = searchResults.narrators || []
|
||||
|
||||
this.isFetching = false
|
||||
if (!this.showMenu) {
|
||||
|
100
client/components/widgets/NarratorsSlider.vue
Normal file
100
client/components/widgets/NarratorsSlider.vue
Normal 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>
|
@ -11,27 +11,27 @@
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, params, redirect, query, app }) {
|
||||
var libraryId = params.library
|
||||
var library = await store.dispatch('libraries/fetch', libraryId)
|
||||
const libraryId = params.library
|
||||
const library = await store.dispatch('libraries/fetch', libraryId)
|
||||
if (!library) {
|
||||
return redirect('/oops?message=Library not found')
|
||||
}
|
||||
var query = query.q
|
||||
var results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${query}`).catch((error) => {
|
||||
let results = await app.$axios.$get(`/api/libraries/${libraryId}/search?q=${query.q}`).catch((error) => {
|
||||
console.error('Failed to search library', error)
|
||||
return null
|
||||
})
|
||||
results = {
|
||||
podcasts: results && results.podcast ? results.podcast : null,
|
||||
books: results && results.book ? results.book : null,
|
||||
authors: results && results.authors.length ? results.authors : null,
|
||||
series: results && results.series.length ? results.series : null,
|
||||
tags: results && results.tags.length ? results.tags : null
|
||||
podcasts: results?.podcast || [],
|
||||
books: results?.book || [],
|
||||
authors: results?.authors || [],
|
||||
series: results?.series || [],
|
||||
tags: results?.tags || [],
|
||||
narrators: results?.narrators || []
|
||||
}
|
||||
return {
|
||||
libraryId,
|
||||
results,
|
||||
query
|
||||
query: query.q
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@ -55,16 +55,17 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
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)
|
||||
return null
|
||||
})
|
||||
this.results = {
|
||||
podcasts: results && results.podcast ? results.podcast : null,
|
||||
books: results && results.book ? results.book : null,
|
||||
authors: results && results.authors.length ? results.authors : null,
|
||||
series: results && results.series.length ? results.series : null,
|
||||
tags: results && results.tags.length ? results.tags : null
|
||||
podcasts: results?.podcast || [],
|
||||
books: results?.book || [],
|
||||
authors: results?.authors || [],
|
||||
series: results?.series || [],
|
||||
tags: results?.tags || [],
|
||||
narrators: results?.narrators || []
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.bookshelf) {
|
||||
|
@ -596,6 +596,7 @@ class LibraryController {
|
||||
|
||||
const itemMatches = []
|
||||
const authorMatches = {}
|
||||
const narratorMatches = {}
|
||||
const seriesMatches = {}
|
||||
const tagMatches = {}
|
||||
|
||||
@ -608,7 +609,7 @@ class LibraryController {
|
||||
matchText: queryResult.matchText
|
||||
})
|
||||
}
|
||||
if (queryResult.series && queryResult.series.length) {
|
||||
if (queryResult.series?.length) {
|
||||
queryResult.series.forEach((se) => {
|
||||
if (!seriesMatches[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) => {
|
||||
if (!authorMatches[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) => {
|
||||
if (!tagMatches[tag]) {
|
||||
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 results = {
|
||||
[itemKey]: itemMatches.slice(0, maxResults),
|
||||
tags: Object.values(tagMatches).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)
|
||||
}
|
||||
|
@ -322,6 +322,7 @@ class Book {
|
||||
tags: this.tags.filter(t => cleanStringForSearch(t).includes(query)),
|
||||
series: this.metadata.searchSeries(query),
|
||||
authors: this.metadata.searchAuthors(query),
|
||||
narrators: this.metadata.searchNarrators(query),
|
||||
matchKey: null,
|
||||
matchText: null
|
||||
}
|
||||
@ -336,10 +337,12 @@ class Book {
|
||||
} else if (payload.series.length) {
|
||||
payload.matchKey = 'series'
|
||||
payload.matchText = this.metadata.seriesName
|
||||
}
|
||||
else if (payload.tags.length) {
|
||||
} else if (payload.tags.length) {
|
||||
payload.matchKey = 'tags'
|
||||
payload.matchText = this.tags.join(', ')
|
||||
} else if (payload.narrators.length) {
|
||||
payload.matchKey = 'narrators'
|
||||
payload.matchText = this.metadata.narratorName
|
||||
}
|
||||
}
|
||||
return payload
|
||||
|
@ -381,6 +381,9 @@ class BookMetadata {
|
||||
searchAuthors(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
|
||||
const keysToCheck = ['title', 'asin', 'isbn']
|
||||
for (const key of keysToCheck) {
|
||||
|
Loading…
Reference in New Issue
Block a user