From ef2b9a0415c1d71e84ca96578a284a77b6638c9d Mon Sep 17 00:00:00 2001
From: advplyr <advplyr@protonmail.com>
Date: Fri, 1 Oct 2021 20:29:00 -0500
Subject: [PATCH] Scan for covers now saves covers, server settings to save
 covers in audiobook folder

---
 client/components/ui/ToggleSwitch.vue |  2 +-
 client/package.json                   |  2 +-
 client/pages/config/index.vue         | 54 ++++++++++++++++++++++++---
 client/plugins/constants.js           |  8 +++-
 package.json                          |  2 +-
 server/CoverController.js             | 19 +++-------
 server/Scanner.js                     | 18 ++++++---
 server/Server.js                      |  4 +-
 server/objects/Audiobook.js           |  5 ++-
 9 files changed, 83 insertions(+), 31 deletions(-)

diff --git a/client/components/ui/ToggleSwitch.vue b/client/components/ui/ToggleSwitch.vue
index 73892a51..9292d59d 100644
--- a/client/components/ui/ToggleSwitch.vue
+++ b/client/components/ui/ToggleSwitch.vue
@@ -30,7 +30,7 @@ export default {
       }
     },
     className() {
-      if (this.disabled) return 'bg-bg cursor-not-allowed'
+      if (this.disabled) return this.toggleValue ? `bg-${this.onColor} cursor-not-allowed` : `bg-${this.offColor} cursor-not-allowed`
       return this.toggleValue ? `bg-${this.onColor}` : `bg-${this.offColor}`
     },
     switchClassName() {
diff --git a/client/package.json b/client/package.json
index 91940d35..a9d7b142 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,6 +1,6 @@
 {
   "name": "audiobookshelf-client",
-  "version": "1.3.3",
+  "version": "1.3.4",
   "description": "Audiobook manager and player",
   "main": "index.js",
   "scripts": {
diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue
index a0bb7efa..c25ef8b6 100644
--- a/client/pages/config/index.vue
+++ b/client/pages/config/index.vue
@@ -42,9 +42,9 @@
         <div class="flex items-start py-2">
           <div class="py-2">
             <div class="flex items-center">
-              <ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" @input="updateScannerParseSubtitle" />
+              <ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" :disabled="updatingServerSettings" @input="updateScannerParseSubtitle" />
               <ui-tooltip :text="parseSubtitleTooltip">
-                <p class="pl-4 text-lg">Parse Subtitles <span class="material-icons icon-text">info_outlined</span></p>
+                <p class="pl-4 text-lg">Parse subtitles <span class="material-icons icon-text">info_outlined</span></p>
               </ui-tooltip>
             </div>
           </div>
@@ -53,12 +53,30 @@
             <ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
 
             <div class="w-full mb-4">
-              <ui-tooltip direction="bottom" text="Only scans audiobooks without a cover. Covers will be applied if a close match is found." class="w-full">
+              <ui-tooltip direction="bottom" text="(Warning: Long running task!) Attempts to lookup and match a cover with all audiobooks that don't have one." class="w-full">
                 <ui-btn color="primary" class="w-full" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
               </ui-tooltip>
             </div>
+          </div>
+        </div>
+      </div>
 
-            <!-- <ui-btn color="primary" small @click="saveMetadataFiles">Save Metadata</ui-btn> -->
+      <div class="py-4 mb-4">
+        <p class="text-2xl">Metadata</p>
+        <div class="flex items-start py-2">
+          <div class="py-2">
+            <div class="flex items-center">
+              <ui-toggle-switch v-model="storeCoversInAudiobookDir" :disabled="updatingServerSettings" @input="updateCoverStorageDestination" />
+              <ui-tooltip :text="coverDestinationTooltip">
+                <p class="pl-4 text-lg">Store covers with audiobook <span class="material-icons icon-text">info_outlined</span></p>
+              </ui-tooltip>
+            </div>
+          </div>
+          <div class="flex-grow" />
+          <div class="w-40 flex flex-col">
+            <ui-tooltip :text="saveMetadataTooltip" direction="bottom" class="w-full">
+              <ui-btn color="primary" small class="w-full" @click="saveMetadataFiles">Save Metadata</ui-btn>
+            </ui-tooltip>
           </div>
         </div>
       </div>
@@ -101,18 +119,21 @@ export default {
   },
   data() {
     return {
+      storeCoversInAudiobookDir: false,
       isResettingAudiobooks: false,
       users: [],
       selectedAccount: null,
       showAccountModal: false,
       isDeletingUser: false,
-      newServerSettings: {}
+      newServerSettings: {},
+      updatingServerSettings: false
     }
   },
   watch: {
     serverSettings(newVal, oldVal) {
       if (newVal && !oldVal) {
         this.newServerSettings = { ...this.serverSettings }
+        this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
       }
     }
   },
@@ -120,6 +141,12 @@ export default {
     parseSubtitleTooltip() {
       return 'Extract subtitles from audiobook directory names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"'
     },
+    coverDestinationTooltip() {
+      return 'By default covers are stored in /metadata/books, enabling this setting will store covers inside your audiobooks directory. Only one file named "cover" will be kept.'
+    },
+    saveMetadataTooltip() {
+      return 'This will write a "metadata.nfo" file in all of your audiobook directories.'
+    },
     serverSettings() {
       return this.$store.state.serverSettings
     },
@@ -134,6 +161,12 @@ export default {
     }
   },
   methods: {
+    updateCoverStorageDestination(val) {
+      this.newServerSettings.coverDestination = val ? this.$constants.CoverDestination.AUDIOBOOK : this.$constants.CoverDestination.METADATA
+      this.updateServerSettings({
+        coverDestination: this.newServerSettings.coverDestination
+      })
+    },
     updateScannerParseSubtitle(val) {
       var payload = {
         scannerParseSubtitle: val
@@ -141,13 +174,16 @@ export default {
       this.updateServerSettings(payload)
     },
     updateServerSettings(payload) {
+      this.updatingServerSettings = true
       this.$store
         .dispatch('updateServerSettings', payload)
         .then((success) => {
           console.log('Updated Server Settings', success)
+          this.updatingServerSettings = false
         })
         .catch((error) => {
           console.error('Failed to update server settings', error)
+          this.updatingServerSettings = false
         })
     },
     setDeveloperMode() {
@@ -161,7 +197,14 @@ export default {
     scanCovers() {
       this.$root.socket.emit('scan_covers')
     },
+    saveMetadataComplete(result) {
+      this.savingMetadata = false
+      if (!result) return
+      this.$toast.success(`Metadata saved for ${result.success} audiobooks`)
+    },
     saveMetadataFiles() {
+      this.savingMetadata = true
+      this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete)
       this.$root.socket.emit('save_metadata')
     },
     loadUsers() {
@@ -247,6 +290,7 @@ export default {
       this.$root.socket.on('user_removed', this.userRemoved)
 
       this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
+      this.storeCoversInAudiobookDir = this.newServerSettings.coverDestination === this.$constants.CoverDestination.AUDIOBOOK
     }
   },
   mounted() {
diff --git a/client/plugins/constants.js b/client/plugins/constants.js
index 35dc5e08..dbd71632 100644
--- a/client/plugins/constants.js
+++ b/client/plugins/constants.js
@@ -5,8 +5,14 @@ const DownloadStatus = {
   FAILED: 3
 }
 
+const CoverDestination = {
+  METADATA: 0,
+  AUDIOBOOK: 1
+}
+
 const Constants = {
-  DownloadStatus
+  DownloadStatus,
+  CoverDestination
 }
 
 export default ({ app }, inject) => {
diff --git a/package.json b/package.json
index 592e2f4a..fd4e75d5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "audiobookshelf",
-  "version": "1.3.3",
+  "version": "1.3.4",
   "description": "Self-hosted audiobook server for managing and playing audiobooks",
   "main": "index.js",
   "scripts": {
diff --git a/server/CoverController.js b/server/CoverController.js
index fba0a791..0aa5aedd 100644
--- a/server/CoverController.js
+++ b/server/CoverController.js
@@ -8,7 +8,6 @@ const imageType = require('image-type')
 const globals = require('./utils/globals')
 const { CoverDestination } = require('./utils/constants')
 
-
 class CoverController {
   constructor(db, MetadataPath, AudiobookPath) {
     this.db = db
@@ -52,8 +51,8 @@ class CoverController {
     }
   }
 
-  // Remove covers in metadata/books/{ID} that dont have the same filename as the new cover
-  async checkBookMetadataCovers(dirpath, newCoverExt) {
+  // Remove covers that dont have the same filename as the new cover
+  async removeOldCovers(dirpath, newCoverExt) {
     var filesInDir = await this.getFilesInDirectory(dirpath)
 
     for (let i = 0; i < filesInDir.length; i++) {
@@ -97,17 +96,11 @@ class CoverController {
 
     var { fullPath, relPath } = this.getCoverDirectory(audiobook)
     await fs.ensureDir(fullPath)
-    var isStoringInMetadata = relPath.slice(1).startsWith('metadata')
 
     var coverFilename = `cover${extname}`
     var coverFullPath = Path.join(fullPath, coverFilename)
     var coverPath = Path.join(relPath, coverFilename)
 
-
-    if (isStoringInMetadata) {
-      await this.checkBookMetadataCovers(fullPath, extname)
-    }
-
     // Move cover from temp upload dir to destination
     var success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => {
       Logger.error('[CoverController] Failed to move cover file', path, error)
@@ -115,12 +108,13 @@ class CoverController {
     })
 
     if (!success) {
-      // return res.status(500).send('Failed to move cover into destination')
       return {
         error: 'Failed to move cover into destination'
       }
     }
 
+    await this.removeOldCovers(fullPath, extname)
+
     Logger.info(`[CoverController] Uploaded audiobook cover "${coverPath}" for "${audiobook.title}"`)
 
     audiobook.updateBookCover(coverPath)
@@ -171,10 +165,7 @@ class CoverController {
       var coverFullPath = Path.join(fullPath, coverFilename)
       await fs.rename(temppath, coverFullPath)
 
-      var isStoringInMetadata = relPath.slice(1).startsWith('metadata')
-      if (isStoringInMetadata) {
-        await this.checkBookMetadataCovers(fullPath, '.' + imgtype.ext)
-      }
+      await this.removeOldCovers(fullPath, '.' + imgtype.ext)
 
       Logger.info(`[CoverController] Downloaded audiobook cover "${coverPath}" from url "${url}" for "${audiobook.title}"`)
 
diff --git a/server/Scanner.js b/server/Scanner.js
index 44a7b78a..e3881c9c 100644
--- a/server/Scanner.js
+++ b/server/Scanner.js
@@ -10,12 +10,13 @@ const { secondsToTimestamp } = require('./utils/fileUtils')
 const { ScanResult, CoverDestination } = require('./utils/constants')
 
 class Scanner {
-  constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) {
+  constructor(AUDIOBOOK_PATH, METADATA_PATH, db, coverController, emitter) {
     this.AudiobookPath = AUDIOBOOK_PATH
     this.MetadataPath = METADATA_PATH
     this.BookMetadataPath = Path.join(this.MetadataPath, 'books')
 
     this.db = db
+    this.coverController = coverController
     this.emitter = emitter
 
     this.cancelScan = false
@@ -453,6 +454,8 @@ class Scanner {
     var audiobooksNeedingCover = this.audiobooks.filter(ab => !ab.cover && ab.author)
     var found = 0
     var notFound = 0
+    var failed = 0
+
     for (let i = 0; i < audiobooksNeedingCover.length; i++) {
       var audiobook = audiobooksNeedingCover[i]
       var options = {
@@ -462,10 +465,15 @@ class Scanner {
       var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.author, options)
       if (results.length) {
         Logger.debug(`[Scanner] Found best cover for "${audiobook.title}"`)
-        audiobook.book.cover = results[0]
-        await this.db.updateAudiobook(audiobook)
-        found++
-        this.emitter('audiobook_updated', audiobook.toJSONMinified())
+        var coverUrl = results[0]
+        var result = await this.coverController.downloadCoverFromUrl(audiobook, coverUrl)
+        if (result.error) {
+          failed++
+        } else {
+          found++
+          await this.db.updateAudiobook(audiobook)
+          this.emitter('audiobook_updated', audiobook.toJSONMinified())
+        }
       } else {
         notFound++
       }
diff --git a/server/Server.js b/server/Server.js
index b47e5ab1..c3ee1fe4 100644
--- a/server/Server.js
+++ b/server/Server.js
@@ -36,10 +36,10 @@ class Server {
     this.db = new Db(this.ConfigPath)
     this.auth = new Auth(this.db)
     this.watcher = new Watcher(this.AudiobookPath)
-    this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.emitter.bind(this))
+    this.coverController = new CoverController(this.db, this.MetadataPath, this.AudiobookPath)
+    this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.coverController, this.emitter.bind(this))
     this.streamManager = new StreamManager(this.db, this.MetadataPath)
     this.rssFeeds = new RssFeeds(this.Port, this.db)
-    this.coverController = new CoverController(this.db, this.MetadataPath, this.AudiobookPath)
     this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, this.emitter.bind(this))
     this.apiController = new ApiController(this.MetadataPath, this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.coverController, this.emitter.bind(this), this.clientEmitter.bind(this))
     this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath)
diff --git a/server/objects/Audiobook.js b/server/objects/Audiobook.js
index 2fec1e86..a4b777df 100644
--- a/server/objects/Audiobook.js
+++ b/server/objects/Audiobook.js
@@ -437,7 +437,10 @@ class Audiobook {
     this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
 
     // Some files are not there anymore and filtered out
-    if (currOtherFileNum !== this.otherFiles.length) hasUpdates = true
+    if (currOtherFileNum !== this.otherFiles.length) {
+      Logger.debug(`[Audiobook] ${currOtherFileNum - this.otherFiles.length} other files were removed for "${this.title}"`)
+      hasUpdates = true
+    }
 
     // If desc.txt is new or forcing rescan then read it and update description if empty
     var descriptionTxt = newOtherFiles.find(file => file.filename === 'desc.txt')