From 1dc01615dd26e0bf14171a631ed0ac07b0d22657 Mon Sep 17 00:00:00 2001
From: advplyr <advplyr@protonmail.com>
Date: Wed, 25 Aug 2021 19:15:00 -0500
Subject: [PATCH] Fix listener for audiobook updates in edit modal, add remove
 cover button

---
 client/components/modals/EditModal.vue        | 16 +++--
 client/components/modals/edit-tabs/Cover.vue  | 37 ++++++++--
 .../components/modals/edit-tabs/Details.vue   |  1 -
 client/components/modals/edit-tabs/Match.vue  |  3 +-
 client/package.json                           |  2 +-
 package.json                                  |  2 +-
 server/Book.js                                | 10 ++-
 server/BookFinder.js                          | 69 ++++++++++---------
 server/Scanner.js                             |  7 +-
 9 files changed, 96 insertions(+), 51 deletions(-)

diff --git a/client/components/modals/EditModal.vue b/client/components/modals/EditModal.vue
index b4e337f4..441d115a 100644
--- a/client/components/modals/EditModal.vue
+++ b/client/components/modals/EditModal.vue
@@ -25,18 +25,21 @@ export default {
     return {
       selectedTab: 'details',
       processing: false,
-      audiobook: null
+      audiobook: null,
+      fetchOnShow: false
     }
   },
   watch: {
     show: {
       handler(newVal) {
         if (newVal) {
-          if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) return
+          if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) {
+            if (this.fetchOnShow) this.fetchFull()
+            return
+          }
+          this.fetchOnShow = false
           this.audiobook = null
           this.init()
-        } else {
-          this.$store.commit('audiobooks/removeListener', 'edit-modal')
         }
       }
     }
@@ -75,7 +78,10 @@ export default {
       this.selectedTab = tab
     },
     audiobookUpdated() {
-      this.fetchFull()
+      if (!this.show) this.fetchOnShow = true
+      else {
+        this.fetchFull()
+      }
     },
     init() {
       this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedAudiobookId })
diff --git a/client/components/modals/edit-tabs/Cover.vue b/client/components/modals/edit-tabs/Cover.vue
index 38ba5d60..201c6f89 100644
--- a/client/components/modals/edit-tabs/Cover.vue
+++ b/client/components/modals/edit-tabs/Cover.vue
@@ -1,7 +1,16 @@
 <template>
   <div class="w-full h-full overflow-hidden overflow-y-auto px-1">
     <div class="flex">
-      <cards-book-cover :audiobook="audiobook" />
+      <div class="relative">
+        <cards-book-cover :audiobook="audiobook" />
+        <!-- book cover overlay -->
+        <div v-if="book.cover" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
+          <div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
+          <div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
+            <span class="material-icons">delete</span>
+          </div>
+        </div>
+      </div>
       <div class="flex-grow pl-6 pr-2">
         <form @submit.prevent="submitForm">
           <div class="flex items-center">
@@ -19,7 +28,6 @@
 
           <div v-if="showLocalCovers" class="flex items-center justify-center">
             <template v-for="cover in localCovers">
-              <!-- <img :src="`/local/${cover.path}`" :key="cover.path" class="w-20 h-32 object-cover m-0.5" /> -->
               <div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover.localPath)">
                 <img :src="cover.localPath" class="h-24 object-cover" style="width: 60px" />
               </div>
@@ -43,9 +51,11 @@
         <div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-60 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>
+            <ui-tooltip :key="cover" direction="bottom" :text="cover">
+              <div 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>
+            </ui-tooltip>
           </template>
         </div>
       </div>
@@ -78,7 +88,9 @@ export default {
     audiobook: {
       immediate: true,
       handler(newVal) {
-        if (newVal) this.init()
+        if (newVal) {
+          this.init()
+        }
       }
     }
   },
@@ -118,10 +130,22 @@ export default {
       this.searchTitle = this.book.title || ''
       this.searchAuthor = this.book.author || ''
     },
+    removeCover() {
+      if (!this.book.cover) {
+        this.imageUrl = ''
+        return
+      }
+      this.updateCover('')
+    },
     submitForm() {
       this.updateCover(this.imageUrl)
     },
     async updateCover(cover) {
+      if (cover === this.book.cover) {
+        console.warn('Cover has not changed..', cover)
+        return
+      }
+
       this.isProcessing = true
       const updatePayload = {
         book: {
@@ -134,7 +158,6 @@ export default {
       })
       this.isProcessing = false
       if (updatedAudiobook) {
-        console.log('Update Successful', updatedAudiobook)
         this.$toast.success('Update Successful')
         this.$emit('close')
       }
diff --git a/client/components/modals/edit-tabs/Details.vue b/client/components/modals/edit-tabs/Details.vue
index 9b9cec3f..99c7e506 100644
--- a/client/components/modals/edit-tabs/Details.vue
+++ b/client/components/modals/edit-tabs/Details.vue
@@ -130,7 +130,6 @@ export default {
       })
       this.isProcessing = false
       if (updatedAudiobook) {
-        console.log('Update Successful', updatedAudiobook)
         this.$toast.success('Update Successful')
         this.$emit('close')
       }
diff --git a/client/components/modals/edit-tabs/Match.vue b/client/components/modals/edit-tabs/Match.vue
index 93c547ae..9e522625 100644
--- a/client/components/modals/edit-tabs/Match.vue
+++ b/client/components/modals/edit-tabs/Match.vue
@@ -66,7 +66,7 @@ export default {
   },
   methods: {
     getSearchQuery() {
-      var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
+      var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
       if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
       return searchQuery
     },
@@ -129,7 +129,6 @@ export default {
       })
       this.isProcessing = false
       if (updatedAudiobook) {
-        console.log('Update Successful', updatedAudiobook)
         this.$toast.success('Update Successful')
         this.$emit('close')
       }
diff --git a/client/package.json b/client/package.json
index 74fa3690..1642a98f 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,6 +1,6 @@
 {
   "name": "audiobookshelf-client",
-  "version": "0.9.77-beta",
+  "version": "0.9.78-beta",
   "description": "Audiobook manager and player",
   "main": "index.js",
   "scripts": {
diff --git a/package.json b/package.json
index fe5728f5..cc9e918c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "audiobookshelf",
-  "version": "0.9.77-beta",
+  "version": "0.9.78-beta",
   "description": "Self-hosted audiobook server for managing and playing audiobooks.",
   "main": "index.js",
   "scripts": {
diff --git a/server/Book.js b/server/Book.js
index 601e0221..f636b86a 100644
--- a/server/Book.js
+++ b/server/Book.js
@@ -95,13 +95,21 @@ class Book {
     if (data.otherFiles && data.otherFiles.length) {
       var imageFile = data.otherFiles.find(f => f.filetype === 'image')
       if (imageFile) {
-        this.cover = Path.join('/local', imageFile.path)
+        this.cover = Path.normalize(Path.join('/local', imageFile.path))
       }
     }
   }
 
   update(payload) {
     var hasUpdates = false
+
+    if (payload.cover) {
+      // If updating to local cover then normalize path
+      if (!payload.cover.startsWith('http:') && !payload.cover.startsWith('https:')) {
+        payload.cover = Path.normalize(payload.cover)
+      }
+    }
+
     for (const key in payload) {
       if (payload[key] === undefined) continue;
 
diff --git a/server/BookFinder.js b/server/BookFinder.js
index 5e05cd09..7e4ea0c4 100644
--- a/server/BookFinder.js
+++ b/server/BookFinder.js
@@ -12,7 +12,7 @@ class BookFinder {
   async findByISBN(isbn) {
     var book = await this.openLibrary.isbnLookup(isbn)
     if (book.errorCode) {
-      console.error('Book not found')
+      Logger.error('Book not found')
     }
     return book
   }
@@ -61,73 +61,82 @@ class BookFinder {
     return books.map(b => {
       b.cleanedTitle = this.cleanTitleForCompares(b.title)
       b.titleDistance = levenshteinDistance(b.cleanedTitle, title)
+
+      // Total length of search (title or both title & author)
+      b.totalPossibleDistance = b.title.length
+
       if (author) {
         if (!b.author) {
           b.authorDistance = author.length
         } else {
+          b.totalPossibleDistance += b.author.length
           b.cleanedAuthor = this.cleanAuthorForCompares(b.author)
 
           var cleanedAuthorDistance = levenshteinDistance(b.cleanedAuthor, searchAuthor)
           var authorDistance = levenshteinDistance(b.author || '', author)
+
           // Use best distance
-          if (cleanedAuthorDistance > authorDistance) b.authorDistance = authorDistance
-          else b.authorDistance = cleanedAuthorDistance
+          b.authorDistance = Math.min(cleanedAuthorDistance, authorDistance)
+
+          // Check book author contains searchAuthor
+          if (searchAuthor.length > 4 && b.cleanedAuthor.includes(searchAuthor)) b.includesAuthor = searchAuthor
+          else if (author.length > 4 && b.author.includes(author)) b.includesAuthor = 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
+      // Check book title contains the searchTitle
+      if (searchTitle.length > 4 && b.cleanedTitle.includes(searchTitle)) b.includesTitle = searchTitle
+      else if (title.length > 4 && b.title.includes(title)) b.includesTitle = title
 
       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}`)
+      if (b.includesTitle) { // If search title was found in result title then skip over leven distance check
+        Logger.debug(`Exact title was included in "${b.title}", Search: "${b.includesTitle}"`)
       } 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 (author) {
+        if (b.includesAuthor) { // If search author was found in result author then skip over leven distance check
+          Logger.debug(`Exact author was included in "${b.author}", Search: "${b.includesAuthor}"`)
+        } else if (b.authorDistance > maxAuthorDistance) {
+          Logger.debug(`Filtering out search result "${b.author}", author distance = ${b.authorDistance}: "${b.author}"/"${author}"`)
+          return false
+        }
       }
 
-      if (b.totalPossibleDistance < 4 && b.totalDistance > 0) return false
+      // If book total search length < 5 and was not exact match, then filter out
+      if (b.totalPossibleDistance < 5 && 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}`)
+    Logger.debug(`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`)
+      Logger.debug(`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}`)
+    Logger.debug(`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`)
+      Logger.debug(`Search has ${books.length} matches, but no close title matches`)
     }
     return booksFiltered
   }
@@ -136,7 +145,7 @@ class BookFinder {
     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}`)
+    Logger.debug(`Book Search, title: "${title}", author: "${author}", provider: ${provider}`)
 
     if (provider === 'libgen') {
       books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
@@ -147,18 +156,16 @@ class BookFinder {
       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`)
+      books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
+      var hasCloseMatch = books.find(b => (b.totalDistance < 2 && b.totalPossibleDistance > 6))
+      if (!hasCloseMatch) {
+        Logger.debug(`Book Search, openlib has no super close matches - get libgen results also`)
         var lbBooks = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
         books = books.concat(lbBooks)
       }
 
-      if (!books.length && author) {
-        Logger.info(`Book Search, no matches for title and author.. check title only`)
+      if (!books.length && author && options.fallbackTitleOnly) {
+        Logger.debug(`Book Search, no matches for title and author.. check title only`)
         return this.search(provider, title, null, options)
       }
     }
@@ -170,7 +177,7 @@ class BookFinder {
 
   async findCovers(provider, title, author, options = {}) {
     var searchResults = await this.search(provider, title, author, options)
-    Logger.info(`[BookFinder] FindCovers search results: ${searchResults.length}`)
+    Logger.debug(`[BookFinder] FindCovers search results: ${searchResults.length}`)
 
     var covers = []
     searchResults.forEach((result) => {
diff --git a/server/Scanner.js b/server/Scanner.js
index 0d844ae9..8e827993 100644
--- a/server/Scanner.js
+++ b/server/Scanner.js
@@ -247,7 +247,7 @@ class Scanner {
       }
       var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.author, options)
       if (results.length) {
-        Logger.info(`[Scanner] Found best cover for "${audiobook.title}"`)
+        Logger.debug(`[Scanner] Found best cover for "${audiobook.title}"`)
         audiobook.book.cover = results[0]
         await this.db.updateAudiobook(audiobook)
         found++
@@ -294,7 +294,10 @@ class Scanner {
 
   async findCovers(req, res) {
     var query = req.query
-    var result = await this.bookFinder.findCovers(query.provider, query.title, query.author || null)
+    var options = {
+      fallbackTitleOnly: !!query.fallbackTitleOnly
+    }
+    var result = await this.bookFinder.findCovers(query.provider, query.title, query.author || null, options)
     res.json(result)
   }
 }