Update book finder and cover matching - includes LibGen provider

This commit is contained in:
Mark Cooper 2021-08-21 09:15:44 -05:00
parent be7e2576f1
commit 30700c1eb0
14 changed files with 379 additions and 76 deletions

View File

@ -1,5 +1,5 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px' }">
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
<img ref="cover" :src="cover" class="w-full h-full object-cover" />
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">

View File

@ -0,0 +1,59 @@
<template>
<div class="w-full border-b border-gray-700 pb-2">
<div class="flex py-1 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch">
<img :src="selectedCover || '/book_placeholder.jpg'" class="h-24 object-cover" style="width: 60px" />
<div class="px-4 flex-grow">
<div class="flex items-center">
<h1>{{ book.title }}</h1>
<div class="flex-grow" />
<p>{{ book.year || book.first_publish_date }}</p>
</div>
<p class="text-gray-400">{{ book.author }}</p>
<div class="w-full max-h-12 overflow-hidden">
<p class="text-gray-500 text-xs" v-html="book.description"></p>
</div>
</div>
</div>
<div v-if="bookCovers.length > 1" class="flex">
<template v-for="cover in bookCovers">
<div :key="cover" class="border-2 hover:border-yellow-300 border-transparent" :class="cover === selectedCover ? 'border-yellow-200' : ''" @mousedown.stop @mouseup.stop @click.stop="clickCover(cover)">
<img :src="cover" class="h-20 w-12 object-cover mr-1" />
</div>
</template>
</div>
</div>
</template>
<script>
export default {
props: {
book: {
type: Object,
default: () => {}
}
},
data() {
return {
selectedCover: null
}
},
computed: {
bookCovers() {
return this.book.covers ? this.book.covers || [] : []
}
},
methods: {
selectMatch() {
var book = { ...this.book }
book.cover = this.selectedCover
this.$emit('select', this.book)
},
clickCover(cover) {
this.selectedCover = cover
}
},
mounted() {
this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : null
}
}
</script>

View File

@ -34,7 +34,9 @@ export default {
if (newVal) {
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) return
this.audiobook = null
this.fetchFull()
this.init()
} else {
this.$store.commit('audiobooks/removeListener', 'edit-modal')
}
}
}
@ -72,6 +74,13 @@ export default {
selectTab(tab) {
this.selectedTab = tab
},
audiobookUpdated() {
this.fetchFull()
},
init() {
this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedAudiobookId })
this.fetchFull()
},
async fetchFull() {
try {
this.audiobook = await this.$axios.$get(`/api/audiobook/${this.selectedAudiobookId}`)

View File

@ -2,13 +2,35 @@
<div class="w-full h-full">
<div class="flex">
<cards-book-cover :audiobook="audiobook" />
<div class="flex-grow px-8">
<div class="flex-grow pl-6 pr-2">
<form @submit.prevent="submitForm">
<div class="flex items-center">
<ui-text-input-with-label v-model="imageUrl" label="Cover Image URL" />
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-4">Update</ui-btn>
<ui-btn color="success" type="submit" :padding-x="4" class="mt-5 ml-3 w-24">Update</ui-btn>
</div>
</form>
<form @submit.prevent="submitSearchForm">
<div class="flex items-center justify-start -mx-1 py-2 mt-2">
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="searchTitle" label="Search Title" placeholder="Search" :disabled="processing" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="searchAuthor" label="Author" :disabled="processing" />
</div>
<div class="w-24 px-1">
<ui-btn type="submit" class="mt-5 w-full" :padding-x="0">Search</ui-btn>
</div>
</div>
</form>
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-72 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)">
<img :src="cover" class="h-24 object-cover" style="width: 60px" />
</div>
</template>
</div>
</div>
</div>
</div>
@ -25,7 +47,11 @@ export default {
},
data() {
return {
imageUrl: null
searchTitle: null,
searchAuthor: null,
imageUrl: null,
coversFound: [],
hasSearched: false
}
},
watch: {
@ -51,14 +77,22 @@ export default {
},
methods: {
init() {
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.author)) {
this.coversFound = []
this.hasSearched = false
}
this.imageUrl = this.book.cover || ''
this.searchTitle = this.book.title || ''
this.searchAuthor = this.book.author || ''
},
async submitForm() {
console.log('Submit form', this.details)
submitForm() {
this.updateCover(this.imageUrl)
},
async updateCover(cover) {
this.isProcessing = true
const updatePayload = {
book: {
cover: this.imageUrl
cover: cover
}
}
var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
@ -71,6 +105,25 @@ export default {
this.$toast.success('Update Successful')
this.$emit('close')
}
},
getSearchQuery() {
var searchQuery = `provider=best&title=${this.searchTitle}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
return searchQuery
},
async submitSearchForm() {
this.isProcessing = true
var searchQuery = this.getSearchQuery()
var results = await this.$axios.$get(`/api/find/covers?${searchQuery}`).catch((error) => {
console.error('Failed', error)
return []
})
this.coversFound = results
this.isProcessing = false
this.hasSearched = true
},
setCover(cover) {
this.updateCover(cover)
}
}
}

View File

@ -1,42 +1,26 @@
<template>
<div class="w-full h-full overflow-hidden">
<div class="flex items-center mb-4">
<div class="w-72">
<form @submit.prevent="submitSearch">
<ui-text-input-with-label v-model="search" label="Search Title" placeholder="Search" :disabled="processing" />
</form>
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-72 px-1">
<ui-text-input-with-label v-model="searchTitle" label="Search Title" placeholder="Search" :disabled="processing" />
</div>
<div class="w-72 px-1">
<ui-text-input-with-label v-model="searchAuthor" label="Author" :disabled="processing" />
</div>
<ui-btn class="mt-5 ml-1" type="submit">Search</ui-btn>
<div class="flex-grow" />
</div>
</form>
<div v-show="processing" class="flex h-full items-center justify-center">
<p>Loading...</p>
</div>
<div v-show="!processing && !searchResults.length" class="flex h-full items-center justify-center">
<p>No Results</p>
</div>
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden">
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper">
<template v-for="(res, index) in searchResults">
<div :key="index" class="w-full border-b border-gray-700 pb-2 hover:bg-gray-300 hover:bg-opacity-10 cursor-pointer" @click="selectMatch(res)">
<div class="flex py-1">
<img :src="res.cover || '/book_placeholder.jpg'" class="h-24 object-cover" style="width: 60px" />
<div class="px-4 flex-grow">
<div class="flex items-center">
<h1>{{ res.title }}</h1>
<div class="flex-grow" />
<p>{{ res.first_publish_year || res.first_publish_date }}</p>
</div>
<p class="text-gray-400">{{ res.author }}</p>
<div class="w-full max-h-12 overflow-hidden">
<p class="text-gray-500 text-xs" v-html="res.description"></p>
</div>
</div>
</div>
<div v-if="res.covers && res.covers.length > 1" class="flex">
<template v-for="cover in res.covers.slice(1)">
<img :key="cover" :src="cover" class="h-20 w-12 object-cover mr-1" />
</template>
</div>
</div>
<cards-book-match-card :key="index" :book="res" @select="selectMatch" />
</template>
</div>
</div>
@ -53,8 +37,10 @@ export default {
},
data() {
return {
search: null,
searchTitle: null,
searchAuthor: null,
lastSearch: null,
provider: 'best',
searchResults: []
}
},
@ -77,36 +63,41 @@ export default {
}
},
methods: {
getSearchQuery() {
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
return searchQuery
},
submitSearch() {
if (!this.searchTitle) {
this.$toast.warning('Search title is required')
return
}
this.runSearch()
},
async runSearch() {
if (this.lastSearch === this.search) return
console.log('Search', this.lastSearch, this.search)
var searchQuery = this.getSearchQuery()
if (this.lastSearch === searchQuery) return
this.searchResults = []
this.isProcessing = true
this.lastSearch = this.search
var results = await this.$axios.$get(`/api/find/search?title=${this.search}`).catch((error) => {
this.lastSearch = searchQuery
var results = await this.$axios.$get(`/api/find/search?${searchQuery}`).catch((error) => {
console.error('Failed', error)
return []
})
results = results.filter((res) => {
return !!res.title
})
console.log('Got results', results)
this.searchResults = results
this.isProcessing = false
},
init() {
if (!this.audiobook.book || !this.audiobook.book.title) {
this.search = null
this.searchTitle = null
return
}
if (this.searchResults.length) {
console.log('Already hav ereuslts', this.searchResults, this.lastSearch)
}
this.search = this.audiobook.book.title
this.searchTitle = this.audiobook.book.title
this.searchAuthor = this.audiobook.book.author || ''
this.runSearch()
},
async selectMatch(match) {
@ -137,3 +128,9 @@ export default {
}
}
</script>
<style>
.matchListWrapper {
height: calc(100% - 80px);
}
</style>

View File

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

View File

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

View File

@ -23,7 +23,6 @@ Title can start with the publish year like so:
* Adding new audiobooks require pressing Scan button again (on settings page)
* Matching is all manual now and only using 1 source (openlibrary)
* Need to add cover selection from match results
* Support different views to see more details of each audiobook
* Then comes the mobile app..

View File

@ -14,8 +14,10 @@ class ApiController {
}
init() {
this.router.get('/find/covers', this.findCovers.bind(this))
this.router.get('/find/:method', this.find.bind(this))
this.router.get('/audiobooks', this.getAudiobooks.bind(this))
this.router.get('/audiobook/:id', this.getAudiobook.bind(this))
this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this))
@ -36,6 +38,11 @@ class ApiController {
this.scanner.find(req, res)
}
findCovers(req, res) {
console.log('Find covers', req.query)
this.scanner.findCovers(req, res)
}
async getMetadata(req, res) {
var metadata = await this.scanner.fetchMetadata(req.params.id, req.params.trackIndex)
res.json(metadata)

View File

@ -1,5 +1,7 @@
const OpenLibrary = require('./providers/OpenLibrary')
const LibGen = require('./providers/LibGen')
const Logger = require('./Logger')
const { levenshteinDistance } = require('./utils/index')
class BookFinder {
constructor() {
@ -15,19 +17,142 @@ class BookFinder {
return book
}
async search(query, provider = 'openlibrary') {
var books = null
stripSubtitle(title) {
if (title.includes(':')) {
return title.split(':')[0].trim()
} else if (title.includes(' - ')) {
return title.split(' - ')[0].trim()
}
return title
}
cleanTitleForCompares(title) {
// Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
var stripped = this.stripSubtitle(title)
// Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game")
var cleaned = stripped.replace(/ *\([^)]*\) */g, "")
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
cleaned = cleaned.replace(/'/g, '')
return cleaned.toLowerCase()
}
filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) {
var searchTitle = this.cleanTitleForCompares(title)
return books.map(b => {
b.cleanedTitle = this.cleanTitleForCompares(b.title)
b.titleDistance = levenshteinDistance(b.cleanedTitle, title)
if (author) {
b.authorDistance = levenshteinDistance(b.author || '', author)
}
b.totalDistance = b.titleDistance + (b.authorDistance || 0)
b.totalPossibleDistance = b.title.length
if (b.cleanedTitle.includes(searchTitle) && searchTitle.length > 4) {
b.includesSearch = searchTitle
} else if (b.title.includes(searchTitle) && searchTitle.length > 4) {
b.includesSearch = searchTitle
}
if (author && b.author) b.totalPossibleDistance += b.author.length
return b
}).filter(b => {
if (b.includesSearch) { // If search was found in result title exactly then skip over leven distance check
Logger.debug(`Exact search was found inside title ${b.cleanedTitle}/${b.includesSearch}`)
} else if (b.titleDistance > maxTitleDistance) {
Logger.debug(`Filtering out search result title distance = ${b.titleDistance}: "${b.cleanedTitle}"/"${searchTitle}"`)
return false
}
if (author && b.authorDistance > maxAuthorDistance) {
Logger.debug(`Filtering out search result "${b.title}", author distance = ${b.authorDistance}: "${b.author}"/"${author}"`)
return false
}
if (b.totalPossibleDistance < 4 && b.totalDistance > 0) return false
return true
})
}
async getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance) {
var books = await this.libGen.search(title)
Logger.info(`LibGen Book Search Results: ${books.length || 0}`)
if (books.errorCode) {
Logger.error(`LibGen Search Error ${books.errorCode}`)
return []
}
var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance)
if (!booksFiltered.length && books.length) {
Logger.info(`Search has ${books.length} matches, but no close title matches`)
}
return booksFiltered
}
async getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) {
var books = await this.openLibrary.searchTitle(title)
Logger.info(`OpenLib Book Search Results: ${books.length || 0}`)
if (books.errorCode) {
Logger.error(`OpenLib Search Error ${books.errorCode}`)
return []
}
var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance)
if (!booksFiltered.length && books.length) {
Logger.info(`Search has ${books.length} matches, but no close title matches`)
}
return booksFiltered
}
async search(provider, title, author, options = {}) {
var books = []
var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
var maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4
Logger.info(`Book Search, title: "${title}", author: "${author}", provider: ${provider}`)
if (provider === 'libgen') {
books = await this.libGen.search(query)
return books
books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
} else if (provider === 'openlibrary') {
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
} else if (provider === 'all') {
var lbBooks = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
var olBooks = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
books = books.concat(lbBooks, olBooks)
} else {
var olBooks = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
var hasCloseMatch = olBooks.find(b => (b.totalDistance < 4 && b.totalPossibleDistance > 4))
if (hasCloseMatch) {
books = olBooks
} else {
Logger.info(`Book Search, LibGen has no close matches - get openlib results also`)
var lbBooks = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
books = books.concat(lbBooks)
}
books = await this.openLibrary.search(query)
if (books.errorCode) {
console.error('Books not found')
if (!books.length && author) {
Logger.info(`Book Search, no matches for title and author.. check title only`)
return this.search(provider, title, null, options)
}
return books
}
return books.sort((a, b) => {
return a.totalDistance - b.totalDistance
})
}
async findCovers(provider, title, author, options = {}) {
var searchResults = await this.search(provider, title, author, options)
console.log('Find Covers search results', searchResults)
var covers = []
searchResults.forEach((result) => {
if (result.covers && result.covers.length) {
covers = covers.concat(result.covers)
}
if (result.cover) {
covers.push(result.cover)
}
})
return covers
}
}
module.exports = BookFinder

View File

@ -77,14 +77,18 @@ class Scanner {
var result = null
if (method === 'isbn') {
console.log('Search', query, 'via ISBN')
result = await this.bookFinder.findByISBN(query)
} else if (method === 'search') {
console.log('Search', query, 'via query')
result = await this.bookFinder.search(query)
result = await this.bookFinder.search(query.provider, query.title, query.author || null)
}
res.json(result)
}
async findCovers(req, res) {
var query = req.query
var result = await this.bookFinder.findCovers(query.provider, query.title, query.author || null)
res.json(result)
}
}
module.exports = Scanner

View File

@ -10,28 +10,40 @@ class LibGen {
console.log(`${this.mirror} is currently fastest`)
}
async search(query) {
async search(queryTitle) {
if (!this.mirror) {
await this.init()
}
queryTitle = queryTitle.replace(/'/g, '')
var options = {
mirror: this.mirror,
query: query,
query: queryTitle,
search_in: 'title'
}
var httpsMirror = this.mirror
if (httpsMirror.startsWith('http:')) {
httpsMirror = httpsMirror.replace('http:', 'https:')
}
// console.log('LibGen Search Options', options)
try {
const data = await libgen.search(options)
let n = data.length
console.log(`${n} results for "${options.query}"`)
// console.log(`${n} results for "${options.query}"`)
var cleanedResults = []
while (n--) {
console.log('');
console.log('Title: ' + data[n].title)
console.log('Author: ' + data[n].author)
console.log('Download: ' +
'http://gen.lib.rus.ec/book/index.php?md5=' +
data[n].md5.toLowerCase())
var resultObj = {
id: data[n].id,
title: data[n].title,
author: data[n].author,
publisher: data[n].publisher,
description: data[n].descr,
cover: `${httpsMirror}/covers/${data[n].coverurl}`,
year: data[n].year
}
return data
if (!resultObj.title) continue;
cleanedResults.push(resultObj)
}
return cleanedResults
} catch (err) {
console.error(err)
return {

View File

@ -50,7 +50,7 @@ class OpenLibrary {
return {
title: doc.title,
author: doc.author_name ? doc.author_name.join(', ') : null,
first_publish_year: doc.first_publish_year,
year: doc.first_publish_year,
edition: doc.cover_edition_key,
cover: doc.cover_edition_key ? `https://covers.openlibrary.org/b/OLID/${doc.cover_edition_key}-L.jpg` : null,
...worksData
@ -68,5 +68,17 @@ class OpenLibrary {
var searchDocs = await Promise.all(lookupData.docs.map(d => this.cleanSearchDoc(d)))
return searchDocs
}
async searchTitle(title) {
title = title.replace(/'/g, '')
var lookupData = await this.get(`/search.json?title=${title}`)
if (!lookupData) {
return {
errorCode: 404
}
}
var searchDocs = await Promise.all(lookupData.docs.map(d => this.cleanSearchDoc(d)))
return searchDocs
}
}
module.exports = OpenLibrary

26
server/utils/index.js Normal file
View File

@ -0,0 +1,26 @@
const levenshteinDistance = (str1, str2, caseSensitive = false) => {
if (!caseSensitive) {
str1 = str1.toLowerCase()
str2 = str2.toLowerCase()
}
const track = Array(str2.length + 1).fill(null).map(() =>
Array(str1.length + 1).fill(null));
for (let i = 0; i <= str1.length; i += 1) {
track[0][i] = i;
}
for (let j = 0; j <= str2.length; j += 1) {
track[j][0] = j;
}
for (let j = 1; j <= str2.length; j += 1) {
for (let i = 1; i <= str1.length; i += 1) {
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
track[j][i] = Math.min(
track[j][i - 1] + 1, // deletion
track[j - 1][i] + 1, // insertion
track[j - 1][i - 1] + indicator, // substitution
);
}
}
return track[str2.length][str1.length];
}
module.exports.levenshteinDistance = levenshteinDistance