Update global search, fix toggling between automated backup, add open search cover in new tab #83

This commit is contained in:
advplyr 2021-10-09 11:09:06 -05:00
parent 59d12ef5de
commit 32bc9d5282
16 changed files with 254 additions and 52 deletions

View File

@ -18,7 +18,7 @@
<p class="text-sm text-white text-opacity-70 leading-3 font-book pl-2">{{ libraryName }}</p>
</div> -->
<!-- </div> -->
<div class="bg-black bg-opacity-20 rounded-sm py-1.5 px-3 flex items-center border border-bg text-white text-opacity-70 cursor-pointer hover:bg-opacity-10 hover:text-opacity-90" @click="clickLibrary">
<div class="bg-black bg-opacity-20 rounded-md py-1.5 px-3 flex items-center text-white text-opacity-70 cursor-pointer hover:bg-opacity-10 hover:text-opacity-90" @click="clickLibrary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>

View File

@ -77,6 +77,11 @@ export default {
this.$store.commit('audiobooks/setSearchResults', this.searchResults)
this.setBookshelfEntities()
})
},
'$route.query.filter'() {
if (this.$route.query.filter && this.$route.query.filter !== this.filterBy) {
this.$store.dispatch('user/updateUserSettings', { filterBy: this.$route.query.filter })
}
}
},
computed: {
@ -171,6 +176,7 @@ export default {
this.currSearchParams = this.buildSearchParams()
var entities = this.entities
var groups = []
var currentRow = 0
var currentGroup = []

View File

@ -4,13 +4,16 @@
<template v-if="page !== 'search' && !isHome">
<p v-if="!selectedSeries" class="font-book">{{ numShowing }} {{ entityName }}</p>
<div v-else class="flex items-center">
<div @click="seriesBackArrow" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
<span class="material-icons text-3xl text-white">west</span>
<div @click="seriesBackArrow" class="rounded-full h-9 w-9 flex items-center justify-center hover:bg-white hover:bg-opacity-10 cursor-pointer">
<span class="material-icons text-2xl text-white">west</span>
</div>
<!-- <span class="material-icons text-2xl cursor-pointer" @click="seriesBackArrow">west</span> -->
<p class="pl-4 font-book text-lg">
{{ selectedSeries }} <span class="ml-3 font-mono text-lg bg-black bg-opacity-30 rounded-lg px-1 py-0.5">{{ numShowing }}</span>
{{ selectedSeries }}
</p>
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
<span class="font-mono">{{ numShowing }}</span>
</div>
</div>
<div class="flex-grow" />

View File

@ -1,6 +1,6 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<cards-book-cover :audiobook="audiobook" :width="40" />
<cards-book-cover :audiobook="audiobook" :width="50" />
<div class="flex-grow px-2 searchCardContent h-full">
<p class="truncate text-sm">{{ title }}</p>
<p class="text-xs text-gray-200 truncate">by {{ author }}</p>
@ -38,7 +38,7 @@ export default {
<style>
.searchCardContent {
width: calc(100% - 80px);
height: calc(40px * 1.5);
height: calc(50px * 1.5);
display: flex;
flex-direction: column;
justify-content: center;

View File

@ -0,0 +1,32 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<img src="https://rpgplanner.com/wp-content/uploads/2020/06/no-photo-available.png" class="w-40 h-40 max-h-40 object-contain" style="max-height: 40px; max-width: 40px" />
<div class="flex-grow px-2 authorSearchCardContent h-full">
<p class="truncate text-sm">{{ author }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
author: String
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style>
.searchCardContent {
width: calc(100% - 80px);
height: 40px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View File

@ -1,10 +1,14 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<div class="w-full h-full relative">
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
<div class="w-full h-full z-0" ref="coverBg" />
</div>
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
<a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }">
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
</a>
</div>
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
@ -23,12 +27,16 @@ export default {
width: {
type: Number,
default: 120
}
},
showOpenNewTab: Boolean
},
data() {
return {
imageFailed: false,
showCoverBg: false
showCoverBg: false,
isHovering: false,
naturalHeight: 0,
naturalWidth: 0
}
},
watch: {
@ -60,6 +68,9 @@ export default {
imageLoaded() {
if (this.$refs.cover) {
var { naturalWidth, naturalHeight } = this.$refs.cover
this.naturalHeight = naturalHeight
this.naturalWidth = naturalWidth
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - 1.6)

View File

@ -0,0 +1,36 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<cards-group-cover :name="series" :book-items="bookItems" :width="60" :height="60" />
<div class="flex-grow px-2 seriesSearchCardContent h-full">
<p class="truncate text-sm">{{ series }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
series: String,
bookItems: {
type: Array,
default: () => []
}
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style>
.seriesSearchCardContent {
width: calc(100% - 80px);
height: 60px;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<div class="w-64 ml-8 relative">
<div class="w-80 ml-6 relative">
<form @submit.prevent="submitSearch">
<ui-text-input ref="input" v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
</form>
@ -7,23 +7,42 @@
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
</div>
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<li v-if="isTyping" class="py-2 px-2">
<p>Typing...</p>
<p>Thinking...</p>
</li>
<li v-else-if="isFetching" class="py-2 px-2">
<p>Fetching...</p>
</li>
<li v-else-if="!items.length" class="py-2 px-2">
<li v-else-if="!totalResults" class="py-2 px-2">
<p>No Results</p>
</li>
<template v-else>
<template v-for="item in items">
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickedOption(item)">
<template v-if="item.type === 'audiobook'">
<cards-audiobook-search-card :audiobook="item.data" />
</template>
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">Books</p>
<template v-for="item in audiobookResults">
<li :key="item.audiobook.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/audiobook/${item.audiobook.id}`">
<cards-audiobook-search-card :audiobook="item.audiobook" />
</nuxt-link>
</li>
</template>
<p v-if="authorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Authors</p>
<template v-for="item in authorResults">
<li :key="item.author" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(item.author)}`">
<cards-author-search-card :author="item.author" />
</nuxt-link>
</li>
</template>
<p v-if="seriesResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">Series</p>
<template v-for="item in seriesResults">
<li :key="item.series" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickedOption(item.series)">
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf/series?series=${$encode(item.series)}`">
<cards-series-search-card :series="item.series" :book-items="item.audiobooks" />
</nuxt-link>
</li>
</template>
</template>
@ -42,7 +61,9 @@ export default {
isTyping: false,
isFetching: false,
search: null,
items: [],
audiobookResults: [],
authorResults: [],
seriesResults: [],
searchTimeout: null,
lastSearch: null
}
@ -53,6 +74,9 @@ export default {
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
totalResults() {
return this.audiobookResults.length + this.seriesResults.length + this.authorResults.length
}
},
methods: {
@ -61,7 +85,9 @@ export default {
this.$router.push(`/library/${this.currentLibraryId}/bookshelf/search?query=${this.search}`)
this.search = null
this.items = []
this.audiobookResults = []
this.authorResults = []
this.seriesResults = []
this.showMenu = false
this.$nextTick(() => {
if (this.$refs.input) {
@ -86,22 +112,19 @@ export default {
return
}
this.isFetching = true
var results = await this.$axios.$get(`/api/audiobooks?q=${value}`).catch((error) => {
var searchResults = await this.$axios.$get(`/api/library/${this.currentLibraryId}/search?q=${value}`).catch((error) => {
console.error('Search error', error)
return []
})
this.audiobookResults = searchResults.audiobooks || []
this.authorResults = searchResults.authors || []
this.seriesResults = searchResults.series || []
this.isFetching = false
if (!this.showMenu) {
return
}
this.items = results.map((res) => {
return {
id: res.id,
data: res,
type: 'audiobook'
}
})
},
inputUpdate(val) {
clearTimeout(this.searchTimeout)
@ -114,21 +137,25 @@ export default {
this.searchTimeout = setTimeout(() => {
this.isTyping = false
this.runSearch(val)
}, 1000)
},
clickedOption(option) {
if (option.type === 'audiobook') {
this.$router.push(`/audiobook/${option.data.id}`)
}
}, 750)
},
clickClear() {
if (this.search) {
this.search = null
this.items = []
this.lastSearch = null
this.audiobookResults = []
this.authorResults = []
this.seriesResults = []
this.showMenu = false
}
}
},
mounted() {}
}
</script>
</script>
<style>
.globalSearchMenu {
max-height: 80vh;
}
</style>

View File

@ -53,14 +53,11 @@
</div>
</div>
</form>
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-60 overflow-y-scroll mt-2 max-w-full">
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-80 overflow-y-scroll mt-2 max-w-full">
<p v-if="!coversFound.length">No Covers Found</p>
<template v-for="cover in coversFound">
<div :key="cover" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
<div class="h-24 bg-primary" style="width: 60px">
<img :src="cover" class="h-full w-full object-contain" />
</div>
<!-- <img :src="cover" class="h-24 object-cover" style="width: 60px" /> -->
<cards-preview-cover :src="cover" :width="80" show-open-new-tab />
</div>
</template>
</div>

View File

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "1.4.2",
"version": "1.4.3",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {

View File

@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "1.4.2",
"version": "1.4.3",
"description": "Self-hosted audiobook server for managing and playing audiobooks",
"main": "index.js",
"scripts": {

View File

@ -30,6 +30,7 @@ class ApiController {
this.router.get('/find/:method', this.find.bind(this))
this.router.get('/libraries', this.getLibraries.bind(this))
this.router.get('/library/:id/search', this.searchLibrary.bind(this))
this.router.get('/library/:id', this.getLibrary.bind(this))
this.router.delete('/library/:id', this.deleteLibrary.bind(this))
this.router.patch('/library/:id', this.updateLibrary.bind(this))
@ -97,6 +98,53 @@ class ApiController {
res.json(libraries)
}
searchLibrary(req, res) {
var library = this.db.libraries.find(lib => lib.id === req.params.id)
if (!library) {
return res.status(404).send('Library not found')
}
if (!req.query.q) {
return res.status(400).send('No query string')
}
var maxResults = req.query.max || 3
var bookMatches = []
var authorMatches = {}
var seriesMatches = {}
var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === library.id)
audiobooksInLibrary.forEach((ab) => {
var queryResult = ab.searchQuery(req.query.q)
if (queryResult.book) {
bookMatches.push({
audiobook: ab,
matchKey: queryResult.book
})
}
if (queryResult.author && !authorMatches[queryResult.author]) {
authorMatches[queryResult.author] = {
author: queryResult.author
}
}
if (queryResult.series) {
if (!seriesMatches[queryResult.series]) {
seriesMatches[queryResult.series] = {
series: queryResult.series,
audiobooks: [ab]
}
} else {
seriesMatches[queryResult.series].audiobooks.push(ab)
}
}
})
res.json({
audiobooks: bookMatches.slice(0, maxResults),
authors: Object.values(authorMatches).slice(0, maxResults),
series: Object.values(seriesMatches).slice(0, maxResults)
})
}
getLibrary(req, res) {
var library = this.db.libraries.find(lib => lib.id === req.params.id)
if (!library) {
@ -563,8 +611,14 @@ class ApiController {
if (!settingsUpdate || !isObject(settingsUpdate)) {
return res.status(500).send('Invalid settings update object')
}
var madeUpdates = this.db.serverSettings.update(settingsUpdate)
if (madeUpdates) {
// If backup schedule is updated - update backup manager
if (settingsUpdate.backupSchedule !== undefined) {
this.backupManager.updateCronSchedule()
}
await this.db.updateEntity('settings', this.db.serverSettings)
}
return res.json({

View File

@ -21,6 +21,8 @@ class BackupManager {
this.Gid = Gid
this.db = db
this.scheduleTask = null
this.backups = []
}
@ -28,7 +30,7 @@ class BackupManager {
return this.db.serverSettings || {}
}
async init(overrideCron = null) {
async init() {
var backupsDirExists = await fs.pathExists(this.BackupPath)
if (!backupsDirExists) {
await fs.ensureDir(this.BackupPath)
@ -36,16 +38,34 @@ class BackupManager {
}
await this.loadBackups()
this.scheduleCron()
}
scheduleCron() {
if (!this.serverSettings.backupSchedule) {
Logger.info(`[BackupManager] Auto Backups are disabled`)
return
}
try {
var cronSchedule = overrideCron || this.serverSettings.backupSchedule
cron.schedule(cronSchedule, this.runBackup.bind(this))
var cronSchedule = this.serverSettings.backupSchedule
this.scheduleTask = cron.schedule(cronSchedule, this.runBackup.bind(this))
} catch (error) {
Logger.error(`[BackupManager] Failed to schedule backup cron ${this.serverSettings.backupSchedule}`)
Logger.error(`[BackupManager] Failed to schedule backup cron ${this.serverSettings.backupSchedule}`, error)
}
}
updateCronSchedule() {
if (this.scheduleTask && !this.serverSettings.backupSchedule) {
Logger.info(`[BackupManager] Disabling backup schedule`)
if (this.scheduleTask.destroy) this.scheduleTask.destroy()
this.scheduleTask = null
} else if (!this.scheduleTask && this.serverSettings.backupSchedule) {
Logger.info(`[BackupManager] Starting backup schedule ${this.serverSettings.backupSchedule}`)
this.scheduleCron()
} else if (this.serverSettings.backupSchedule) {
Logger.info(`[BackupManager] Restarting backup schedule ${this.serverSettings.backupSchedule}`)
if (this.scheduleTask.destroy) this.scheduleTask.destroy()
this.scheduleCron()
}
}

View File

@ -623,6 +623,10 @@ class Audiobook {
return this.book.isSearchMatch(search.toLowerCase().trim())
}
searchQuery(search) {
return this.book.getQueryMatches(search.toLowerCase().trim())
}
getAudioFileByIno(ino) {
return this.audioFiles.find(af => af.ino === ino)
}

View File

@ -5,7 +5,6 @@ const parseAuthors = require('../utils/parseAuthors')
class Book {
constructor(book = null) {
this.olid = null
this.title = null
this.subtitle = null
this.author = null
@ -46,7 +45,6 @@ class Book {
}
construct(book) {
this.olid = book.olid
this.title = book.title
this.subtitle = book.subtitle || null
this.author = book.author
@ -69,7 +67,6 @@ class Book {
toJSON() {
return {
olid: this.olid,
title: this.title,
subtitle: this.subtitle,
author: this.author,
@ -111,7 +108,6 @@ class Book {
}
setData(data) {
this.olid = data.olid || null
this.title = data.title || null
this.subtitle = data.subtitle || null
this.author = data.author || null
@ -217,6 +213,17 @@ class Book {
return this._title.toLowerCase().includes(search) || this._subtitle.toLowerCase().includes(search) || this._author.toLowerCase().includes(search) || this._series.toLowerCase().includes(search)
}
getQueryMatches(search) {
var titleMatch = this._title.toLowerCase().includes(search) || this._subtitle.toLowerCase().includes(search)
var authorMatch = this._author.toLowerCase().includes(search)
var seriesMatch = this._series.toLowerCase().includes(search)
return {
book: titleMatch ? 'title' : authorMatch ? 'author' : seriesMatch ? 'series' : false,
author: authorMatch ? this._author : false,
series: seriesMatch ? this._series : false
}
}
setDetailsFromFileMetadata(audioFileMetadata) {
const MetadataMapArray = [
{

View File

@ -5,6 +5,7 @@ class Library {
this.id = null
this.name = null
this.folders = []
this.icon = 'database'
this.lastScan = 0
@ -24,6 +25,8 @@ class Library {
this.id = library.id
this.name = library.name
this.folders = (library.folders || []).map(f => new Folder(f))
this.icon = library.icon || 'database'
this.createdAt = library.createdAt
this.lastUpdate = library.lastUpdate
}
@ -33,6 +36,7 @@ class Library {
id: this.id,
name: this.name,
folders: (this.folders || []).map(f => f.toJSON()),
icon: this.icon,
createdAt: this.createdAt,
lastUpdate: this.lastUpdate
}
@ -55,6 +59,7 @@ class Library {
return newFolder
})
}
this.icon = data.icon || 'database'
this.createdAt = Date.now()
this.lastUpdate = Date.now()
}