mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	New data model save covers, scanner, new api routes
This commit is contained in:
		
							parent
							
								
									5f4e5cd3d8
								
							
						
					
					
						commit
						73257188f6
					
				| @ -425,42 +425,42 @@ export default { | |||||||
|       this.handleScroll(scrollTop) |       this.handleScroll(scrollTop) | ||||||
|       // }, 250) |       // }, 250) | ||||||
|     }, |     }, | ||||||
|     audiobookAdded(audiobook) { |     libraryItemAdded(libraryItem) { | ||||||
|       console.log('Audiobook added', audiobook) |       console.log('libraryItem added', libraryItem) | ||||||
|       // TODO: Check if audiobook would be on this shelf |       // TODO: Check if audiobook would be on this shelf | ||||||
|       this.resetEntities() |       this.resetEntities() | ||||||
|     }, |     }, | ||||||
|     audiobookUpdated(audiobook) { |     libraryItemUpdated(libraryItem) { | ||||||
|       console.log('Audiobook updated', audiobook) |       console.log('Item updated', libraryItem) | ||||||
|       if (this.entityName === 'books' || this.entityName === 'series-books') { |       if (this.entityName === 'books' || this.entityName === 'series-books') { | ||||||
|         var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id) |         var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id) | ||||||
|         if (indexOf >= 0) { |         if (indexOf >= 0) { | ||||||
|           this.entities[indexOf] = audiobook |           this.entities[indexOf] = libraryItem | ||||||
|           if (this.entityComponentRefs[indexOf]) { |           if (this.entityComponentRefs[indexOf]) { | ||||||
|             this.entityComponentRefs[indexOf].setEntity(audiobook) |             this.entityComponentRefs[indexOf].setEntity(libraryItem) | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     audiobookRemoved(audiobook) { |     libraryItemRemoved(libraryItem) { | ||||||
|       if (this.entityName === 'books' || this.entityName === 'series-books') { |       if (this.entityName === 'books' || this.entityName === 'series-books') { | ||||||
|         var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id) |         var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id) | ||||||
|         if (indexOf >= 0) { |         if (indexOf >= 0) { | ||||||
|           this.entities = this.entities.filter((ent) => ent.id !== audiobook.id) |           this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id) | ||||||
|           this.totalEntities = this.entities.length |           this.totalEntities = this.entities.length | ||||||
|           this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities) |           this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities) | ||||||
|           this.remountEntities() |           this.executeRebuild() | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     audiobooksAdded(audiobooks) { |     libraryItemsAdded(libraryItems) { | ||||||
|       console.log('audiobooks added', audiobooks) |       console.log('items added', libraryItems) | ||||||
|       // TODO: Check if audiobook would be on this shelf |       // TODO: Check if audiobook would be on this shelf | ||||||
|       this.resetEntities() |       this.resetEntities() | ||||||
|     }, |     }, | ||||||
|     audiobooksUpdated(audiobooks) { |     libraryItemsUpdated(libraryItems) { | ||||||
|       audiobooks.forEach((ab) => { |       libraryItems.forEach((ab) => { | ||||||
|         this.audiobookUpdated(ab) |         this.libraryItemUpdated(ab) | ||||||
|       }) |       }) | ||||||
|     }, |     }, | ||||||
|     initSizeData(_bookshelf) { |     initSizeData(_bookshelf) { | ||||||
| @ -525,11 +525,11 @@ export default { | |||||||
|       this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated }) |       this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated }) | ||||||
| 
 | 
 | ||||||
|       if (this.$root.socket) { |       if (this.$root.socket) { | ||||||
|         this.$root.socket.on('audiobook_updated', this.audiobookUpdated) |         this.$root.socket.on('item_updated', this.libraryItemUpdated) | ||||||
|         this.$root.socket.on('audiobook_added', this.audiobookAdded) |         this.$root.socket.on('item_added', this.libraryItemAdded) | ||||||
|         this.$root.socket.on('audiobook_removed', this.audiobookRemoved) |         this.$root.socket.on('item_removed', this.libraryItemRemoved) | ||||||
|         this.$root.socket.on('audiobooks_updated', this.audiobooksUpdated) |         this.$root.socket.on('items_updated', this.libraryItemsUpdated) | ||||||
|         this.$root.socket.on('audiobooks_added', this.audiobooksAdded) |         this.$root.socket.on('items_added', this.libraryItemsAdded) | ||||||
|       } else { |       } else { | ||||||
|         console.error('Bookshelf - Socket not initialized') |         console.error('Bookshelf - Socket not initialized') | ||||||
|       } |       } | ||||||
| @ -546,11 +546,11 @@ export default { | |||||||
|       this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf') |       this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf') | ||||||
| 
 | 
 | ||||||
|       if (this.$root.socket) { |       if (this.$root.socket) { | ||||||
|         this.$root.socket.off('audiobook_updated', this.audiobookUpdated) |         this.$root.socket.off('item_updated', this.libraryItemUpdated) | ||||||
|         this.$root.socket.off('audiobook_added', this.audiobookAdded) |         this.$root.socket.off('item_added', this.libraryItemAdded) | ||||||
|         this.$root.socket.off('audiobook_removed', this.audiobookRemoved) |         this.$root.socket.off('item_removed', this.libraryItemRemoved) | ||||||
|         this.$root.socket.off('audiobooks_updated', this.audiobooksUpdated) |         this.$root.socket.off('items_updated', this.libraryItemsUpdated) | ||||||
|         this.$root.socket.off('audiobooks_added', this.audiobooksAdded) |         this.$root.socket.off('items_added', this.libraryItemsAdded) | ||||||
|       } else { |       } else { | ||||||
|         console.error('Bookshelf - Socket not initialized') |         console.error('Bookshelf - Socket not initialized') | ||||||
|       } |       } | ||||||
|  | |||||||
| @ -379,8 +379,8 @@ export default { | |||||||
|       this.isSelectionMode = val |       this.isSelectionMode = val | ||||||
|       if (!val) this.selected = false |       if (!val) this.selected = false | ||||||
|     }, |     }, | ||||||
|     setEntity(audiobook) { |     setEntity(libraryItem) { | ||||||
|       this.audiobook = audiobook |       this.audiobook = libraryItem | ||||||
|     }, |     }, | ||||||
|     clickCard(e) { |     clickCard(e) { | ||||||
|       if (this.isSelectionMode) { |       if (this.isSelectionMode) { | ||||||
|  | |||||||
| @ -157,7 +157,7 @@ export default { | |||||||
|         .filter((f) => f.fileType === 'image') |         .filter((f) => f.fileType === 'image') | ||||||
|         .map((file) => { |         .map((file) => { | ||||||
|           var _file = { ...file } |           var _file = { ...file } | ||||||
|           _file.localPath = `/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath)}` |           _file.localPath = `/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}` | ||||||
|           return _file |           return _file | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
| @ -169,7 +169,7 @@ export default { | |||||||
|       form.set('cover', this.selectedFile) |       form.set('cover', this.selectedFile) | ||||||
| 
 | 
 | ||||||
|       this.$axios |       this.$axios | ||||||
|         .$post(`/api/books/${this.libraryItemId}/cover`, form) |         .$post(`/api/items/${this.libraryItemId}/cover`, form) | ||||||
|         .then((data) => { |         .then((data) => { | ||||||
|           if (data.error) { |           if (data.error) { | ||||||
|             this.$toast.error(data.error) |             this.$toast.error(data.error) | ||||||
| @ -230,8 +230,20 @@ export default { | |||||||
|       this.isProcessing = true |       this.isProcessing = true | ||||||
|       var success = false |       var success = false | ||||||
| 
 | 
 | ||||||
|       // Download cover from url and use |       if (!cover) { | ||||||
|       if (cover.startsWith('http:') || cover.startsWith('https:')) { |         // Remove cover | ||||||
|  |         success = await this.$axios | ||||||
|  |           .$delete(`/api/items/${this.libraryItemId}/cover`) | ||||||
|  |           .then(() => true) | ||||||
|  |           .catch((error) => { | ||||||
|  |             console.error('Failed to remove cover', error) | ||||||
|  |             if (error.response && error.response.data) { | ||||||
|  |               this.$toast.error(error.response.data) | ||||||
|  |             } | ||||||
|  |             return false | ||||||
|  |           }) | ||||||
|  |       } else if (cover.startsWith('http:') || cover.startsWith('https:')) { | ||||||
|  |         // Download cover from url and use | ||||||
|         success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover }).catch((error) => { |         success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover }).catch((error) => { | ||||||
|           console.error('Failed to download cover from url', error) |           console.error('Failed to download cover from url', error) | ||||||
|           if (error.response && error.response.data) { |           if (error.response && error.response.data) { | ||||||
| @ -242,11 +254,9 @@ export default { | |||||||
|       } else { |       } else { | ||||||
|         // Update local cover url |         // Update local cover url | ||||||
|         const updatePayload = { |         const updatePayload = { | ||||||
|           book: { |           cover | ||||||
|             cover: cover |  | ||||||
|           } |  | ||||||
|         } |         } | ||||||
|         success = await this.$axios.$patch(`/api/books/${this.libraryItemId}`, updatePayload).catch((error) => { |         success = await this.$axios.$patch(`/api/items/${this.libraryItemId}/cover`, updatePayload).catch((error) => { | ||||||
|           console.error('Failed to update', error) |           console.error('Failed to update', error) | ||||||
|           if (error.response && error.response.data) { |           if (error.response && error.response.data) { | ||||||
|             this.$toast.error(error.response.data) |             this.$toast.error(error.response.data) | ||||||
| @ -256,7 +266,7 @@ export default { | |||||||
|       } |       } | ||||||
|       if (success) { |       if (success) { | ||||||
|         this.$toast.success('Update Successful') |         this.$toast.success('Update Successful') | ||||||
|         this.$emit('close') |         // this.$emit('close') | ||||||
|       } else { |       } else { | ||||||
|         this.imageUrl = this.media.coverPath || '' |         this.imageUrl = this.media.coverPath || '' | ||||||
|       } |       } | ||||||
|  | |||||||
| @ -287,22 +287,22 @@ export default { | |||||||
|           this.quickMatching = false |           this.quickMatching = false | ||||||
|         }) |         }) | ||||||
|     }, |     }, | ||||||
|     audiobookScanComplete(result) { |     libraryScanComplete(result) { | ||||||
|       this.rescanning = false |       this.rescanning = false | ||||||
|       if (!result) { |       if (!result) { | ||||||
|         this.$toast.error(`Re-Scan Failed for "${this.title}"`) |         this.$toast.error(`Re-Scan Failed for "${this.title}"`) | ||||||
|       } else if (result === 'UPDATED') { |       } else if (result === 'UPDATED') { | ||||||
|         this.$toast.success(`Re-Scan complete audiobook was updated`) |         this.$toast.success(`Re-Scan complete item was updated`) | ||||||
|       } else if (result === 'UPTODATE') { |       } else if (result === 'UPTODATE') { | ||||||
|         this.$toast.success(`Re-Scan complete audiobook was up to date`) |         this.$toast.success(`Re-Scan complete item was up to date`) | ||||||
|       } else if (result === 'REMOVED') { |       } else if (result === 'REMOVED') { | ||||||
|         this.$toast.error(`Re-Scan complete audiobook was removed`) |         this.$toast.error(`Re-Scan complete item was removed`) | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     rescan() { |     rescan() { | ||||||
|       this.rescanning = true |       this.rescanning = true | ||||||
|       this.$root.socket.once('audiobook_scan_complete', this.audiobookScanComplete) |       this.$root.socket.once('item_scan_complete', this.libraryScanComplete) | ||||||
|       this.$root.socket.emit('scan_audiobook', this.audiobookId) |       this.$root.socket.emit('scan_item', this.libraryItemId) | ||||||
|     }, |     }, | ||||||
|     saveMetadataComplete(result) { |     saveMetadataComplete(result) { | ||||||
|       this.savingMetadata = false |       this.savingMetadata = false | ||||||
| @ -381,7 +381,7 @@ export default { | |||||||
|       if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) { |       if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) { | ||||||
|         this.isProcessing = true |         this.isProcessing = true | ||||||
|         this.$axios |         this.$axios | ||||||
|           .$delete(`/api/books/${this.libraryItemId}`) |           .$delete(`/api/items/${this.libraryItemId}`) | ||||||
|           .then(() => { |           .then(() => { | ||||||
|             console.log('Item removed') |             console.log('Item removed') | ||||||
|             this.$toast.success('Item Removed') |             this.$toast.success('Item Removed') | ||||||
|  | |||||||
| @ -13,20 +13,20 @@ | |||||||
|             <ui-btn small color="primary">Manage Tracks</ui-btn> |             <ui-btn small color="primary">Manage Tracks</ui-btn> | ||||||
|           </nuxt-link> |           </nuxt-link> | ||||||
|         </div> |         </div> | ||||||
|         <table class="text-sm tracksTable"> |         <table class="text-sm tracksTable break-all"> | ||||||
|           <tr class="font-book"> |           <tr class="font-book"> | ||||||
|             <th>#</th> |             <th class="w-16">#</th> | ||||||
|             <th class="text-left">Filename</th> |             <th class="text-left">Filename</th> | ||||||
|             <th class="text-left">Size</th> |             <th class="text-left w-24 min-w-24">Size</th> | ||||||
|             <th class="text-left">Duration</th> |             <th class="text-left w-24 min-w-24">Duration</th> | ||||||
|             <th v-if="showDownload" class="text-center">Download</th> |             <th v-if="showDownload" class="text-center w-24 min-w-24">Download</th> | ||||||
|           </tr> |           </tr> | ||||||
|           <template v-for="track in tracks"> |           <template v-for="track in tracks"> | ||||||
|             <tr :key="track.index"> |             <tr :key="track.index"> | ||||||
|               <td class="text-center"> |               <td class="text-center"> | ||||||
|                 <p>{{ track.index }}</p> |                 <p>{{ track.index }}</p> | ||||||
|               </td> |               </td> | ||||||
|               <td class="font-sans">{{ showFullPath ? track.path : track.filename }}</td> |               <td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td> | ||||||
|               <td class="font-mono"> |               <td class="font-mono"> | ||||||
|                 {{ $bytesPretty(track.metadata.size) }} |                 {{ $bytesPretty(track.metadata.size) }} | ||||||
|               </td> |               </td> | ||||||
|  | |||||||
| @ -16,18 +16,22 @@ | |||||||
|         <table class="text-sm tracksTable"> |         <table class="text-sm tracksTable"> | ||||||
|           <tr class="font-book"> |           <tr class="font-book"> | ||||||
|             <th class="text-left px-4">Path</th> |             <th class="text-left px-4">Path</th> | ||||||
|  |             <th class="text-left w-24 min-w-24">Size</th> | ||||||
|             <th class="text-left px-4 w-24">Filetype</th> |             <th class="text-left px-4 w-24">Filetype</th> | ||||||
|             <th v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th> |             <th v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th> | ||||||
|           </tr> |           </tr> | ||||||
|           <template v-for="file in files"> |           <template v-for="file in files"> | ||||||
|             <tr :key="file.path"> |             <tr :key="file.path"> | ||||||
|               <td class="font-book pl-2"> |               <td class="font-book px-4"> | ||||||
|                 {{ showFullPath ? file.metadata.path : file.metadata.relPath }} |                 {{ showFullPath ? file.metadata.path : file.metadata.relPath }} | ||||||
|               </td> |               </td> | ||||||
|  |               <td class="font-mono"> | ||||||
|  |                 {{ $bytesPretty(file.metadata.size) }} | ||||||
|  |               </td> | ||||||
|               <td class="text-xs"> |               <td class="text-xs"> | ||||||
|                 <div class="flex items-center"> |                 <div class="flex items-center"> | ||||||
|                   <span v-if="file.filetype === 'ebook'" class="material-icons text-base mr-1 cursor-pointer text-white text-opacity-60 hover:text-opacity-100" @click="readEbookClick(file)">auto_stories </span> |                   <span v-if="file.filetype === 'ebook'" class="material-icons text-base mr-1 cursor-pointer text-white text-opacity-60 hover:text-opacity-100" @click="readEbookClick(file)">auto_stories </span> | ||||||
|                   <p>{{ file.metadata.ext }}</p> |                   <p>{{ file.fileType }}</p> | ||||||
|                 </div> |                 </div> | ||||||
|               </td> |               </td> | ||||||
|               <td v-if="userCanDownload && !isMissing" class="text-center"> |               <td v-if="userCanDownload && !isMissing" class="text-center"> | ||||||
|  | |||||||
| @ -171,24 +171,6 @@ export default { | |||||||
|       // this.$store.commit('audiobooks/addUpdate', audiobook) |       // this.$store.commit('audiobooks/addUpdate', audiobook) | ||||||
|       this.$store.commit('libraries/updateFilterDataWithAudiobook', audiobook) |       this.$store.commit('libraries/updateFilterDataWithAudiobook', audiobook) | ||||||
|     }, |     }, | ||||||
|     audiobookUpdated(audiobook) { |  | ||||||
|       if (this.$store.state.selectedAudiobook && this.$store.state.selectedAudiobook.id === audiobook.id) { |  | ||||||
|         console.log('Updating selected audiobook', audiobook) |  | ||||||
|         this.$store.commit('setSelectedAudiobook', audiobook) |  | ||||||
|       } |  | ||||||
|       // Just triggers the listeners |  | ||||||
|       this.$store.commit('audiobooks/audiobookUpdated', audiobook) |  | ||||||
|       this.$store.commit('libraries/updateFilterDataWithAudiobook', audiobook) |  | ||||||
|       // this.$store.commit('audiobooks/addUpdate', audiobook) |  | ||||||
|     }, |  | ||||||
|     audiobookRemoved(audiobook) { |  | ||||||
|       if (this.$route.name.startsWith('audiobook')) { |  | ||||||
|         if (this.$route.params.id === audiobook.id) { |  | ||||||
|           this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`) |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|       // this.$store.commit('audiobooks/remove', audiobook) |  | ||||||
|     }, |  | ||||||
|     audiobooksAdded(audiobooks) { |     audiobooksAdded(audiobooks) { | ||||||
|       audiobooks.forEach((ab) => { |       audiobooks.forEach((ab) => { | ||||||
|         this.audiobookAdded(ab) |         this.audiobookAdded(ab) | ||||||
| @ -215,6 +197,13 @@ export default { | |||||||
|       } |       } | ||||||
|       this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem) |       this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem) | ||||||
|     }, |     }, | ||||||
|  |     libraryItemRemoved(item) { | ||||||
|  |       if (this.$route.name.startsWith('item')) { | ||||||
|  |         if (this.$route.params.id === item.id) { | ||||||
|  |           this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`) | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     scanComplete(data) { |     scanComplete(data) { | ||||||
|       console.log('Scan complete received', data) |       console.log('Scan complete received', data) | ||||||
| 
 | 
 | ||||||
| @ -403,6 +392,7 @@ export default { | |||||||
| 
 | 
 | ||||||
|       // Library Item Listeners |       // Library Item Listeners | ||||||
|       this.socket.on('item_updated', this.libraryItemUpdated) |       this.socket.on('item_updated', this.libraryItemUpdated) | ||||||
|  |       this.socket.on('item_removed', this.libraryItemRemoved) | ||||||
| 
 | 
 | ||||||
|       // User Listeners |       // User Listeners | ||||||
|       this.socket.on('user_updated', this.userUpdated) |       this.socket.on('user_updated', this.userUpdated) | ||||||
|  | |||||||
| @ -76,8 +76,12 @@ class ApiController { | |||||||
|     //
 |     //
 | ||||||
|     this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this)) |     this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this)) | ||||||
|     this.router.patch('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.update.bind(this)) |     this.router.patch('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.update.bind(this)) | ||||||
|  |     this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this)) | ||||||
|     this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this)) |     this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this)) | ||||||
|     this.router.get('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.getCover.bind(this)) |     this.router.get('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.getCover.bind(this)) | ||||||
|  |     this.router.post('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.uploadCover.bind(this)) | ||||||
|  |     this.router.patch('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.updateCover.bind(this)) | ||||||
|  |     this.router.delete('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.removeCover.bind(this)) | ||||||
| 
 | 
 | ||||||
|     //
 |     //
 | ||||||
|     // Book Routes
 |     // Book Routes
 | ||||||
| @ -437,18 +441,18 @@ class ApiController { | |||||||
|     return json |     return json | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async handleDeleteAudiobook(audiobook) { |   async handleDeleteLibraryItem(libraryItem) { | ||||||
|     // Remove audiobook from users
 |     // Remove libraryItem from users
 | ||||||
|     for (let i = 0; i < this.db.users.length; i++) { |     for (let i = 0; i < this.db.users.length; i++) { | ||||||
|       var user = this.db.users[i] |       var user = this.db.users[i] | ||||||
|       var madeUpdates = user.deleteAudiobookData(audiobook.id) |       var madeUpdates = user.deleteAudiobookData(libraryItem.id) | ||||||
|       if (madeUpdates) { |       if (madeUpdates) { | ||||||
|         await this.db.updateEntity('user', user) |         await this.db.updateEntity('user', user) | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // remove any streams open for this audiobook
 |     // remove any streams open for this audiobook
 | ||||||
|     var streams = this.streamManager.streams.filter(stream => stream.audiobookId === audiobook.id) |     var streams = this.streamManager.streams.filter(stream => stream.audiobookId === libraryItem.id) | ||||||
|     for (let i = 0; i < streams.length; i++) { |     for (let i = 0; i < streams.length; i++) { | ||||||
|       var stream = streams[i] |       var stream = streams[i] | ||||||
|       var client = stream.client |       var client = stream.client | ||||||
| @ -461,22 +465,22 @@ class ApiController { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // remove book from collections
 |     // remove book from collections
 | ||||||
|     var collectionsWithBook = this.db.collections.filter(c => c.books.includes(audiobook.id)) |     var collectionsWithBook = this.db.collections.filter(c => c.books.includes(libraryItem.id)) | ||||||
|     for (let i = 0; i < collectionsWithBook.length; i++) { |     for (let i = 0; i < collectionsWithBook.length; i++) { | ||||||
|       var collection = collectionsWithBook[i] |       var collection = collectionsWithBook[i] | ||||||
|       collection.removeBook(audiobook.id) |       collection.removeBook(libraryItem.id) | ||||||
|       await this.db.updateEntity('collection', collection) |       await this.db.updateEntity('collection', collection) | ||||||
|       this.clientEmitter(collection.userId, 'collection_updated', collection.toJSONExpanded(this.db.audiobooks)) |       this.clientEmitter(collection.userId, 'collection_updated', collection.toJSONExpanded(this.db.libraryItems)) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // purge cover cache
 |     // purge cover cache
 | ||||||
|     if (audiobook.cover) { |     if (libraryItem.media.coverPath) { | ||||||
|       await this.cacheManager.purgeCoverCache(audiobook.id) |       await this.cacheManager.purgeCoverCache(libraryItem.id) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     var audiobookJSON = audiobook.toJSONMinified() |     var json = libraryItem.toJSONExpanded() | ||||||
|     await this.db.removeEntity('audiobook', audiobook.id) |     await this.db.removeLibraryItem(libraryItem.id) | ||||||
|     this.emitter('audiobook_removed', audiobookJSON) |     this.emitter('item_removed', json) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async getUserListeningSessionsHelper(userId) { |   async getUserListeningSessionsHelper(userId) { | ||||||
|  | |||||||
| @ -13,12 +13,10 @@ const Logger = require('./Logger') | |||||||
| const Backup = require('./objects/Backup') | const Backup = require('./objects/Backup') | ||||||
| 
 | 
 | ||||||
| class BackupManager { | class BackupManager { | ||||||
|   constructor(Uid, Gid, db) { |   constructor(db) { | ||||||
|     this.BackupPath = Path.join(global.MetadataPath, 'backups') |     this.BackupPath = Path.join(global.MetadataPath, 'backups') | ||||||
|     this.MetadataBooksPath = Path.join(global.MetadataPath, 'books') |     this.MetadataBooksPath = Path.join(global.MetadataPath, 'books') | ||||||
| 
 | 
 | ||||||
|     this.Uid = Uid |  | ||||||
|     this.Gid = Gid |  | ||||||
|     this.db = db |     this.db = db | ||||||
| 
 | 
 | ||||||
|     this.scheduleTask = null |     this.scheduleTask = null | ||||||
| @ -37,7 +35,7 @@ class BackupManager { | |||||||
|     var backupsDirExists = await fs.pathExists(this.BackupPath) |     var backupsDirExists = await fs.pathExists(this.BackupPath) | ||||||
|     if (!backupsDirExists) { |     if (!backupsDirExists) { | ||||||
|       await fs.ensureDir(this.BackupPath) |       await fs.ensureDir(this.BackupPath) | ||||||
|       await filePerms(this.BackupPath, 0o774, this.Uid, this.Gid) |       await filePerms.setDefault(this.BackupPath) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     await this.loadBackups() |     await this.loadBackups() | ||||||
| @ -211,7 +209,7 @@ class BackupManager { | |||||||
|     }) |     }) | ||||||
|     if (zipResult) { |     if (zipResult) { | ||||||
|       Logger.info(`[BackupManager] Backup successful ${newBackup.id}`) |       Logger.info(`[BackupManager] Backup successful ${newBackup.id}`) | ||||||
|       await filePerms(newBackup.fullPath, 0o774, this.Uid, this.Gid) |       await filePerms.setDefault(newBackup.fullPath) | ||||||
|       newBackup.fileSize = await getFileSize(newBackup.fullPath) |       newBackup.fileSize = await getFileSize(newBackup.fullPath) | ||||||
|       var existingIndex = this.backups.findIndex(b => b.id === newBackup.id) |       var existingIndex = this.backups.findIndex(b => b.id === newBackup.id) | ||||||
|       if (existingIndex >= 0) { |       if (existingIndex >= 0) { | ||||||
|  | |||||||
| @ -42,11 +42,11 @@ class CacheManager { | |||||||
|     readStream.pipe(res) |     readStream.pipe(res) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async purgeCoverCache(audiobookId) { |   async purgeCoverCache(libraryItemId) { | ||||||
|     // If purgeAll has been called... The cover cache directory no longer exists
 |     // If purgeAll has been called... The cover cache directory no longer exists
 | ||||||
|     await fs.ensureDir(this.CoverCachePath) |     await fs.ensureDir(this.CoverCachePath) | ||||||
|     return Promise.all((await fs.readdir(this.CoverCachePath)).reduce((promises, file) => { |     return Promise.all((await fs.readdir(this.CoverCachePath)).reduce((promises, file) => { | ||||||
|       if (file.startsWith(audiobookId)) { |       if (file.startsWith(libraryItemId)) { | ||||||
|         Logger.debug(`[CacheManager] Going to purge ${file}`); |         Logger.debug(`[CacheManager] Going to purge ${file}`); | ||||||
|         promises.push(this.removeCache(Path.join(this.CoverCachePath, file))) |         promises.push(this.removeCache(Path.join(this.CoverCachePath, file))) | ||||||
|       } |       } | ||||||
|  | |||||||
| @ -4,9 +4,11 @@ const axios = require('axios') | |||||||
| const Logger = require('./Logger') | const Logger = require('./Logger') | ||||||
| const readChunk = require('read-chunk') | const readChunk = require('read-chunk') | ||||||
| const imageType = require('image-type') | const imageType = require('image-type') | ||||||
|  | const filePerms = require('./utils/filePerms') | ||||||
| 
 | 
 | ||||||
| const globals = require('./utils/globals') | const globals = require('./utils/globals') | ||||||
| const { downloadFile } = require('./utils/fileUtils') | const { downloadFile } = require('./utils/fileUtils') | ||||||
|  | const { extractCoverArt } = require('./utils/ffmpegHelpers') | ||||||
| 
 | 
 | ||||||
| class CoverController { | class CoverController { | ||||||
|   constructor(db, cacheManager) { |   constructor(db, cacheManager) { | ||||||
| @ -16,17 +18,11 @@ class CoverController { | |||||||
|     this.BookMetadataPath = Path.posix.join(global.MetadataPath, 'books') |     this.BookMetadataPath = Path.posix.join(global.MetadataPath, 'books') | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   getCoverDirectory(audiobook) { |   getCoverDirectory(libraryItem) { | ||||||
|     if (this.db.serverSettings.storeCoverWithBook) { |     if (this.db.serverSettings.storeCoverWithBook) { | ||||||
|       return { |       return libraryItem.path | ||||||
|         fullPath: audiobook.fullPath, |  | ||||||
|         relPath: '/s/book/' + audiobook.id |  | ||||||
|       } |  | ||||||
|     } else { |     } else { | ||||||
|       return { |       return Path.posix.join(this.BookMetadataPath, libraryItem.id) | ||||||
|         fullPath: Path.posix.join(this.BookMetadataPath, audiobook.id), |  | ||||||
|         relPath: Path.posix.join('/metadata', 'books', audiobook.id) |  | ||||||
|       } |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -67,18 +63,18 @@ class CoverController { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async checkFileIsValidImage(imagepath) { |   async checkFileIsValidImage(imagepath, removeOnInvalid = false) { | ||||||
|     const buffer = await readChunk(imagepath, 0, 12) |     const buffer = await readChunk(imagepath, 0, 12) | ||||||
|     const imgType = imageType(buffer) |     const imgType = imageType(buffer) | ||||||
|     if (!imgType) { |     if (!imgType) { | ||||||
|       await this.removeFile(imagepath) |       if (removeOnInvalid) await this.removeFile(imagepath) | ||||||
|       return { |       return { | ||||||
|         error: 'Invalid image' |         error: 'Invalid image' | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (!globals.SupportedImageTypes.includes(imgType.ext)) { |     if (!globals.SupportedImageTypes.includes(imgType.ext)) { | ||||||
|       await this.removeFile(imagepath) |       if (removeOnInvalid) await this.removeFile(imagepath) | ||||||
|       return { |       return { | ||||||
|         error: `Invalid image type ${imgType.ext} (Supported: ${globals.SupportedImageTypes.join(',')})` |         error: `Invalid image type ${imgType.ext} (Supported: ${globals.SupportedImageTypes.join(',')})` | ||||||
|       } |       } | ||||||
| @ -86,7 +82,7 @@ class CoverController { | |||||||
|     return imgType |     return imgType | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async uploadCover(audiobook, coverFile) { |   async uploadCover(libraryItem, coverFile) { | ||||||
|     var extname = Path.extname(coverFile.name.toLowerCase()) |     var extname = Path.extname(coverFile.name.toLowerCase()) | ||||||
|     if (!extname || !globals.SupportedImageTypes.includes(extname.slice(1))) { |     if (!extname || !globals.SupportedImageTypes.includes(extname.slice(1))) { | ||||||
|       return { |       return { | ||||||
| @ -94,12 +90,10 @@ class CoverController { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     var { fullPath, relPath } = this.getCoverDirectory(audiobook) |     var coverDirPath = this.getCoverDirectory(libraryItem) | ||||||
|     await fs.ensureDir(fullPath) |     await fs.ensureDir(coverDirPath) | ||||||
| 
 | 
 | ||||||
|     var coverFilename = `cover${extname}` |     var coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`) | ||||||
|     var coverFullPath = Path.posix.join(fullPath, coverFilename) |  | ||||||
|     var coverPath = Path.posix.join(relPath, coverFilename) |  | ||||||
| 
 | 
 | ||||||
|     // Move cover from temp upload dir to destination
 |     // Move cover from temp upload dir to destination
 | ||||||
|     var success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => { |     var success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => { | ||||||
| @ -113,23 +107,23 @@ class CoverController { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     await this.removeOldCovers(fullPath, extname) |     await this.removeOldCovers(coverDirPath, extname) | ||||||
|     await this.cacheManager.purgeCoverCache(audiobook.id) |     await this.cacheManager.purgeCoverCache(libraryItem.id) | ||||||
| 
 | 
 | ||||||
|     Logger.info(`[CoverController] Uploaded audiobook cover "${coverPath}" for "${audiobook.title}"`) |     Logger.info(`[CoverController] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.metadata.title}"`) | ||||||
| 
 | 
 | ||||||
|     audiobook.updateBookCover(coverPath, coverFullPath) |     libraryItem.updateMediaCover(coverFullPath) | ||||||
|     return { |     return { | ||||||
|       cover: coverPath |       cover: coverFullPath | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async downloadCoverFromUrl(audiobook, url) { |   async downloadCoverFromUrl(libraryItem, url) { | ||||||
|     try { |     try { | ||||||
|       var { fullPath, relPath } = this.getCoverDirectory(audiobook) |       var coverDirPath = this.getCoverDirectory(libraryItem) | ||||||
|       await fs.ensureDir(fullPath) |       await fs.ensureDir(coverDirPath) | ||||||
| 
 | 
 | ||||||
|       var temppath = Path.posix.join(fullPath, 'cover') |       var temppath = Path.posix.join(coverDirPath, 'cover') | ||||||
|       var success = await downloadFile(url, temppath).then(() => true).catch((err) => { |       var success = await downloadFile(url, temppath).then(() => true).catch((err) => { | ||||||
|         Logger.error(`[CoverController] Download image file failed for "${url}"`, err) |         Logger.error(`[CoverController] Download image file failed for "${url}"`, err) | ||||||
|         return false |         return false | ||||||
| @ -140,25 +134,24 @@ class CoverController { | |||||||
|         } |         } | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       var imgtype = await this.checkFileIsValidImage(temppath) |       var imgtype = await this.checkFileIsValidImage(temppath, true) | ||||||
| 
 | 
 | ||||||
|       if (imgtype.error) { |       if (imgtype.error) { | ||||||
|         return imgtype |         return imgtype | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       var coverFilename = `cover.${imgtype.ext}` |       var coverFilename = `cover.${imgtype.ext}` | ||||||
|       var coverPath = Path.posix.join(relPath, coverFilename) |       var coverFullPath = Path.posix.join(coverDirPath, coverFilename) | ||||||
|       var coverFullPath = Path.posix.join(fullPath, coverFilename) |  | ||||||
|       await fs.rename(temppath, coverFullPath) |       await fs.rename(temppath, coverFullPath) | ||||||
| 
 | 
 | ||||||
|       await this.removeOldCovers(fullPath, '.' + imgtype.ext) |       await this.removeOldCovers(coverDirPath, '.' + imgtype.ext) | ||||||
|       await this.cacheManager.purgeCoverCache(audiobook.id) |       await this.cacheManager.purgeCoverCache(libraryItem.id) | ||||||
| 
 | 
 | ||||||
|       Logger.info(`[CoverController] Downloaded audiobook cover "${coverPath}" from url "${url}" for "${audiobook.title}"`) |       Logger.info(`[CoverController] Downloaded libraryItem cover "${coverFullPath}" from url "${url}" for "${libraryItem.media.metadata.title}"`) | ||||||
| 
 | 
 | ||||||
|       audiobook.updateBookCover(coverPath, coverFullPath) |       libraryItem.updateMediaCover(coverFullPath) | ||||||
|       return { |       return { | ||||||
|         cover: coverPath |         cover: coverFullPath | ||||||
|       } |       } | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
|       Logger.error(`[CoverController] Fetch cover image from url "${url}" failed`, error) |       Logger.error(`[CoverController] Fetch cover image from url "${url}" failed`, error) | ||||||
| @ -167,5 +160,94 @@ class CoverController { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   async validateCoverPath(coverPath, libraryItem) { | ||||||
|  |     // Invalid cover path
 | ||||||
|  |     if (!coverPath || coverPath.startsWith('http:') || coverPath.startsWith('https:')) { | ||||||
|  |       Logger.error(`[CoverController] validate cover path invalid http url "${coverPath}"`) | ||||||
|  |       return { | ||||||
|  |         error: 'Invalid cover path' | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     coverPath = coverPath.replace(/\\/g, '/') | ||||||
|  |     // Cover path already set on media
 | ||||||
|  |     if (libraryItem.media.coverPath == coverPath) { | ||||||
|  |       Logger.debug(`[CoverController] validate cover path already set "${coverPath}"`) | ||||||
|  |       return { | ||||||
|  |         cover: coverPath, | ||||||
|  |         updated: false | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     // Cover path does not exist
 | ||||||
|  |     if (!await fs.pathExists(coverPath)) { | ||||||
|  |       Logger.error(`[CoverController] validate cover path does not exist "${coverPath}"`) | ||||||
|  |       return { | ||||||
|  |         error: 'Cover path does not exist' | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     // Check valid image at path
 | ||||||
|  |     var imgtype = await this.checkFileIsValidImage(coverPath, true) | ||||||
|  |     if (imgtype.error) { | ||||||
|  |       return imgtype | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var coverDirPath = this.getCoverDirectory(libraryItem) | ||||||
|  | 
 | ||||||
|  |     // Cover path is not in correct directory - make a copy
 | ||||||
|  |     if (!coverPath.startsWith(coverDirPath)) { | ||||||
|  |       await fs.ensureDir(coverDirPath) | ||||||
|  | 
 | ||||||
|  |       var coverFilename = `cover.${imgtype.ext}` | ||||||
|  |       var newCoverPath = Path.posix.join(coverDirPath, coverFilename) | ||||||
|  |       Logger.debug(`[CoverController] validate cover path copy cover from "${coverPath}" to "${newCoverPath}"`) | ||||||
|  | 
 | ||||||
|  |       var copySuccess = await fs.copy(coverPath, newCoverPath, { overwrite: true }).then(() => true).catch((error) => { | ||||||
|  |         Logger.error(`[CoverController] validate cover path failed to copy cover`, error) | ||||||
|  |         return false | ||||||
|  |       }) | ||||||
|  |       if (!copySuccess) { | ||||||
|  |         return { | ||||||
|  |           error: 'Failed to copy cover to dir' | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       await filePerms.setDefault(newCoverPath) | ||||||
|  |       await this.removeOldCovers(coverDirPath, '.' + imgtype.ext) | ||||||
|  |       Logger.debug(`[CoverController] cover copy success`) | ||||||
|  |       coverPath = newCoverPath | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     await this.cacheManager.purgeCoverCache(libraryItem.id) | ||||||
|  | 
 | ||||||
|  |     libraryItem.updateMediaCover(coverPath) | ||||||
|  |     return { | ||||||
|  |       cover: coverPath, | ||||||
|  |       updated: true | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async saveEmbeddedCoverArt(libraryItem) { | ||||||
|  |     var audioFileWithCover = libraryItem.media.audioFiles.find(af => af.embeddedCoverArt) | ||||||
|  |     if (!audioFileWithCover) return false | ||||||
|  | 
 | ||||||
|  |     var coverDirPath = this.getCoverDirectory(libraryItem) | ||||||
|  |     await fs.ensureDir(coverDirPath) | ||||||
|  | 
 | ||||||
|  |     var coverFilename = audioFileWithCover.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg' | ||||||
|  |     var coverFilePath = Path.join(coverDirPath, coverFilename) | ||||||
|  | 
 | ||||||
|  |     var coverAlreadyExists = await fs.pathExists(coverFilePath) | ||||||
|  |     if (coverAlreadyExists) { | ||||||
|  |       Logger.warn(`[Audiobook] Extract embedded cover art but cover already exists for "${libraryItem.media.metadata.title}" - bail`) | ||||||
|  |       return false | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath) | ||||||
|  |     if (success) { | ||||||
|  |       libraryItem.updateMediaCover(coverFilePath) | ||||||
|  |       return coverFilePath | ||||||
|  |     } | ||||||
|  |     return false | ||||||
|  |   } | ||||||
|  | 
 | ||||||
| } | } | ||||||
| module.exports = CoverController | module.exports = CoverController | ||||||
							
								
								
									
										49
									
								
								server/Db.js
									
									
									
									
									
								
							
							
						
						
									
										49
									
								
								server/Db.js
									
									
									
									
									
								
							| @ -185,19 +185,56 @@ class Db { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async updateLibraryItem(libraryItem) { |   async updateLibraryItem(libraryItem) { | ||||||
|     if (libraryItem && libraryItem.saveMetadata) { |     return this.updateLibraryItems([libraryItem]) | ||||||
|       await libraryItem.saveMetadata() |   } | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     return this.libraryItemsDb.update((record) => record.id === libraryItem.id, () => libraryItem).then((results) => { |   async updateLibraryItems(libraryItems) { | ||||||
|       Logger.debug(`[DB] Library Item updated ${results.updated}`) |     await Promise.all(libraryItems.map(async (li) => { | ||||||
|  |       if (li && li.saveMetadata) return li.saveMetadata() | ||||||
|  |       return null | ||||||
|  |     })) | ||||||
|  | 
 | ||||||
|  |     var libraryItemIds = libraryItems.map(li => li.id) | ||||||
|  |     return this.libraryItemsDb.update((record) => libraryItemIds.includes(record.id), (record) => { | ||||||
|  |       return libraryItems.find(li => li.id === record.id) | ||||||
|  |     }).then((results) => { | ||||||
|  |       Logger.debug(`[DB] Library Items updated ${results.updated}`) | ||||||
|       return true |       return true | ||||||
|     }).catch((error) => { |     }).catch((error) => { | ||||||
|       Logger.error(`[DB] Library Item update failed ${error}`) |       Logger.error(`[DB] Library Items update failed ${error}`) | ||||||
|       return false |       return false | ||||||
|     }) |     }) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async insertLibraryItem(libraryItem) { | ||||||
|  |     return this.insertLibraryItems([libraryItem]) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async insertLibraryItems(libraryItems) { | ||||||
|  |     await Promise.all(libraryItems.map(async (li) => { | ||||||
|  |       if (li && li.saveMetadata) return li.saveMetadata() | ||||||
|  |       return null | ||||||
|  |     })) | ||||||
|  | 
 | ||||||
|  |     return this.libraryItemsDb.insert(libraryItems).then((results) => { | ||||||
|  |       Logger.debug(`[DB] Library Items inserted ${results.inserted}`) | ||||||
|  |       this.libraryItems = this.libraryItems.concat(libraryItems) | ||||||
|  |       return true | ||||||
|  |     }).catch((error) => { | ||||||
|  |       Logger.error(`[DB] Library Items insert failed ${error}`) | ||||||
|  |       return false | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   removeLibraryItem(id) { | ||||||
|  |     return this.libraryItemsDb.delete((record) => record.id === id).then((results) => { | ||||||
|  |       Logger.debug(`[DB] Deleted Library Items: ${results.deleted}`) | ||||||
|  |       this.libraryItems = this.libraryItems.filter(li => li.id !== id) | ||||||
|  |     }).catch((error) => { | ||||||
|  |       Logger.error(`[DB] Remove Library Items Failed: ${error}`) | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   async updateAudiobook(audiobook) { |   async updateAudiobook(audiobook) { | ||||||
|     if (audiobook && audiobook.saveAbMetadata) { |     if (audiobook && audiobook.saveAbMetadata) { | ||||||
|       // TODO: Book may have updates where this save is not necessary
 |       // TODO: Book may have updates where this save is not necessary
 | ||||||
|  | |||||||
| @ -11,9 +11,7 @@ const { writeConcatFile, writeMetadataFile } = require('./utils/ffmpegHelpers') | |||||||
| const { getFileSize } = require('./utils/fileUtils') | const { getFileSize } = require('./utils/fileUtils') | ||||||
| const TAG = 'DownloadManager' | const TAG = 'DownloadManager' | ||||||
| class DownloadManager { | class DownloadManager { | ||||||
|   constructor(db, Uid, Gid) { |   constructor(db) { | ||||||
|     this.Uid = Uid |  | ||||||
|     this.Gid = Gid |  | ||||||
|     this.db = db |     this.db = db | ||||||
| 
 | 
 | ||||||
|     this.downloadDirPath = Path.join(global.MetadataPath, 'downloads') |     this.downloadDirPath = Path.join(global.MetadataPath, 'downloads') | ||||||
| @ -344,7 +342,7 @@ class DownloadManager { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // Set file permissions and ownership
 |     // Set file permissions and ownership
 | ||||||
|     await filePerms(download.fullPath, 0o774, this.Uid, this.Gid) |     await filePerms.setDefault(download.fullPath) | ||||||
| 
 | 
 | ||||||
|     var filesize = await getFileSize(download.fullPath) |     var filesize = await getFileSize(download.fullPath) | ||||||
|     download.setComplete(filesize) |     download.setComplete(filesize) | ||||||
|  | |||||||
| @ -32,11 +32,9 @@ const CacheManager = require('./CacheManager') | |||||||
| class Server { | class Server { | ||||||
|   constructor(PORT, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) { |   constructor(PORT, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) { | ||||||
|     this.Port = PORT |     this.Port = PORT | ||||||
|     this.Uid = isNaN(UID) ? 0 : Number(UID) |  | ||||||
|     this.Gid = isNaN(GID) ? 0 : Number(GID) |  | ||||||
|     this.Host = '0.0.0.0' |     this.Host = '0.0.0.0' | ||||||
|     global.Uid = this.Uid |     global.Uid = isNaN(UID) ? 0 : Number(UID) | ||||||
|     global.Gid = this.Gid |     global.Gid = isNaN(GID) ? 0 : Number(GID) | ||||||
|     global.ConfigPath = Path.normalize(CONFIG_PATH) |     global.ConfigPath = Path.normalize(CONFIG_PATH) | ||||||
|     global.AudiobookPath = Path.normalize(AUDIOBOOK_PATH) |     global.AudiobookPath = Path.normalize(AUDIOBOOK_PATH) | ||||||
|     global.MetadataPath = Path.normalize(METADATA_PATH) |     global.MetadataPath = Path.normalize(METADATA_PATH) | ||||||
| @ -53,7 +51,7 @@ class Server { | |||||||
| 
 | 
 | ||||||
|     this.db = new Db() |     this.db = new Db() | ||||||
|     this.auth = new Auth(this.db) |     this.auth = new Auth(this.db) | ||||||
|     this.backupManager = new BackupManager(this.Uid, this.Gid, this.db) |     this.backupManager = new BackupManager(this.db) | ||||||
|     this.logManager = new LogManager(this.db) |     this.logManager = new LogManager(this.db) | ||||||
|     this.cacheManager = new CacheManager() |     this.cacheManager = new CacheManager() | ||||||
|     this.watcher = new Watcher() |     this.watcher = new Watcher() | ||||||
| @ -61,7 +59,7 @@ class Server { | |||||||
|     this.scanner = new Scanner(this.db, this.coverController, this.emitter.bind(this)) |     this.scanner = new Scanner(this.db, this.coverController, this.emitter.bind(this)) | ||||||
| 
 | 
 | ||||||
|     this.streamManager = new StreamManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this)) |     this.streamManager = new StreamManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this)) | ||||||
|     this.downloadManager = new DownloadManager(this.db, this.Uid, this.Gid) |     this.downloadManager = new DownloadManager(this.db) | ||||||
|     this.apiController = new ApiController(this.db, this.auth, this.scanner, this.streamManager, this.downloadManager, this.coverController, this.backupManager, this.watcher, this.cacheManager, this.emitter.bind(this), this.clientEmitter.bind(this)) |     this.apiController = new ApiController(this.db, this.auth, this.scanner, this.streamManager, this.downloadManager, this.coverController, this.backupManager, this.watcher, this.cacheManager, this.emitter.bind(this), this.clientEmitter.bind(this)) | ||||||
|     this.hlsController = new HlsController(this.db, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath) |     this.hlsController = new HlsController(this.db, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath) | ||||||
| 
 | 
 | ||||||
| @ -268,8 +266,8 @@ class Server { | |||||||
|       // Scanning
 |       // Scanning
 | ||||||
|       socket.on('scan', this.scan.bind(this)) |       socket.on('scan', this.scan.bind(this)) | ||||||
|       socket.on('cancel_scan', this.cancelScan.bind(this)) |       socket.on('cancel_scan', this.cancelScan.bind(this)) | ||||||
|       socket.on('scan_audiobook', (audiobookId) => this.scanAudiobook(socket, audiobookId)) |       socket.on('scan_item', (libraryItemId) => this.scanLibraryItem(socket, libraryItemId)) | ||||||
|       socket.on('save_metadata', (audiobookId) => this.saveMetadata(socket, audiobookId)) |       socket.on('save_metadata', (libraryItemId) => this.saveMetadata(socket, libraryItemId)) | ||||||
| 
 | 
 | ||||||
|       // Streaming (only still used in the mobile app)
 |       // Streaming (only still used in the mobile app)
 | ||||||
|       socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId)) |       socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId)) | ||||||
| @ -329,15 +327,15 @@ class Server { | |||||||
|     Logger.info('[Server] Scan complete') |     Logger.info('[Server] Scan complete') | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async scanAudiobook(socket, audiobookId) { |   async scanLibraryItem(socket, libraryItemId) { | ||||||
|     var result = await this.scanner.scanAudiobookById(audiobookId) |     var result = await this.scanner.scanLibraryItemById(libraryItemId) | ||||||
|     var scanResultName = '' |     var scanResultName = '' | ||||||
|     for (const key in ScanResult) { |     for (const key in ScanResult) { | ||||||
|       if (ScanResult[key] === result) { |       if (ScanResult[key] === result) { | ||||||
|         scanResultName = key |         scanResultName = key | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     socket.emit('audiobook_scan_complete', scanResultName) |     socket.emit('item_scan_complete', scanResultName) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   cancelScan(id) { |   cancelScan(id) { | ||||||
| @ -459,8 +457,7 @@ class Server { | |||||||
|       }) |       }) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     Logger.info(`[Server] Setting owner/perms for first dir "${firstDirPath}"`) |     await filePerms.setDefault(firstDirPath) | ||||||
|     await filePerms(firstDirPath, 0o774, this.Uid, this.Gid) |  | ||||||
| 
 | 
 | ||||||
|     res.sendStatus(200) |     res.sendStatus(200) | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -57,12 +57,12 @@ class LibraryController { | |||||||
|       // Update watcher
 |       // Update watcher
 | ||||||
|       this.watcher.updateLibrary(library) |       this.watcher.updateLibrary(library) | ||||||
| 
 | 
 | ||||||
|       // Remove audiobooks no longer in library
 |       // Remove libraryItems no longer in library
 | ||||||
|       var audiobooksToRemove = this.db.audiobooks.filter(ab => ab.libraryId === library.id && !library.checkFullPathInLibrary(ab.fullPath)) |       var itemsToRemove = this.db.libraryItems.filter(li => li.libraryId === library.id && !library.checkFullPathInLibrary(li.path)) | ||||||
|       if (audiobooksToRemove.length) { |       if (itemsToRemove.length) { | ||||||
|         Logger.info(`[Scanner] Updating library, removing ${audiobooksToRemove.length} audiobooks`) |         Logger.info(`[Scanner] Updating library, removing ${itemsToRemove.length} items`) | ||||||
|         for (let i = 0; i < audiobooksToRemove.length; i++) { |         for (let i = 0; i < itemsToRemove.length; i++) { | ||||||
|           await this.handleDeleteAudiobook(audiobooksToRemove[i]) |           await this.handleDeleteLibraryItem(itemsToRemove[i]) | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|       await this.db.updateEntity('library', library) |       await this.db.updateEntity('library', library) | ||||||
| @ -77,11 +77,11 @@ class LibraryController { | |||||||
|     // Remove library watcher
 |     // Remove library watcher
 | ||||||
|     this.watcher.removeLibrary(library) |     this.watcher.removeLibrary(library) | ||||||
| 
 | 
 | ||||||
|     // Remove audiobooks in this library
 |     // Remove items in this library
 | ||||||
|     var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === library.id) |     var libraryItems = this.db.libraryItems.filter(li => li.libraryId === library.id) | ||||||
|     Logger.info(`[Server] deleting library "${library.name}" with ${audiobooks.length} audiobooks"`) |     Logger.info(`[Server] deleting library "${library.name}" with ${libraryItems.length} items"`) | ||||||
|     for (let i = 0; i < audiobooks.length; i++) { |     for (let i = 0; i < libraryItems.length; i++) { | ||||||
|       await this.handleDeleteAudiobook(audiobooks[i]) |       await this.handleDeleteLibraryItem(libraryItems[i]) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     var libraryJson = library.toJSON() |     var libraryJson = library.toJSON() | ||||||
| @ -91,7 +91,7 @@ class LibraryController { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   // api/libraries/:id/items
 |   // api/libraries/:id/items
 | ||||||
|   // TODO: Optimize this method, audiobooks are iterated through several times but can be combined
 |   // TODO: Optimize this method, items are iterated through several times but can be combined
 | ||||||
|   getLibraryItems(req, res) { |   getLibraryItems(req, res) { | ||||||
|     var libraryId = req.library.id |     var libraryId = req.library.id | ||||||
|     var media = req.query.media || 'all' |     var media = req.query.media || 'all' | ||||||
|  | |||||||
| @ -12,10 +12,6 @@ class LibraryItemController { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async update(req, res) { |   async update(req, res) { | ||||||
|     if (!req.user.canUpdate) { |  | ||||||
|       Logger.warn('User attempted to update without permission', req.user) |  | ||||||
|       return res.sendStatus(403) |  | ||||||
|     } |  | ||||||
|     var libraryItem = req.libraryItem |     var libraryItem = req.libraryItem | ||||||
|     // Item has cover and update is removing cover so purge it from cache
 |     // Item has cover and update is removing cover so purge it from cache
 | ||||||
|     if (libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) { |     if (libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) { | ||||||
| @ -31,15 +27,15 @@ class LibraryItemController { | |||||||
|     res.json(libraryItem.toJSON()) |     res.json(libraryItem.toJSON()) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async delete(req, res) { | ||||||
|  |     await this.handleDeleteLibraryItem(req.libraryItem) | ||||||
|  |     res.sendStatus(200) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   //
 |   //
 | ||||||
|   // PATCH: will create new authors & series if in payload
 |   // PATCH: will create new authors & series if in payload
 | ||||||
|   //
 |   //
 | ||||||
|   async updateMedia(req, res) { |   async updateMedia(req, res) { | ||||||
|     if (!req.user.canUpdate) { |  | ||||||
|       Logger.warn('User attempted to update without permission', req.user) |  | ||||||
|       return res.sendStatus(403) |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     var libraryItem = req.libraryItem |     var libraryItem = req.libraryItem | ||||||
|     var mediaPayload = req.body |     var mediaPayload = req.body | ||||||
|     // Item has cover and update is removing cover so purge it from cache
 |     // Item has cover and update is removing cover so purge it from cache
 | ||||||
| @ -100,6 +96,75 @@ class LibraryItemController { | |||||||
|     res.json(libraryItem) |     res.json(libraryItem) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   // POST: api/items/:id/cover
 | ||||||
|  |   async uploadCover(req, res) { | ||||||
|  |     if (!req.user.canUpload || !req.user.canUpdate) { | ||||||
|  |       Logger.warn('User attempted to upload a cover without permission', req.user) | ||||||
|  |       return res.sendStatus(403) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var libraryItem = req.libraryItem | ||||||
|  | 
 | ||||||
|  |     var result = null | ||||||
|  |     if (req.body && req.body.url) { | ||||||
|  |       Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`) | ||||||
|  |       result = await this.coverController.downloadCoverFromUrl(libraryItem, req.body.url) | ||||||
|  |     } else if (req.files && req.files.cover) { | ||||||
|  |       Logger.debug(`[LibraryItemController] Handling uploaded cover`) | ||||||
|  |       result = await this.coverController.uploadCover(libraryItem, req.files.cover) | ||||||
|  |     } else { | ||||||
|  |       return res.status(400).send('Invalid request no file or url') | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (result && result.error) { | ||||||
|  |       return res.status(400).send(result.error) | ||||||
|  |     } else if (!result || !result.cover) { | ||||||
|  |       return res.status(500).send('Unknown error occurred') | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     await this.db.updateLibraryItem(libraryItem) | ||||||
|  |     this.emitter('item_updated', libraryItem.toJSONExpanded()) | ||||||
|  |     res.json({ | ||||||
|  |       success: true, | ||||||
|  |       cover: result.cover | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // PATCH: api/items/:id/cover
 | ||||||
|  |   async updateCover(req, res) { | ||||||
|  |     var libraryItem = req.libraryItem | ||||||
|  |     if (!req.body.cover) { | ||||||
|  |       return res.status(400).error('Invalid request no cover path') | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var validationResult = await this.coverController.validateCoverPath(req.body.cover, libraryItem) | ||||||
|  |     if (validationResult.error) { | ||||||
|  |       return res.status(500).send(validationResult.error) | ||||||
|  |     } | ||||||
|  |     if (validationResult.updated) { | ||||||
|  |       await this.db.updateLibraryItem(libraryItem) | ||||||
|  |       this.emitter('item_updated', libraryItem.toJSONExpanded()) | ||||||
|  |     } | ||||||
|  |     res.json({ | ||||||
|  |       success: true, | ||||||
|  |       cover: validationResult.cover | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // DELETE: api/items/:id/cover
 | ||||||
|  |   async removeCover(req, res) { | ||||||
|  |     var libraryItem = req.libraryItem | ||||||
|  | 
 | ||||||
|  |     if (libraryItem.media.coverPath) { | ||||||
|  |       libraryItem.updateMediaCover('') | ||||||
|  |       await this.cacheManager.purgeCoverCache(libraryItem.id) | ||||||
|  |       await this.db.updateLibraryItem(libraryItem) | ||||||
|  |       this.emitter('item_updated', libraryItem.toJSONExpanded()) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     res.sendStatus(200) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   // GET api/items/:id/cover
 |   // GET api/items/:id/cover
 | ||||||
|   async getCover(req, res) { |   async getCover(req, res) { | ||||||
|     let { query: { width, height, format }, libraryItem } = req |     let { query: { width, height, format }, libraryItem } = req | ||||||
| @ -114,13 +179,21 @@ class LibraryItemController { | |||||||
| 
 | 
 | ||||||
|   middleware(req, res, next) { |   middleware(req, res, next) { | ||||||
|     var item = this.db.libraryItems.find(li => li.id === req.params.id) |     var item = this.db.libraryItems.find(li => li.id === req.params.id) | ||||||
|     if (!item || !item.media || !item.media.coverPath) return res.sendStatus(404) |     if (!item || !item.media) return res.sendStatus(404) | ||||||
| 
 | 
 | ||||||
|     // Check user can access this audiobooks library
 |     // Check user can access this audiobooks library
 | ||||||
|     if (!req.user.checkCanAccessLibrary(item.libraryId)) { |     if (!req.user.checkCanAccessLibrary(item.libraryId)) { | ||||||
|       return res.sendStatus(403) |       return res.sendStatus(403) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     if (req.method == 'DELETE' && !req.user.canDelete) { | ||||||
|  |       Logger.warn(`[LibraryItemController] User attempted to delete without permission`, req.user) | ||||||
|  |       return res.sendStatus(403) | ||||||
|  |     } else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) { | ||||||
|  |       Logger.warn('[LibraryItemController] User attempted to update without permission', req.user) | ||||||
|  |       return res.sendStatus(403) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     req.libraryItem = item |     req.libraryItem = item | ||||||
|     next() |     next() | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -1,3 +1,4 @@ | |||||||
|  | const { version } = require('../../package.json') | ||||||
| const Logger = require('../Logger') | const Logger = require('../Logger') | ||||||
| const LibraryFile = require('./files/LibraryFile') | const LibraryFile = require('./files/LibraryFile') | ||||||
| const Book = require('./entities/Book') | const Book = require('./entities/Book') | ||||||
| @ -22,8 +23,10 @@ class LibraryItem { | |||||||
|     this.lastScan = null |     this.lastScan = null | ||||||
|     this.scanVersion = null |     this.scanVersion = null | ||||||
| 
 | 
 | ||||||
|     // Entity was scanned and not found
 |     // Was scanned and no longer exists
 | ||||||
|     this.isMissing = false |     this.isMissing = false | ||||||
|  |     // Was scanned and no longer has media files
 | ||||||
|  |     this.isInvalid = false | ||||||
| 
 | 
 | ||||||
|     this.mediaType = null |     this.mediaType = null | ||||||
|     this.media = null |     this.media = null | ||||||
| @ -51,6 +54,7 @@ class LibraryItem { | |||||||
|     this.scanVersion = libraryItem.scanVersion || null |     this.scanVersion = libraryItem.scanVersion || null | ||||||
| 
 | 
 | ||||||
|     this.isMissing = !!libraryItem.isMissing |     this.isMissing = !!libraryItem.isMissing | ||||||
|  |     this.isInvalid = !!libraryItem.isInvalid | ||||||
| 
 | 
 | ||||||
|     this.mediaType = libraryItem.mediaType |     this.mediaType = libraryItem.mediaType | ||||||
|     if (this.mediaType === 'book') { |     if (this.mediaType === 'book') { | ||||||
| @ -78,6 +82,7 @@ class LibraryItem { | |||||||
|       lastScan: this.lastScan, |       lastScan: this.lastScan, | ||||||
|       scanVersion: this.scanVersion, |       scanVersion: this.scanVersion, | ||||||
|       isMissing: !!this.isMissing, |       isMissing: !!this.isMissing, | ||||||
|  |       isInvalid: !!this.isInvalid, | ||||||
|       mediaType: this.mediaType, |       mediaType: this.mediaType, | ||||||
|       media: this.media.toJSON(), |       media: this.media.toJSON(), | ||||||
|       libraryFiles: this.libraryFiles.map(f => f.toJSON()) |       libraryFiles: this.libraryFiles.map(f => f.toJSON()) | ||||||
| @ -98,6 +103,7 @@ class LibraryItem { | |||||||
|       addedAt: this.addedAt, |       addedAt: this.addedAt, | ||||||
|       updatedAt: this.updatedAt, |       updatedAt: this.updatedAt, | ||||||
|       isMissing: !!this.isMissing, |       isMissing: !!this.isMissing, | ||||||
|  |       isInvalid: !!this.isInvalid, | ||||||
|       mediaType: this.mediaType, |       mediaType: this.mediaType, | ||||||
|       media: this.media.toJSONMinified(), |       media: this.media.toJSONMinified(), | ||||||
|       numFiles: this.libraryFiles.length |       numFiles: this.libraryFiles.length | ||||||
| @ -121,6 +127,7 @@ class LibraryItem { | |||||||
|       lastScan: this.lastScan, |       lastScan: this.lastScan, | ||||||
|       scanVersion: this.scanVersion, |       scanVersion: this.scanVersion, | ||||||
|       isMissing: !!this.isMissing, |       isMissing: !!this.isMissing, | ||||||
|  |       isInvalid: !!this.isInvalid, | ||||||
|       mediaType: this.mediaType, |       mediaType: this.mediaType, | ||||||
|       media: this.media.toJSONExpanded(), |       media: this.media.toJSONExpanded(), | ||||||
|       libraryFiles: this.libraryFiles.map(f => f.toJSON()), |       libraryFiles: this.libraryFiles.map(f => f.toJSON()), | ||||||
| @ -133,6 +140,42 @@ class LibraryItem { | |||||||
|     this.libraryFiles.forEach((lf) => total += lf.metadata.size) |     this.libraryFiles.forEach((lf) => total += lf.metadata.size) | ||||||
|     return total |     return total | ||||||
|   } |   } | ||||||
|  |   get hasAudioFiles() { | ||||||
|  |     return this.libraryFiles.some(lf => lf.fileType === 'audio') | ||||||
|  |   } | ||||||
|  |   get hasMediaFiles() { | ||||||
|  |     return this.media.hasMediaFiles | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // Data comes from scandir library item data
 | ||||||
|  |   setData(libraryMediaType, payload) { | ||||||
|  |     if (libraryMediaType === 'podcast') { | ||||||
|  |       this.mediaType = 'podcast' | ||||||
|  |       this.media = new Podcast() | ||||||
|  |     } else { | ||||||
|  |       this.mediaType = 'book' | ||||||
|  |       this.media = new Book() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     for (const key in payload) { | ||||||
|  |       if (key === 'libraryFiles') { | ||||||
|  |         this.libraryFiles = payload.libraryFiles.map(lf => lf.clone()) | ||||||
|  | 
 | ||||||
|  |         // Use first image library file as cover
 | ||||||
|  |         var firstImageFile = this.libraryFiles.find(lf => lf.fileType === 'image') | ||||||
|  |         if (firstImageFile) this.media.coverPath = firstImageFile.metadata.path | ||||||
|  |       } else if (this[key] !== undefined) { | ||||||
|  |         this[key] = payload[key] | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (payload.mediaMetadata) { | ||||||
|  |       this.media.setData(payload.mediaMetadata) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     this.addedAt = Date.now() | ||||||
|  |     this.updatedAt = Date.now() | ||||||
|  |   } | ||||||
| 
 | 
 | ||||||
|   update(payload) { |   update(payload) { | ||||||
|     var json = this.toJSON() |     var json = this.toJSON() | ||||||
| @ -149,7 +192,214 @@ class LibraryItem { | |||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |     if (hasUpdates) { | ||||||
|  |       this.updatedAt = Date.now() | ||||||
|  |     } | ||||||
|     return hasUpdates |     return hasUpdates | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   updateMediaCover(coverPath) { | ||||||
|  |     this.media.updateCover(coverPath) | ||||||
|  |     this.updatedAt = Date.now() | ||||||
|  |     return true | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   setMissing() { | ||||||
|  |     this.isMissing = true | ||||||
|  |     this.updatedAt = Date.now() | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   setInvalid() { | ||||||
|  |     this.isInvalid = true | ||||||
|  |     this.updatedAt = Date.now() | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   setLastScan() { | ||||||
|  |     this.lastScan = Date.now() | ||||||
|  |     this.scanVersion = version | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   saveMetadata() { } | ||||||
|  | 
 | ||||||
|  |   // Returns null if file not found, true if file was updated, false if up to date
 | ||||||
|  |   checkFileFound(fileFound) { | ||||||
|  |     var hasUpdated = false | ||||||
|  | 
 | ||||||
|  |     var existingFile = this.libraryFiles.find(lf => lf.ino === fileFound.ino) | ||||||
|  |     var mediaFile = null | ||||||
|  |     if (!existingFile) { | ||||||
|  |       existingFile = this.libraryFiles.find(lf => lf.metadata.path === fileFound.metadata.path) | ||||||
|  |       if (existingFile) { | ||||||
|  |         // Update media file ino
 | ||||||
|  |         mediaFile = this.media.findFileWithInode(existingFile.ino) | ||||||
|  |         if (mediaFile) { | ||||||
|  |           mediaFile.ino = fileFound.ino | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // file inode was updated
 | ||||||
|  |         existingFile.ino = fileFound.ino | ||||||
|  |         hasUpdated = true | ||||||
|  |       } else { | ||||||
|  |         // file not found
 | ||||||
|  |         return null | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       mediaFile = this.media.findFileWithInode(existingFile.ino) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (existingFile.metadata.path !== fileFound.metadata.path) { | ||||||
|  |       existingFile.metadata.path = fileFound.metadata.path | ||||||
|  |       existingFile.metadata.relPath = fileFound.metadata.relPath | ||||||
|  |       if (mediaFile) { | ||||||
|  |         mediaFile.metadata.path = fileFound.metadata.path | ||||||
|  |         mediaFile.metadata.relPath = fileFound.metadata.relPath | ||||||
|  |       } | ||||||
|  |       hasUpdated = true | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var keysToCheck = ['filename', 'ext', 'mtimeMs', 'ctimeMs', 'birthtimeMs', 'size'] | ||||||
|  |     keysToCheck.forEach((key) => { | ||||||
|  |       if (existingFile.metadata[key] !== fileFound.metadata[key]) { | ||||||
|  | 
 | ||||||
|  |         // Add modified flag on file data object if exists and was changed
 | ||||||
|  |         if (key === 'mtimeMs' && existingFile.metadata[key]) { | ||||||
|  |           fileFound.metadata.wasModified = true | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         existingFile.metadata[key] = fileFound.metadata[key] | ||||||
|  |         if (mediaFile) { | ||||||
|  |           if (key === 'mtimeMs') mediaFile.metadata.wasModified = true | ||||||
|  |           mediaFile.metadata[key] = fileFound.metadata[key] | ||||||
|  |         } | ||||||
|  |         hasUpdated = true | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  | 
 | ||||||
|  |     return hasUpdated | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // Data pulled from scandir during a scan, check it with current data
 | ||||||
|  |   checkScanData(dataFound) { | ||||||
|  |     var hasUpdated = false | ||||||
|  | 
 | ||||||
|  |     if (this.isMissing) { | ||||||
|  |       // Item no longer missing
 | ||||||
|  |       this.isMissing = false | ||||||
|  |       hasUpdated = true | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (dataFound.ino !== this.ino) { | ||||||
|  |       this.ino = dataFound.ino | ||||||
|  |       hasUpdated = true | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (dataFound.folderId !== this.folderId) { | ||||||
|  |       Logger.warn(`[LibraryItem] Check scan item changed folder ${this.folderId} -> ${dataFound.folderId}`) | ||||||
|  |       this.folderId = dataFound.folderId | ||||||
|  |       hasUpdated = true | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (dataFound.path !== this.path) { | ||||||
|  |       Logger.warn(`[LibraryItem] Check scan item changed path "${this.path}" -> "${dataFound.path}"`) | ||||||
|  |       this.path = dataFound.path | ||||||
|  |       this.relPath = dataFound.relPath | ||||||
|  |       hasUpdated = true | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var keysToCheck = ['mtimeMs', 'ctimeMs', 'birthtimeMs'] | ||||||
|  |     keysToCheck.forEach((key) => { | ||||||
|  |       if (dataFound[key] != this[key]) { | ||||||
|  |         this[key] = dataFound[key] || 0 | ||||||
|  |         hasUpdated = true | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  | 
 | ||||||
|  |     var newLibraryFiles = [] | ||||||
|  |     var existingLibraryFiles = [] | ||||||
|  | 
 | ||||||
|  |     dataFound.libraryFiles.forEach((lf) => { | ||||||
|  |       var fileFoundCheck = this.checkFileFound(lf, true) | ||||||
|  |       console.log('Check library file', fileFoundCheck, lf.metadata.filename) | ||||||
|  |       if (fileFoundCheck === null) { | ||||||
|  |         newLibraryFiles.push(lf) | ||||||
|  |       } else if (fileFoundCheck) { | ||||||
|  |         hasUpdated = true | ||||||
|  |         existingLibraryFiles.push(lf) | ||||||
|  |       } else { | ||||||
|  |         existingLibraryFiles.push(lf) | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  | 
 | ||||||
|  |     const filesRemoved = [] | ||||||
|  | 
 | ||||||
|  |     // Remove files not found (inodes will all be up to date at this point)
 | ||||||
|  |     this.libraryFiles = this.libraryFiles.filter(lf => { | ||||||
|  |       if (!dataFound.libraryFiles.find(_lf => _lf.ino === lf.ino)) { | ||||||
|  |         if (lf.metadata.path === this.media.coverPath) { | ||||||
|  |           Logger.debug(`[LibraryItem] "${this.media.metadata.title}" check scan cover removed`) | ||||||
|  |           this.media.updateCover('') | ||||||
|  |         } | ||||||
|  |         filesRemoved.push(lf.toJSON()) | ||||||
|  |         this.media.removeFileWithInode(lf.ino) | ||||||
|  |         return false | ||||||
|  |       } | ||||||
|  |       return true | ||||||
|  |     }) | ||||||
|  |     if (filesRemoved.length) { | ||||||
|  |       this.media.checkUpdateMissingTracks() | ||||||
|  |       hasUpdated = true | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Add library files to library item
 | ||||||
|  |     if (newLibraryFiles.length) { | ||||||
|  |       newLibraryFiles.forEach((lf) => this.libraryFiles.push(lf.clone())) | ||||||
|  |       hasUpdated = true | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // Check if invalid
 | ||||||
|  |     this.isInvalid = !this.media.hasMediaFiles | ||||||
|  | 
 | ||||||
|  |     // If cover path is in item folder, make sure libraryFile exists for it
 | ||||||
|  |     if (this.media.coverPath && this.media.coverPath.startsWith(this.path)) { | ||||||
|  |       var lf = this.libraryFiles.find(lf => lf.metadata.path === this.media.coverPath) | ||||||
|  |       if (!lf) { | ||||||
|  |         Logger.warn(`[LibraryItem] Invalid cover path - library file dne "${this.media.coverPath}"`) | ||||||
|  |         this.media.updateCover('') | ||||||
|  |         hasUpdated = true | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (hasUpdated) { | ||||||
|  |       this.setLastScan() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return { | ||||||
|  |       updated: hasUpdated, | ||||||
|  |       newLibraryFiles, | ||||||
|  |       filesRemoved, | ||||||
|  |       existingLibraryFiles // Existing file data may get re-scanned if forceRescan is set
 | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // Set metadata from files
 | ||||||
|  |   async syncFiles(preferOpfMetadata) { | ||||||
|  |     var imageFiles = this.libraryFiles.filter(lf => lf.fileType === 'image') | ||||||
|  |     console.log('image files', imageFiles.length, 'has cover', this.media.coverPath) | ||||||
|  |     if (imageFiles.length && !this.media.coverPath) { | ||||||
|  |       this.media.coverPath = imageFiles[0].metadata.path | ||||||
|  |       Logger.debug('[LibraryItem] Set media cover path', this.media.coverPath) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var textMetadataFiles = this.libraryFiles.filter(lf => lf.fileType === 'metadata' || lf.fileType === 'text') | ||||||
|  |     if (!textMetadataFiles.length) { | ||||||
|  |       return false | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var hasUpdated = await this.media.syncMetadataFiles(textMetadataFiles, preferOpfMetadata) | ||||||
|  |     if (hasUpdated) { | ||||||
|  |       this.updatedAt = Date.now() | ||||||
|  |     } | ||||||
|  |     return hasUpdated | ||||||
|  |   } | ||||||
| } | } | ||||||
| module.exports = LibraryItem | module.exports = LibraryItem | ||||||
| @ -53,5 +53,10 @@ class Author { | |||||||
|     this.addedAt = Date.now() |     this.addedAt = Date.now() | ||||||
|     this.updatedAt = Date.now() |     this.updatedAt = Date.now() | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   checkNameEquals(name) { | ||||||
|  |     if (!name) return false | ||||||
|  |     return this.name.toLowerCase() == name.toLowerCase().trim() | ||||||
|  |   } | ||||||
| } | } | ||||||
| module.exports = Author | module.exports = Author | ||||||
| @ -2,7 +2,10 @@ const Logger = require('../../Logger') | |||||||
| const BookMetadata = require('../metadata/BookMetadata') | const BookMetadata = require('../metadata/BookMetadata') | ||||||
| const AudioFile = require('../files/AudioFile') | const AudioFile = require('../files/AudioFile') | ||||||
| const EBookFile = require('../files/EBookFile') | const EBookFile = require('../files/EBookFile') | ||||||
|  | const abmetadataGenerator = require('../../utils/abmetadataGenerator') | ||||||
| const { areEquivalent, copyValue } = require('../../utils/index') | const { areEquivalent, copyValue } = require('../../utils/index') | ||||||
|  | const { parseOpfMetadataXML } = require('../../utils/parseOpfMetadata') | ||||||
|  | const { readTextFile } = require('../../utils/fileUtils') | ||||||
| 
 | 
 | ||||||
| class Book { | class Book { | ||||||
|   constructor(book) { |   constructor(book) { | ||||||
| @ -13,6 +16,10 @@ class Book { | |||||||
|     this.audioFiles = [] |     this.audioFiles = [] | ||||||
|     this.ebookFiles = [] |     this.ebookFiles = [] | ||||||
|     this.chapters = [] |     this.chapters = [] | ||||||
|  |     this.missingParts = [] | ||||||
|  | 
 | ||||||
|  |     this.lastCoverSearch = null | ||||||
|  |     this.lastCoverSearchQuery = null | ||||||
| 
 | 
 | ||||||
|     if (book) { |     if (book) { | ||||||
|       this.construct(book) |       this.construct(book) | ||||||
| @ -26,6 +33,9 @@ class Book { | |||||||
|     this.audioFiles = book.audioFiles.map(f => new AudioFile(f)) |     this.audioFiles = book.audioFiles.map(f => new AudioFile(f)) | ||||||
|     this.ebookFiles = book.ebookFiles.map(f => new EBookFile(f)) |     this.ebookFiles = book.ebookFiles.map(f => new EBookFile(f)) | ||||||
|     this.chapters = book.chapters.map(c => ({ ...c })) |     this.chapters = book.chapters.map(c => ({ ...c })) | ||||||
|  |     this.missingParts = book.missingParts ? [...book.missingParts] : [] | ||||||
|  |     this.lastCoverSearch = book.lastCoverSearch || null | ||||||
|  |     this.lastCoverSearchQuery = book.lastCoverSearchQuery || null | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   toJSON() { |   toJSON() { | ||||||
| @ -35,7 +45,8 @@ class Book { | |||||||
|       tags: [...this.tags], |       tags: [...this.tags], | ||||||
|       audioFiles: this.audioFiles.map(f => f.toJSON()), |       audioFiles: this.audioFiles.map(f => f.toJSON()), | ||||||
|       ebookFiles: this.ebookFiles.map(f => f.toJSON()), |       ebookFiles: this.ebookFiles.map(f => f.toJSON()), | ||||||
|       chapters: this.chapters.map(c => ({ ...c })) |       chapters: this.chapters.map(c => ({ ...c })), | ||||||
|  |       missingParts: [...this.missingParts] | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -48,6 +59,7 @@ class Book { | |||||||
|       numAudioFiles: this.audioFiles.length, |       numAudioFiles: this.audioFiles.length, | ||||||
|       numEbooks: this.ebookFiles.length, |       numEbooks: this.ebookFiles.length, | ||||||
|       numChapters: this.chapters.length, |       numChapters: this.chapters.length, | ||||||
|  |       numMissingParts: this.missingParts.length, | ||||||
|       duration: this.duration, |       duration: this.duration, | ||||||
|       size: this.size |       size: this.size | ||||||
|     } |     } | ||||||
| @ -63,7 +75,8 @@ class Book { | |||||||
|       chapters: this.chapters.map(c => ({ ...c })), |       chapters: this.chapters.map(c => ({ ...c })), | ||||||
|       duration: this.duration, |       duration: this.duration, | ||||||
|       size: this.size, |       size: this.size, | ||||||
|       tracks: this.tracks.map(t => t.toJSON()) |       tracks: this.tracks.map(t => t.toJSON()), | ||||||
|  |       missingParts: [...this.missingParts] | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -80,6 +93,17 @@ class Book { | |||||||
|     this.audioFiles.forEach((af) => total += af.metadata.size) |     this.audioFiles.forEach((af) => total += af.metadata.size) | ||||||
|     return total |     return total | ||||||
|   } |   } | ||||||
|  |   get hasMediaFiles() { | ||||||
|  |     return !!(this.tracks.length + this.ebookFiles.length) | ||||||
|  |   } | ||||||
|  |   get shouldSearchForCover() { | ||||||
|  |     if (this.coverPath) return false | ||||||
|  |     if (!this.lastCoverSearch || this.metadata.coverSearchQuery !== this.lastCoverSearchQuery) return true | ||||||
|  |     return (Date.now() - this.lastCoverSearch) > 1000 * 60 * 60 * 24 * 7 // 7 day
 | ||||||
|  |   } | ||||||
|  |   get hasEmbeddedCoverArt() { | ||||||
|  |     return this.audioFiles.some(af => af.embeddedCoverArt) | ||||||
|  |   } | ||||||
| 
 | 
 | ||||||
|   update(payload) { |   update(payload) { | ||||||
|     var json = this.toJSON() |     var json = this.toJSON() | ||||||
| @ -99,5 +123,195 @@ class Book { | |||||||
|     } |     } | ||||||
|     return hasUpdates |     return hasUpdates | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   updateCover(coverPath) { | ||||||
|  |     coverPath = coverPath.replace(/\\/g, '/') | ||||||
|  |     if (this.coverPath === coverPath) return false | ||||||
|  |     this.coverPath = coverPath | ||||||
|  |     return true | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   checkUpdateMissingTracks() { | ||||||
|  |     var currMissingParts = (this.missingParts || []).join(',') || '' | ||||||
|  | 
 | ||||||
|  |     var current_index = 1 | ||||||
|  |     var missingParts = [] | ||||||
|  | 
 | ||||||
|  |     for (let i = 0; i < this.tracks.length; i++) { | ||||||
|  |       var _track = this.tracks[i] | ||||||
|  |       if (_track.index > current_index) { | ||||||
|  |         var num_parts_missing = _track.index - current_index | ||||||
|  |         for (let x = 0; x < num_parts_missing && x < 9999; x++) { | ||||||
|  |           missingParts.push(current_index + x) | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       current_index = _track.index + 1 | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     this.missingParts = missingParts | ||||||
|  | 
 | ||||||
|  |     var newMissingParts = (this.missingParts || []).join(',') || '' | ||||||
|  |     var wasUpdated = newMissingParts !== currMissingParts | ||||||
|  |     if (wasUpdated && this.missingParts.length) { | ||||||
|  |       Logger.info(`[Book] "${this.metadata.title}" has ${missingParts.length} missing parts`) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return wasUpdated | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   removeFileWithInode(inode) { | ||||||
|  |     if (this.audioFiles.some(af => af.ino === inode)) { | ||||||
|  |       this.audioFiles = this.audioFiles.filter(af => af.ino !== inode) | ||||||
|  |       return true | ||||||
|  |     } | ||||||
|  |     if (this.ebookFiles.some(ef => ef.ino === inode)) { | ||||||
|  |       this.ebookFiles = this.ebookFiles.filter(ef => ef.ino !== inode) | ||||||
|  |       return true | ||||||
|  |     } | ||||||
|  |     return false | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   findFileWithInode(inode) { | ||||||
|  |     var audioFile = this.audioFiles.find(af => af.ino == inode) | ||||||
|  |     if (audioFile) return audioFile | ||||||
|  |     var ebookFile = this.ebookFiles.find(ef => ef.inode == inode) | ||||||
|  |     if (ebookFile) return ebookFile | ||||||
|  |     return null | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   updateLastCoverSearch(coverWasFound) { | ||||||
|  |     this.lastCoverSearch = coverWasFound ? null : Date.now() | ||||||
|  |     this.lastCoverSearchQuery = coverWasFound ? null : this.metadata.coverSearchQuery | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // Audio file metadata tags map to book details (will not overwrite)
 | ||||||
|  |   setMetadataFromAudioFile(overrideExistingDetails = false) { | ||||||
|  |     if (!this.audioFiles.length) return false | ||||||
|  |     var audioFile = this.audioFiles[0] | ||||||
|  |     if (!audioFile.metaTags) return false | ||||||
|  |     return this.metadata.setDataFromAudioMetaTags(audioFile.metaTags, overrideExistingDetails) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   rebuildTracks() { | ||||||
|  |     this.audioFiles.sort((a, b) => a.index - b.index) | ||||||
|  |     this.missingParts = [] | ||||||
|  |     this.setChapters() | ||||||
|  |     this.checkUpdateMissingTracks() | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   setChapters() { | ||||||
|  |     // If 1 audio file without chapters, then no chapters will be set
 | ||||||
|  |     var includedAudioFiles = this.audioFiles.filter(af => !af.exclude) | ||||||
|  |     if (includedAudioFiles.length === 1) { | ||||||
|  |       // 1 audio file with chapters
 | ||||||
|  |       if (includedAudioFiles[0].chapters) { | ||||||
|  |         this.chapters = includedAudioFiles[0].chapters.map(c => ({ ...c })) | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       this.chapters = [] | ||||||
|  |       var currChapterId = 0 | ||||||
|  |       var currStartTime = 0 | ||||||
|  |       includedAudioFiles.forEach((file) => { | ||||||
|  |         // If audio file has chapters use chapters
 | ||||||
|  |         if (file.chapters && file.chapters.length) { | ||||||
|  |           file.chapters.forEach((chapter) => { | ||||||
|  |             var chapterDuration = chapter.end - chapter.start | ||||||
|  |             if (chapterDuration > 0) { | ||||||
|  |               var title = `Chapter ${currChapterId}` | ||||||
|  |               if (chapter.title) { | ||||||
|  |                 title += ` (${chapter.title})` | ||||||
|  |               } | ||||||
|  |               this.chapters.push({ | ||||||
|  |                 id: currChapterId++, | ||||||
|  |                 start: currStartTime, | ||||||
|  |                 end: currStartTime + chapterDuration, | ||||||
|  |                 title | ||||||
|  |               }) | ||||||
|  |               currStartTime += chapterDuration | ||||||
|  |             } | ||||||
|  |           }) | ||||||
|  |         } else if (file.duration) { | ||||||
|  |           // Otherwise just use track has chapter
 | ||||||
|  |           this.chapters.push({ | ||||||
|  |             id: currChapterId++, | ||||||
|  |             start: currStartTime, | ||||||
|  |             end: currStartTime + file.duration, | ||||||
|  |             title: file.metadata.filename ? Path.basename(file.metadata.filename, Path.extname(file.metadata.filename)) : `Chapter ${currChapterId}` | ||||||
|  |           }) | ||||||
|  |           currStartTime += file.duration | ||||||
|  |         } | ||||||
|  |       }) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   setData(scanMediaMetadata) { | ||||||
|  |     this.metadata = new BookMetadata() | ||||||
|  |     this.metadata.setData(scanMediaMetadata) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // Look for desc.txt, reader.txt, metadata.abs and opf file then update details if found
 | ||||||
|  |   async syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) { | ||||||
|  |     var metadataUpdatePayload = {} | ||||||
|  | 
 | ||||||
|  |     var descTxt = textMetadataFiles.find(lf => lf.metadata.filename === 'desc.txt') | ||||||
|  |     if (descTxt) { | ||||||
|  |       var descriptionText = await readTextFile(descTxt.metadata.path) | ||||||
|  |       if (descriptionText) { | ||||||
|  |         Logger.debug(`[Book] "${this.metadata.title}" found desc.txt updating description with "${descriptionText.slice(0, 20)}..."`) | ||||||
|  |         metadataUpdatePayload.description = descriptionText | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     var readerTxt = textMetadataFiles.find(lf => lf.metadata.filename === 'reader.txt') | ||||||
|  |     if (readerTxt) { | ||||||
|  |       var narratorText = await readTextFile(readerTxt.metadata.path) | ||||||
|  |       if (narratorText) { | ||||||
|  |         Logger.debug(`[Book] "${this.metadata.title}" found reader.txt updating narrator with "${narratorText}"`) | ||||||
|  |         metadataUpdatePayload.narrators = this.metadata.parseNarratorsTag(narratorText) | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // TODO: Implement metadata.abs
 | ||||||
|  |     var metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs') | ||||||
|  |     if (metadataAbs) { | ||||||
|  | 
 | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var metadataOpf = textMetadataFiles.find(lf => lf.isOPFFile || lf.metadata.filename === 'metadata.xml') | ||||||
|  |     if (metadataOpf) { | ||||||
|  |       var xmlText = await readTextFile(metadataOpf.metadata.path) | ||||||
|  |       if (xmlText) { | ||||||
|  |         var opfMetadata = await parseOpfMetadataXML(xmlText) | ||||||
|  |         if (opfMetadata) { | ||||||
|  |           for (const key in opfMetadata) { | ||||||
|  |             // Add genres only if genres are empty
 | ||||||
|  |             if (key === 'genres') { | ||||||
|  |               if (opfMetadata.genres.length && (!this.metadata.genres.length || opfMetadataOverrideDetails)) { | ||||||
|  |                 metadataUpdatePayload[key] = opfMetadata.genres | ||||||
|  |               } | ||||||
|  |             } else if (key === 'author') { | ||||||
|  |               if (opfMetadata.author && (!this.metadata.authors.length || opfMetadataOverrideDetails)) { | ||||||
|  |                 metadataUpdatePayload.authors = this.metadata.parseAuthorsTag(opfMetadata.author) | ||||||
|  |               } | ||||||
|  |             } else if (key === 'narrator') { | ||||||
|  |               if (opfMetadata.narrator && (!this.metadata.narrators.length || opfMetadataOverrideDetails)) { | ||||||
|  |                 metadataUpdatePayload.narrators = this.metadata.parseNarratorsTag(opfMetadata.narrator) | ||||||
|  |               } | ||||||
|  |             } else if (key === 'series') { | ||||||
|  |               if (opfMetadata.series && (!this.metadata.series.length || opfMetadataOverrideDetails)) { | ||||||
|  |                 metadataUpdatePayload.series = this.metadata.parseSeriesTag(opfMetadata.series, opfMetadata.sequence) | ||||||
|  |               } | ||||||
|  |             } else if (opfMetadata[key] && ((!this.metadata[key] && !metadataUpdatePayload[key]) || opfMetadataOverrideDetails)) { | ||||||
|  |               metadataUpdatePayload[key] = opfMetadata[key] | ||||||
|  |             } | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (Object.keys(metadataUpdatePayload).length) { | ||||||
|  |       return this.metadata.update(metadataUpdatePayload) | ||||||
|  |     } | ||||||
|  |     return false | ||||||
|  |   } | ||||||
| } | } | ||||||
| module.exports = Book | module.exports = Book | ||||||
| @ -1,5 +1,6 @@ | |||||||
| const PodcastEpisode = require('./PodcastEpisode') | const PodcastEpisode = require('./PodcastEpisode') | ||||||
| const PodcastMetadata = require('../metadata/PodcastMetadata') | const PodcastMetadata = require('../metadata/PodcastMetadata') | ||||||
|  | const { areEquivalent, copyValue } = require('../../utils/index') | ||||||
| 
 | 
 | ||||||
| class Podcast { | class Podcast { | ||||||
|   constructor(podcast) { |   constructor(podcast) { | ||||||
| @ -10,8 +11,8 @@ class Podcast { | |||||||
|     this.tags = [] |     this.tags = [] | ||||||
|     this.episodes = [] |     this.episodes = [] | ||||||
| 
 | 
 | ||||||
|     this.createdAt = null |     this.lastCoverSearch = null | ||||||
|     this.lastUpdate = null |     this.lastCoverSearchQuery = null | ||||||
| 
 | 
 | ||||||
|     if (podcast) { |     if (podcast) { | ||||||
|       this.construct(podcast) |       this.construct(podcast) | ||||||
| @ -24,8 +25,6 @@ class Podcast { | |||||||
|     this.coverPath = podcast.coverPath |     this.coverPath = podcast.coverPath | ||||||
|     this.tags = [...podcast.tags] |     this.tags = [...podcast.tags] | ||||||
|     this.episodes = podcast.episodes.map((e) => new PodcastEpisode(e)) |     this.episodes = podcast.episodes.map((e) => new PodcastEpisode(e)) | ||||||
|     this.createdAt = podcast.createdAt |  | ||||||
|     this.lastUpdate = podcast.lastUpdate |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   toJSON() { |   toJSON() { | ||||||
| @ -35,8 +34,6 @@ class Podcast { | |||||||
|       coverPath: this.coverPath, |       coverPath: this.coverPath, | ||||||
|       tags: [...this.tags], |       tags: [...this.tags], | ||||||
|       episodes: this.episodes.map(e => e.toJSON()), |       episodes: this.episodes.map(e => e.toJSON()), | ||||||
|       createdAt: this.createdAt, |  | ||||||
|       lastUpdate: this.lastUpdate |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -47,8 +44,7 @@ class Podcast { | |||||||
|       coverPath: this.coverPath, |       coverPath: this.coverPath, | ||||||
|       tags: [...this.tags], |       tags: [...this.tags], | ||||||
|       episodes: this.episodes.map(e => e.toJSON()), |       episodes: this.episodes.map(e => e.toJSON()), | ||||||
|       createdAt: this.createdAt, | 
 | ||||||
|       lastUpdate: this.lastUpdate |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -59,9 +55,74 @@ class Podcast { | |||||||
|       coverPath: this.coverPath, |       coverPath: this.coverPath, | ||||||
|       tags: [...this.tags], |       tags: [...this.tags], | ||||||
|       episodes: this.episodes.map(e => e.toJSON()), |       episodes: this.episodes.map(e => e.toJSON()), | ||||||
|       createdAt: this.createdAt, | 
 | ||||||
|       lastUpdate: this.lastUpdate |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   get tracks() { | ||||||
|  |     return [] | ||||||
|  |   } | ||||||
|  |   get duration() { | ||||||
|  |     return 0 | ||||||
|  |   } | ||||||
|  |   get size() { | ||||||
|  |     return 0 | ||||||
|  |   } | ||||||
|  |   get hasMediaFiles() { | ||||||
|  |     return !!this.episodes.length | ||||||
|  |   } | ||||||
|  |   get shouldSearchForCover() { | ||||||
|  |     return false | ||||||
|  |   } | ||||||
|  |   get hasEmbeddedCoverArt() { | ||||||
|  |     return false | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   update(payload) { | ||||||
|  |     var json = this.toJSON() | ||||||
|  |     var hasUpdates = false | ||||||
|  |     for (const key in json) { | ||||||
|  |       if (payload[key] !== undefined) { | ||||||
|  |         if (key === 'metadata') { | ||||||
|  |           if (this.metadata.update(payload.metadata)) { | ||||||
|  |             hasUpdates = true | ||||||
|  |           } | ||||||
|  |         } else if (!areEquivalent(payload[key], json[key])) { | ||||||
|  |           this[key] = copyValue(payload[key]) | ||||||
|  |           Logger.debug('[Podcast] Key updated', key, this[key]) | ||||||
|  |           hasUpdates = true | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return hasUpdates | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   updateCover(coverPath) { | ||||||
|  |     coverPath = coverPath.replace(/\\/g, '/') | ||||||
|  |     if (this.coverPath === coverPath) return false | ||||||
|  |     this.coverPath = coverPath | ||||||
|  |     return true | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   checkUpdateMissingTracks() { | ||||||
|  |     return false | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   removeFileWithInode(inode) { | ||||||
|  |     return false | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   findFileWithInode(inode) { | ||||||
|  |     return null | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   setData(scanMediaMetadata) { | ||||||
|  |     this.metadata = new PodcastMetadata() | ||||||
|  |     this.metadata.setData(scanMediaMetadata) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) { | ||||||
|  |     return false | ||||||
|  |   } | ||||||
| } | } | ||||||
| module.exports = Podcast | module.exports = Podcast | ||||||
| @ -42,5 +42,10 @@ class Series { | |||||||
|     this.addedAt = Date.now() |     this.addedAt = Date.now() | ||||||
|     this.updatedAt = Date.now() |     this.updatedAt = Date.now() | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   checkNameEquals(name) { | ||||||
|  |     if (!name) return false | ||||||
|  |     return this.name.toLowerCase() == name.toLowerCase().trim() | ||||||
|  |   } | ||||||
| } | } | ||||||
| module.exports = Series | module.exports = Series | ||||||
| @ -72,6 +72,9 @@ class AudioFile { | |||||||
|     this.index = data.index |     this.index = data.index | ||||||
|     this.ino = data.ino |     this.ino = data.ino | ||||||
|     this.metadata = new FileMetadata(data.metadata || {}) |     this.metadata = new FileMetadata(data.metadata || {}) | ||||||
|  |     if (!this.metadata.toJSON) { | ||||||
|  |       console.error('No metadata tojosnm\n\n\n\n\n\n', this) | ||||||
|  |     } | ||||||
|     this.addedAt = data.addedAt |     this.addedAt = data.addedAt | ||||||
|     this.updatedAt = data.updatedAt |     this.updatedAt = data.updatedAt | ||||||
|     this.manuallyVerified = !!data.manuallyVerified |     this.manuallyVerified = !!data.manuallyVerified | ||||||
| @ -101,19 +104,13 @@ class AudioFile { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   // New scanner creates AudioFile from AudioFileScanner
 |   // New scanner creates AudioFile from AudioFileScanner
 | ||||||
|   setDataFromProbe(fileData, probeData) { |   setDataFromProbe(libraryFile, probeData) { | ||||||
|     this.index = fileData.index || null |     this.ino = libraryFile.ino || null | ||||||
|     this.ino = fileData.ino || null |  | ||||||
| 
 | 
 | ||||||
|     // TODO: Update file metadata for set data from probe
 |     this.metadata = libraryFile.metadata.clone() | ||||||
|     this.addedAt = Date.now() |     this.addedAt = Date.now() | ||||||
|     this.updatedAt = Date.now() |     this.updatedAt = Date.now() | ||||||
| 
 | 
 | ||||||
|     this.trackNumFromMeta = fileData.trackNumFromMeta |  | ||||||
|     this.discNumFromMeta = fileData.discNumFromMeta |  | ||||||
|     this.trackNumFromFilename = fileData.trackNumFromFilename |  | ||||||
|     this.discNumFromFilename = fileData.discNumFromFilename |  | ||||||
| 
 |  | ||||||
|     this.format = probeData.format |     this.format = probeData.format | ||||||
|     this.duration = probeData.duration |     this.duration = probeData.duration | ||||||
|     this.bitRate = probeData.bitRate || null |     this.bitRate = probeData.bitRate || null | ||||||
| @ -196,9 +193,13 @@ class AudioFile { | |||||||
|     newjson.addedAt = this.addedAt |     newjson.addedAt = this.addedAt | ||||||
| 
 | 
 | ||||||
|     for (const key in newjson) { |     for (const key in newjson) { | ||||||
|       if (key === 'metaTags') { |       if (key === 'metadata') { | ||||||
|         if (!this.metaTags || !this.metaTags.isEqual(scannedAudioFile.metadata)) { |         if (this.metadata.update(newjson[key])) { | ||||||
|           this.metaTags = scannedAudioFile.metadata |           hasUpdated = true | ||||||
|  |         } | ||||||
|  |       } else if (key === 'metaTags') { | ||||||
|  |         if (!this.metaTags || !this.metaTags.isEqual(scannedAudioFile.metaTags)) { | ||||||
|  |           this.metaTags = scannedAudioFile.metaTags.clone() | ||||||
|           hasUpdated = true |           hasUpdated = true | ||||||
|         } |         } | ||||||
|       } else if (key === 'chapters') { |       } else if (key === 'chapters') { | ||||||
| @ -206,7 +207,6 @@ class AudioFile { | |||||||
|           hasUpdated = true |           hasUpdated = true | ||||||
|         } |         } | ||||||
|       } else if (this[key] !== newjson[key]) { |       } else if (this[key] !== newjson[key]) { | ||||||
|         // console.log(this.filename, 'key', key, 'updated', this[key], newjson[key])
 |  | ||||||
|         this[key] = newjson[key] |         this[key] = newjson[key] | ||||||
|         hasUpdated = true |         hasUpdated = true | ||||||
|       } |       } | ||||||
|  | |||||||
| @ -1,3 +1,5 @@ | |||||||
|  | const Path = require('path') | ||||||
|  | const { getFileTimestampsWithIno } = require('../../utils/fileUtils') | ||||||
| const globals = require('../../utils/globals') | const globals = require('../../utils/globals') | ||||||
| const FileMetadata = require('../metadata/FileMetadata') | const FileMetadata = require('../metadata/FileMetadata') | ||||||
| 
 | 
 | ||||||
| @ -30,6 +32,10 @@ class LibraryFile { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   clone() { | ||||||
|  |     return new LibraryFile(this.toJSON()) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   get fileType() { |   get fileType() { | ||||||
|     if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image' |     if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image' | ||||||
|     if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio' |     if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio' | ||||||
| @ -38,5 +44,27 @@ class LibraryFile { | |||||||
|     if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata' |     if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata' | ||||||
|     return 'unknown' |     return 'unknown' | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   get isMediaFile() { | ||||||
|  |     return this.fileType === 'audio' || this.fileType === 'ebook' | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   get isOPFFile() { | ||||||
|  |     return this.metadata.ext === '.opf' | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async setDataFromPath(path, relPath) { | ||||||
|  |     var fileTsData = await getFileTimestampsWithIno(path) | ||||||
|  |     var fileMetadata = new FileMetadata() | ||||||
|  |     fileMetadata.setData(fileTsData) | ||||||
|  |     fileMetadata.filename = Path.basename(relPath) | ||||||
|  |     fileMetadata.path = path | ||||||
|  |     fileMetadata.relPath = relPath | ||||||
|  |     fileMetadata.ext = Path.extname(relPath) | ||||||
|  |     this.ino = fileTsData.ino | ||||||
|  |     this.metadata = fileMetadata | ||||||
|  |     this.addedAt = Date.now() | ||||||
|  |     this.updatedAt = Date.now() | ||||||
|  |   } | ||||||
| } | } | ||||||
| module.exports = LibraryFile | module.exports = LibraryFile | ||||||
| @ -1,6 +1,6 @@ | |||||||
| const Path = require('path') | const Path = require('path') | ||||||
| const Logger = require('../../Logger') | const Logger = require('../../Logger') | ||||||
| const parseAuthors = require('../../utils/parseAuthors') | const parseAuthors = require('../../utils/parseNameString') | ||||||
| 
 | 
 | ||||||
| class Book { | class Book { | ||||||
|   constructor(book = null) { |   constructor(book = null) { | ||||||
|  | |||||||
| @ -118,6 +118,10 @@ class AudioMetaTags { | |||||||
|     return hasUpdates |     return hasUpdates | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   clone() { | ||||||
|  |     return new AudioMetaTags(this.toJSON()) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   isEqual(audioFileMetadata) { |   isEqual(audioFileMetadata) { | ||||||
|     if (!audioFileMetadata || !audioFileMetadata.toJSON) return false |     if (!audioFileMetadata || !audioFileMetadata.toJSON) return false | ||||||
|     for (const key in audioFileMetadata.toJSON()) { |     for (const key in audioFileMetadata.toJSON()) { | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| const Logger = require('../../Logger') | const Logger = require('../../Logger') | ||||||
| const { areEquivalent, copyValue } = require('../../utils/index') | const { areEquivalent, copyValue } = require('../../utils/index') | ||||||
| 
 | const parseNameString = require('../../utils/parseNameString') | ||||||
| class BookMetadata { | class BookMetadata { | ||||||
|   constructor(metadata) { |   constructor(metadata) { | ||||||
|     this.title = null |     this.title = null | ||||||
| @ -88,11 +88,16 @@ class BookMetadata { | |||||||
|     return this.title |     return this.title | ||||||
|   } |   } | ||||||
|   get authorName() { |   get authorName() { | ||||||
|  |     if (!this.authors.length) return '' | ||||||
|     return this.authors.map(au => au.name).join(', ') |     return this.authors.map(au => au.name).join(', ') | ||||||
|   } |   } | ||||||
|   get narratorName() { |   get narratorName() { | ||||||
|     return this.narrators.join(', ') |     return this.narrators.join(', ') | ||||||
|   } |   } | ||||||
|  |   get coverSearchQuery() { | ||||||
|  |     if (!this.authorName) return this.title | ||||||
|  |     return this.title + '&' + this.authorName | ||||||
|  |   } | ||||||
| 
 | 
 | ||||||
|   hasAuthor(authorName) { |   hasAuthor(authorName) { | ||||||
|     return !!this.authors.find(au => au.name == authorName) |     return !!this.authors.find(au => au.name == authorName) | ||||||
| @ -118,5 +123,150 @@ class BookMetadata { | |||||||
|     } |     } | ||||||
|     return hasUpdates |     return hasUpdates | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   setData(scanMediaData = {}) { | ||||||
|  |     this.title = scanMediaData.title || null | ||||||
|  |     this.subtitle = scanMediaData.subtitle || null | ||||||
|  |     this.narrators = [] | ||||||
|  |     this.publishYear = scanMediaData.publishYear || null | ||||||
|  |     this.description = scanMediaData.description || null | ||||||
|  |     this.isbn = scanMediaData.isbn || null | ||||||
|  |     this.asin = scanMediaData.asin || null | ||||||
|  |     this.language = scanMediaData.language || null | ||||||
|  |     this.genres = [] | ||||||
|  | 
 | ||||||
|  |     if (scanMediaData.author) { | ||||||
|  |       this.authors = this.parseAuthorsTag(scanMediaData.author) | ||||||
|  |     } | ||||||
|  |     if (scanMediaData.series) { | ||||||
|  |       this.series = this.parseSeriesTag(scanMediaData.series, scanMediaData.sequence) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) { | ||||||
|  |     const MetadataMapArray = [ | ||||||
|  |       { | ||||||
|  |         tag: 'tagComposer', | ||||||
|  |         key: 'narrators' | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         tag: 'tagDescription', | ||||||
|  |         key: 'description' | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         tag: 'tagPublisher', | ||||||
|  |         key: 'publisher' | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         tag: 'tagDate', | ||||||
|  |         key: 'publishYear' | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         tag: 'tagSubtitle', | ||||||
|  |         key: 'subtitle' | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         tag: 'tagAlbum', | ||||||
|  |         altTag: 'tagTitle', | ||||||
|  |         key: 'title', | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         tag: 'tagArtist', | ||||||
|  |         altTag: 'tagAlbumArtist', | ||||||
|  |         key: 'authors' | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         tag: 'tagGenre', | ||||||
|  |         key: 'genres' | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         tag: 'tagSeries', | ||||||
|  |         key: 'series' | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         tag: 'tagIsbn', | ||||||
|  |         key: 'isbn' | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         tag: 'tagLanguage', | ||||||
|  |         key: 'language' | ||||||
|  |       }, | ||||||
|  |       { | ||||||
|  |         tag: 'tagASIN', | ||||||
|  |         key: 'asin' | ||||||
|  |       } | ||||||
|  |     ] | ||||||
|  | 
 | ||||||
|  |     var updatePayload = {} | ||||||
|  | 
 | ||||||
|  |     // Metadata is only mapped to the book if it is empty
 | ||||||
|  |     MetadataMapArray.forEach((mapping) => { | ||||||
|  |       var value = audioFileMetaTags[mapping.tag] | ||||||
|  |       var tagToUse = mapping.tag | ||||||
|  |       if (!value && mapping.altTag) { | ||||||
|  |         value = audioFileMetaTags[mapping.altTag] | ||||||
|  |         tagToUse = mapping.altTag | ||||||
|  |       } | ||||||
|  |       if (value) { | ||||||
|  |         if (mapping.key === 'narrators' && (!this.narrators.length || overrideExistingDetails)) { | ||||||
|  |           updatePayload.narrators = this.parseNarratorsTag(value) | ||||||
|  |         } else if (mapping.key === 'authors' && (!this.authors.length || overrideExistingDetails)) { | ||||||
|  |           updatePayload.authors = this.parseAuthorsTag(value) | ||||||
|  |         } else if (mapping.key === 'genres' && (!this.genres.length || overrideExistingDetails)) { | ||||||
|  |           updatePayload.genres = this.parseGenresTag(value) | ||||||
|  |         } else if (mapping.key === 'series' && (!this.series.length || overrideExistingDetails)) { | ||||||
|  |           var sequenceTag = audioFileMetaTags.tagSeriesPart || null | ||||||
|  |           updatePayload.series = this.parseSeriesTag(value, sequenceTag) | ||||||
|  |         } else if (!this[mapping.key] || overrideExistingDetails) { | ||||||
|  |           updatePayload[mapping.key] = value | ||||||
|  |           // Logger.debug(`[Book] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload[mapping.key]}`)
 | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  | 
 | ||||||
|  |     if (Object.keys(updatePayload).length) { | ||||||
|  |       return this.update(updatePayload) | ||||||
|  |     } | ||||||
|  |     return false | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // Returns array of names in First Last format
 | ||||||
|  |   parseNarratorsTag(narratorsTag) { | ||||||
|  |     var parsed = parseNameString(narratorsTag) | ||||||
|  |     return parsed ? parsed.names : [] | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // Return array of authors minified with placeholder id
 | ||||||
|  |   parseAuthorsTag(authorsTag) { | ||||||
|  |     var parsed = parseNameString(authorsTag) | ||||||
|  |     if (!parsed) return [] | ||||||
|  |     return parsed.map((au) => { | ||||||
|  |       return { | ||||||
|  |         id: `new-${Math.floor(Math.random() * 1000000)}`, | ||||||
|  |         name: au | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   parseGenresTag(genreTag) { | ||||||
|  |     if (!genreTag || !genreTag.length) return [] | ||||||
|  |     var separators = ['/', '//', ';'] | ||||||
|  |     for (let i = 0; i < separators.length; i++) { | ||||||
|  |       if (genreTag.includes(separators[i])) { | ||||||
|  |         return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g) | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return [genreTag] | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // Return array with series with placeholder id
 | ||||||
|  |   parseSeriesTag(seriesTag, sequenceTag) { | ||||||
|  |     if (!seriesTag) return [] | ||||||
|  |     return [{ | ||||||
|  |       id: `new-${Math.floor(Math.random() * 1000000)}`, | ||||||
|  |       name: seriesTag, | ||||||
|  |       sequence: sequenceTag || '' | ||||||
|  |     }] | ||||||
|  |   } | ||||||
| } | } | ||||||
| module.exports = BookMetadata | module.exports = BookMetadata | ||||||
| @ -12,6 +12,9 @@ class FileMetadata { | |||||||
|     if (metadata) { |     if (metadata) { | ||||||
|       this.construct(metadata) |       this.construct(metadata) | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     // Temp flag used in scans
 | ||||||
|  |     this.wasModified = false | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   construct(metadata) { |   construct(metadata) { | ||||||
| @ -46,5 +49,24 @@ class FileMetadata { | |||||||
|     if (!this.ext) return '' |     if (!this.ext) return '' | ||||||
|     return this.ext.slice(1) |     return this.ext.slice(1) | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   update(payload) { | ||||||
|  |     var hasUpdates = false | ||||||
|  |     for (const key in payload) { | ||||||
|  |       if (this[key] !== undefined && this[key] !== payload[key]) { | ||||||
|  |         this[key] = payload[key] | ||||||
|  |         hasUpdates = true | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return hasUpdates | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   setData(payload) { | ||||||
|  |     for (const key in payload) { | ||||||
|  |       if (this[key] !== undefined) { | ||||||
|  |         this[key] = payload[key] | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
| } | } | ||||||
| module.exports = FileMetadata | module.exports = FileMetadata | ||||||
| @ -9,9 +9,9 @@ const { LogLevel } = require('../utils/constants') | |||||||
| class AudioFileScanner { | class AudioFileScanner { | ||||||
|   constructor() { } |   constructor() { } | ||||||
| 
 | 
 | ||||||
|   getTrackAndDiscNumberFromFilename(bookScanData, audioFileData) { |   getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, audioLibraryFile) { | ||||||
|     const { title, author, series, publishYear } = bookScanData |     const { title, author, series, publishYear } = mediaMetadataFromScan | ||||||
|     const { filename, path } = audioFileData |     const { filename, path } = audioLibraryFile.metadata | ||||||
|     var partbasename = Path.basename(filename, Path.extname(filename)) |     var partbasename = Path.basename(filename, Path.extname(filename)) | ||||||
| 
 | 
 | ||||||
|     // Remove title, author, series, and publishYear from filename if there
 |     // Remove title, author, series, and publishYear from filename if there
 | ||||||
| @ -54,25 +54,23 @@ class AudioFileScanner { | |||||||
|     return Math.floor(total / results.length) |     return Math.floor(total / results.length) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async scan(audioFileData, bookScanData, verbose = false) { |   async scan(audioLibraryFile, mediaMetadataFromScan, verbose = false) { | ||||||
|     var probeStart = Date.now() |     var probeStart = Date.now() | ||||||
|     // Logger.debug(`[AudioFileScanner] Start Probe ${audioFileData.fullPath}`)
 |     var probeData = await prober.probe(audioLibraryFile.metadata.path, verbose) | ||||||
|     var probeData = await prober.probe(audioFileData.fullPath, verbose) |  | ||||||
|     if (probeData.error) { |     if (probeData.error) { | ||||||
|       Logger.error(`[AudioFileScanner] ${probeData.error} : "${audioFileData.fullPath}"`) |       Logger.error(`[AudioFileScanner] ${probeData.error} : "${audioLibraryFile.metadata.path}"`) | ||||||
|       return null |       return null | ||||||
|     } |     } | ||||||
|     // Logger.debug(`[AudioFileScanner] Finished Probe ${audioFileData.fullPath} elapsed ${msToTimestamp(Date.now() - probeStart, true)}`)
 |  | ||||||
| 
 | 
 | ||||||
|     var audioFile = new AudioFile() |     var audioFile = new AudioFile() | ||||||
|     audioFileData.trackNumFromMeta = probeData.trackNumber |     audioFile.trackNumFromMeta = probeData.trackNumber | ||||||
|     audioFileData.discNumFromMeta = probeData.discNumber |     audioFile.discNumFromMeta = probeData.discNumber | ||||||
| 
 | 
 | ||||||
|     const { trackNumber, discNumber } = this.getTrackAndDiscNumberFromFilename(bookScanData, audioFileData) |     const { trackNumber, discNumber } = this.getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, audioLibraryFile) | ||||||
|     audioFileData.trackNumFromFilename = trackNumber |     audioFile.trackNumFromFilename = trackNumber | ||||||
|     audioFileData.discNumFromFilename = discNumber |     audioFile.discNumFromFilename = discNumber | ||||||
| 
 | 
 | ||||||
|     audioFile.setDataFromProbe(audioFileData, probeData) |     audioFile.setDataFromProbe(audioLibraryFile, probeData) | ||||||
| 
 | 
 | ||||||
|     return { |     return { | ||||||
|       audioFile, |       audioFile, | ||||||
| @ -81,10 +79,11 @@ class AudioFileScanner { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   // Returns array of { AudioFile, elapsed, averageScanDuration } from audio file scan objects
 |   // Returns array of { AudioFile, elapsed, averageScanDuration } from audio file scan objects
 | ||||||
|   async executeAudioFileScans(audioFileDataArray, bookScanData) { |   async executeAudioFileScans(audioLibraryFiles, scanData) { | ||||||
|  |     var mediaMetadataFromScan = scanData.mediaMetadata || null | ||||||
|     var proms = [] |     var proms = [] | ||||||
|     for (let i = 0; i < audioFileDataArray.length; i++) { |     for (let i = 0; i < audioLibraryFiles.length; i++) { | ||||||
|       proms.push(this.scan(audioFileDataArray[i], bookScanData)) |       proms.push(this.scan(audioLibraryFiles[i], mediaMetadataFromScan)) | ||||||
|     } |     } | ||||||
|     var scanStart = Date.now() |     var scanStart = Date.now() | ||||||
|     var results = await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr)) |     var results = await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr)) | ||||||
| @ -117,7 +116,7 @@ class AudioFileScanner { | |||||||
|     return nodupes |     return nodupes | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   runSmartTrackOrder(audiobook, audioFiles) { |   runSmartTrackOrder(libraryItem, audioFiles) { | ||||||
|     var discsFromFilename = [] |     var discsFromFilename = [] | ||||||
|     var tracksFromFilename = [] |     var tracksFromFilename = [] | ||||||
|     var discsFromMeta = [] |     var discsFromMeta = [] | ||||||
| @ -153,75 +152,78 @@ class AudioFileScanner { | |||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|     if (discKey !== null) { |     if (discKey !== null) { | ||||||
|       Logger.debug(`[AudioFileScanner] Smart track order for "${audiobook.title}" using disc key ${discKey} and track key ${trackKey}`) |       Logger.debug(`[AudioFileScanner] Smart track order for "${libraryItem.media.metadata.title}" using disc key ${discKey} and track key ${trackKey}`) | ||||||
|       audioFiles.sort((a, b) => { |       audioFiles.sort((a, b) => { | ||||||
|         let Dx = a[discKey] - b[discKey] |         let Dx = a[discKey] - b[discKey] | ||||||
|         if (Dx === 0) Dx = a[trackKey] - b[trackKey] |         if (Dx === 0) Dx = a[trackKey] - b[trackKey] | ||||||
|         return Dx |         return Dx | ||||||
|       }) |       }) | ||||||
|     } else { |     } else { | ||||||
|       Logger.debug(`[AudioFileScanner] Smart track order for "${audiobook.title}" using track key ${trackKey}`) |       Logger.debug(`[AudioFileScanner] Smart track order for "${libraryItem.media.metadata.title}" using track key ${trackKey}`) | ||||||
|       audioFiles.sort((a, b) => a[trackKey] - b[trackKey]) |       audioFiles.sort((a, b) => a[trackKey] - b[trackKey]) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     for (let i = 0; i < audioFiles.length; i++) { |     for (let i = 0; i < audioFiles.length; i++) { | ||||||
|       audioFiles[i].index = i + 1 |       audioFiles[i].index = i + 1 | ||||||
|       var existingAF = audiobook.getAudioFileByIno(audioFiles[i].ino) |       var existingAF = libraryItem.media.findFileWithInode(audioFiles[i].ino) | ||||||
|       if (existingAF) { |       if (existingAF) { | ||||||
|         audiobook.updateAudioFile(audioFiles[i]) |         if (existingAF.updateFromScan) existingAF.updateFromScan(audioFiles[i]) | ||||||
|       } else { |       } else { | ||||||
|         audiobook.addAudioFile(audioFiles[i]) |         libraryItem.media.audioFiles.push(audioFiles[i]) | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async scanAudioFiles(audioFileDataArray, bookScanData, audiobook, preferAudioMetadata, libraryScan = null) { |   async scanAudioFiles(audioLibraryFiles, scanData, libraryItem, preferAudioMetadata, libraryScan = null) { | ||||||
|     var hasUpdated = false |     var hasUpdated = false | ||||||
| 
 | 
 | ||||||
|     var audioScanResult = await this.executeAudioFileScans(audioFileDataArray, bookScanData) |     var audioScanResult = await this.executeAudioFileScans(audioLibraryFiles, scanData) | ||||||
|     if (audioScanResult.audioFiles.length) { |     if (audioScanResult.audioFiles.length) { | ||||||
|       if (libraryScan) { |       if (libraryScan) { | ||||||
|         libraryScan.addLog(LogLevel.DEBUG, `Book "${bookScanData.path}" Audio file scan took ${audioScanResult.elapsed}ms for ${audioScanResult.audioFiles.length} with average time of ${audioScanResult.averageScanDuration}ms`) |         libraryScan.addLog(LogLevel.DEBUG, `Library Item "${scanData.path}" Audio file scan took ${audioScanResult.elapsed}ms for ${audioScanResult.audioFiles.length} with average time of ${audioScanResult.averageScanDuration}ms`) | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       var totalAudioFilesToInclude = audioScanResult.audioFiles.length |       var totalAudioFilesToInclude = audioScanResult.audioFiles.length | ||||||
|       var newAudioFiles = audioScanResult.audioFiles.filter(af => { |       var newAudioFiles = audioScanResult.audioFiles.filter(af => { | ||||||
|         return !audiobook.audioFilesToInclude.find(_af => _af.ino === af.ino) |         return !libraryItem.libraryFiles.find(lf => lf.ino === af.ino) | ||||||
|       }) |       }) | ||||||
| 
 | 
 | ||||||
|       if (newAudioFiles.length) { |       // Adding audio files to book media
 | ||||||
|         // Single Track Audiobooks
 |       if (libraryItem.mediaType === 'book') { | ||||||
|         if (totalAudioFilesToInclude === 1) { |         if (newAudioFiles.length) { | ||||||
|           var af = audioScanResult.audioFiles[0] |           // Single Track Audiobooks
 | ||||||
|           af.index = 1 |           if (totalAudioFilesToInclude === 1) { | ||||||
|           audiobook.addAudioFile(af) |             var af = audioScanResult.audioFiles[0] | ||||||
|           hasUpdated = true |             af.index = 1 | ||||||
|  |             libraryItem.media.audioFiles.push(af) | ||||||
|  |             hasUpdated = true | ||||||
|  |           } else { | ||||||
|  |             this.runSmartTrackOrder(libraryItem, audioScanResult.audioFiles) | ||||||
|  |             hasUpdated = true | ||||||
|  |           } | ||||||
|         } else { |         } else { | ||||||
|           this.runSmartTrackOrder(audiobook, audioScanResult.audioFiles) |           Logger.debug(`[AudioFileScanner] No audio track re-order required`) | ||||||
|  |           // Only update metadata not index
 | ||||||
|  |           audioScanResult.audioFiles.forEach((af) => { | ||||||
|  |             var existingAF = libraryItem.media.findFileWithInode(af.ino) | ||||||
|  |             if (existingAF) { | ||||||
|  |               af.index = existingAF.index | ||||||
|  |               if (existingAF.updateFromScan && existingAF.updateFromScan(af)) { | ||||||
|  |                 hasUpdated = true | ||||||
|  |               } | ||||||
|  |             } | ||||||
|  |           }) | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Set book details from audio file ID3 tags, optional prefer
 | ||||||
|  |         if (libraryItem.media.setMetadataFromAudioFile(preferAudioMetadata)) { | ||||||
|           hasUpdated = true |           hasUpdated = true | ||||||
|         } |         } | ||||||
|       } else { |  | ||||||
|         Logger.debug(`[AudioFileScanner] No audio track re-order required`) |  | ||||||
|         // Only update metadata not index
 |  | ||||||
|         audioScanResult.audioFiles.forEach((af) => { |  | ||||||
|           var existingAF = audiobook.getAudioFileByIno(af.ino) |  | ||||||
|           if (existingAF) { |  | ||||||
|             af.index = existingAF.index |  | ||||||
|             if (audiobook.updateAudioFile(af)) { |  | ||||||
|               hasUpdated = true |  | ||||||
|             } |  | ||||||
|           } |  | ||||||
|         }) |  | ||||||
|       } |  | ||||||
| 
 | 
 | ||||||
|       // Set book details from audio file ID3 tags, optional prefer
 |         if (hasUpdated) { | ||||||
|       if (audiobook.setDetailsFromFileMetadata(preferAudioMetadata)) { |           libraryItem.media.rebuildTracks() | ||||||
|         hasUpdated = true |         } | ||||||
|       } |       } // End Book media type
 | ||||||
| 
 |  | ||||||
|       if (hasUpdated) { |  | ||||||
|         audiobook.rebuildTracks() |  | ||||||
|       } |  | ||||||
|     } |     } | ||||||
|     return hasUpdated |     return hasUpdated | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -13,6 +13,7 @@ class LibraryScan { | |||||||
|     this.type = null |     this.type = null | ||||||
|     this.libraryId = null |     this.libraryId = null | ||||||
|     this.libraryName = null |     this.libraryName = null | ||||||
|  |     this.libraryMediaType = null | ||||||
|     this.folders = null |     this.folders = null | ||||||
|     this.verbose = false |     this.verbose = false | ||||||
| 
 | 
 | ||||||
| @ -69,6 +70,7 @@ class LibraryScan { | |||||||
|       type: this.type, |       type: this.type, | ||||||
|       libraryId: this.libraryId, |       libraryId: this.libraryId, | ||||||
|       libraryName: this.libraryName, |       libraryName: this.libraryName, | ||||||
|  |       libraryMediaType: this.libraryMediaType, | ||||||
|       folders: this.folders.map(f => f.toJSON()), |       folders: this.folders.map(f => f.toJSON()), | ||||||
|       scanOptions: this.scanOptions ? this.scanOptions.toJSON() : null, |       scanOptions: this.scanOptions ? this.scanOptions.toJSON() : null, | ||||||
|       startedAt: this.startedAt, |       startedAt: this.startedAt, | ||||||
| @ -85,6 +87,7 @@ class LibraryScan { | |||||||
|     this.type = type |     this.type = type | ||||||
|     this.libraryId = library.id |     this.libraryId = library.id | ||||||
|     this.libraryName = library.name |     this.libraryName = library.name | ||||||
|  |     this.libraryMediaType = library.mediaType | ||||||
|     this.folders = library.folders.map(folder => new Folder(folder.toJSON())) |     this.folders = library.folders.map(folder => new Folder(folder.toJSON())) | ||||||
| 
 | 
 | ||||||
|     this.scanOptions = scanOptions |     this.scanOptions = scanOptions | ||||||
|  | |||||||
| @ -4,16 +4,20 @@ const Path = require('path') | |||||||
| // Utils
 | // Utils
 | ||||||
| const Logger = require('../Logger') | const Logger = require('../Logger') | ||||||
| const { version } = require('../../package.json') | const { version } = require('../../package.json') | ||||||
| const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('../utils/scandir') | const { groupFilesIntoLibraryItemPaths, getLibraryItemFileData, scanFolder } = require('../utils/scandir') | ||||||
| const { comparePaths, getId } = require('../utils/index') | const { comparePaths, getId } = require('../utils/index') | ||||||
| const { ScanResult, LogLevel } = require('../utils/constants') | const { ScanResult, LogLevel } = require('../utils/constants') | ||||||
| 
 | 
 | ||||||
| const AudioFileScanner = require('./AudioFileScanner') | const AudioFileScanner = require('./AudioFileScanner') | ||||||
| const BookFinder = require('../finders/BookFinder') | const BookFinder = require('../finders/BookFinder') | ||||||
| const Audiobook = require('../objects/legacy/Audiobook') | const Audiobook = require('../objects/legacy/Audiobook') | ||||||
|  | const LibraryItem = require('../objects/LibraryItem') | ||||||
| const LibraryScan = require('./LibraryScan') | const LibraryScan = require('./LibraryScan') | ||||||
| const ScanOptions = require('./ScanOptions') | const ScanOptions = require('./ScanOptions') | ||||||
| 
 | 
 | ||||||
|  | const Author = require('../objects/entities/Author') | ||||||
|  | const Series = require('../objects/entities/Series') | ||||||
|  | 
 | ||||||
| class Scanner { | class Scanner { | ||||||
|   constructor(db, coverController, emitter) { |   constructor(db, coverController, emitter) { | ||||||
|     this.BookMetadataPath = Path.posix.join(global.MetadataPath, 'books') |     this.BookMetadataPath = Path.posix.join(global.MetadataPath, 'books') | ||||||
| @ -53,71 +57,69 @@ class Scanner { | |||||||
|     this.cancelLibraryScan[libraryId] = true |     this.cancelLibraryScan[libraryId] = true | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async scanAudiobookById(audiobookId) { |   async scanLibraryItemById(libraryItemId) { | ||||||
|     var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId) |     var libraryItem = this.db.libraryItems.find(li => li.id === libraryItemId) | ||||||
|     if (!audiobook) { |     if (!libraryItem) { | ||||||
|       Logger.error(`[Scanner] Scan audiobook by id not found ${audiobookId}`) |       Logger.error(`[Scanner] Scan libraryItem by id not found ${libraryItemId}`) | ||||||
|       return ScanResult.NOTHING |       return ScanResult.NOTHING | ||||||
|     } |     } | ||||||
|     const library = this.db.libraries.find(lib => lib.id === audiobook.libraryId) |     const library = this.db.libraries.find(lib => lib.id === libraryItem.libraryId) | ||||||
|     if (!library) { |     if (!library) { | ||||||
|       Logger.error(`[Scanner] Scan audiobook by id library not found "${audiobook.libraryId}"`) |       Logger.error(`[Scanner] Scan libraryItem by id library not found "${libraryItem.libraryId}"`) | ||||||
|       return ScanResult.NOTHING |       return ScanResult.NOTHING | ||||||
|     } |     } | ||||||
|     const folder = library.folders.find(f => f.id === audiobook.folderId) |     const folder = library.folders.find(f => f.id === libraryItem.folderId) | ||||||
|     if (!folder) { |     if (!folder) { | ||||||
|       Logger.error(`[Scanner] Scan audiobook by id folder not found "${audiobook.folderId}" in library "${library.name}"`) |       Logger.error(`[Scanner] Scan libraryItem by id folder not found "${libraryItem.folderId}" in library "${library.name}"`) | ||||||
|       return ScanResult.NOTHING |       return ScanResult.NOTHING | ||||||
|     } |     } | ||||||
|     Logger.info(`[Scanner] Scanning Audiobook "${audiobook.title}"`) |     Logger.info(`[Scanner] Scanning Library Item "${libraryItem.media.metadata.title}"`) | ||||||
|     return this.scanAudiobook(folder, audiobook) |     return this.scanLibraryItem(library.mediaType, folder, libraryItem) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async scanAudiobook(folder, audiobook) { |   async scanLibraryItem(libraryMediaType, folder, libraryItem) { | ||||||
|     var audiobookData = await getAudiobookFileData(folder, audiobook.fullPath, this.db.serverSettings) |     var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, this.db.serverSettings) | ||||||
|     if (!audiobookData) { |     if (!libraryItemData) { | ||||||
|       return ScanResult.NOTHING |       return ScanResult.NOTHING | ||||||
|     } |     } | ||||||
|     var hasUpdated = false |     var hasUpdated = false | ||||||
| 
 | 
 | ||||||
|     var checkRes = audiobook.checkScanData(audiobookData, version) |     var checkRes = libraryItem.checkScanData(libraryItemData) | ||||||
|     if (checkRes.updated) hasUpdated = true |     if (checkRes.updated) hasUpdated = true | ||||||
| 
 | 
 | ||||||
|     // Sync other files first so that local images are used as cover art
 |     // Sync other files first so that local images are used as cover art
 | ||||||
|     // TODO: Cleanup other file sync
 |     if (await libraryItem.syncFiles(this.db.serverSettings.scannerPreferOpfMetadata)) { | ||||||
|     var allOtherFiles = checkRes.newOtherFileData.concat(checkRes.existingOtherFileData) |  | ||||||
|     if (await audiobook.syncOtherFiles(allOtherFiles, this.db.serverSettings.scannerPreferOpfMetadata)) { |  | ||||||
|       hasUpdated = true |       hasUpdated = true | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // Scan all audio files
 |     // Scan all audio files
 | ||||||
|     if (audiobookData.audioFiles.length) { |     if (libraryItem.hasAudioFiles) { | ||||||
|       if (await AudioFileScanner.scanAudioFiles(audiobookData.audioFiles, audiobookData, audiobook, this.db.serverSettings.scannerPreferAudioMetadata)) { |       var libraryAudioFiles = libraryItem.libraryFiles.filter(lf => lf.fileType === 'audio') | ||||||
|  |       if (await AudioFileScanner.scanAudioFiles(libraryAudioFiles, libraryItemData, libraryItem, this.db.serverSettings.scannerPreferAudioMetadata)) { | ||||||
|         hasUpdated = true |         hasUpdated = true | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       // Extract embedded cover art if cover is not already in directory
 |       // Extract embedded cover art if cover is not already in directory
 | ||||||
|       if (audiobook.hasEmbeddedCoverArt && !audiobook.cover) { |       if (libraryItem.media.hasEmbeddedCoverArt && !libraryItem.media.coverPath) { | ||||||
|         var outputCoverDirs = this.getCoverDirectory(audiobook) |         var coverPath = await this.coverController.saveEmbeddedCoverArt(libraryItem) | ||||||
|         var relativeDir = await audiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath) |         if (coverPath) { | ||||||
|         if (relativeDir) { |           Logger.debug(`[Scanner] Saved embedded cover art "${coverPath}"`) | ||||||
|           Logger.debug(`[Scanner] Saved embedded cover art "${relativeDir}"`) |  | ||||||
|           hasUpdated = true |           hasUpdated = true | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 |     console.log('Finished library item scan', libraryItem.hasMediaFiles, hasUpdated) | ||||||
|     if (!audiobook.audioFilesToInclude.length && !audiobook.ebooks.length) { // Audiobook is invalid
 |     if (!libraryItem.hasMediaFiles) { // Library Item is invalid
 | ||||||
|       audiobook.setInvalid() |       libraryItem.setInvalid() | ||||||
|       hasUpdated = true |       hasUpdated = true | ||||||
|     } else if (audiobook.isInvalid) { |     } else if (libraryItem.isInvalid) { | ||||||
|       audiobook.isInvalid = false |       libraryItem.isInvalid = false | ||||||
|       hasUpdated = true |       hasUpdated = true | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (hasUpdated) { |     if (hasUpdated) { | ||||||
|       this.emitter('audiobook_updated', audiobook.toJSONExpanded()) |       this.emitter('item_updated', libraryItem.toJSONExpanded()) | ||||||
|       await this.db.updateAudiobook(audiobook) |       await this.db.updateLibraryItem(libraryItem) | ||||||
|       return ScanResult.UPDATED |       return ScanResult.UPDATED | ||||||
|     } |     } | ||||||
|     return ScanResult.UPTODATE |     return ScanResult.UPTODATE | ||||||
| @ -177,241 +179,277 @@ class Scanner { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async scanLibrary(libraryScan) { |   async scanLibrary(libraryScan) { | ||||||
|     var audiobookDataFound = [] |     var libraryItemDataFound = [] | ||||||
| 
 | 
 | ||||||
|     // Scan each library
 |     // Scan each library
 | ||||||
|     for (let i = 0; i < libraryScan.folders.length; i++) { |     for (let i = 0; i < libraryScan.folders.length; i++) { | ||||||
|       var folder = libraryScan.folders[i] |       var folder = libraryScan.folders[i] | ||||||
|       var abDataFoundInFolder = await scanRootDir(folder, this.db.serverSettings) |       var itemDataFoundInFolder = await scanFolder(libraryScan.libraryMediaType, folder, this.db.serverSettings) | ||||||
|       libraryScan.addLog(LogLevel.INFO, `${abDataFoundInFolder.length} ab data found in folder "${folder.fullPath}"`) |       libraryScan.addLog(LogLevel.INFO, `${itemDataFoundInFolder.length} item data found in folder "${folder.fullPath}"`) | ||||||
|       audiobookDataFound = audiobookDataFound.concat(abDataFoundInFolder) |       libraryItemDataFound = libraryItemDataFound.concat(itemDataFoundInFolder) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (this.cancelLibraryScan[libraryScan.libraryId]) return true |     if (this.cancelLibraryScan[libraryScan.libraryId]) return true | ||||||
| 
 | 
 | ||||||
|     // Remove audiobooks with no inode
 |     // Remove audiobooks with no inode
 | ||||||
|     audiobookDataFound = audiobookDataFound.filter(abd => abd.ino) |     libraryItemDataFound = libraryItemDataFound.filter(lid => lid.ino) | ||||||
|     var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === libraryScan.libraryId) |     var libraryItemsInLibrary = this.db.libraryItems.filter(li => li.libraryId === libraryScan.libraryId) | ||||||
| 
 | 
 | ||||||
|     const NumScansPerChunk = 25 |     const NumScansPerChunk = 25 | ||||||
|     const audiobooksToUpdateChunks = [] |     const itemsToUpdateChunks = [] | ||||||
|     const audiobookDataToRescanChunks = [] |     const itemDataToRescanChunks = [] | ||||||
|     const newAudiobookDataToScanChunks = [] |     const newItemDataToScanChunks = [] | ||||||
|     var audiobooksToUpdate = [] |     var itemsToUpdate = [] | ||||||
|     var audiobookDataToRescan = [] |     var itemDataToRescan = [] | ||||||
|     var newAudiobookDataToScan = [] |     var newItemDataToScan = [] | ||||||
|     var audiobooksToFindCovers = [] |     var itemsToFindCovers = [] | ||||||
| 
 | 
 | ||||||
|     // Check for existing & removed audiobooks
 |     // Check for existing & removed library items
 | ||||||
|     for (let i = 0; i < audiobooksInLibrary.length; i++) { |     for (let i = 0; i < libraryItemsInLibrary.length; i++) { | ||||||
|       var audiobook = audiobooksInLibrary[i] |       var libraryItem = libraryItemsInLibrary[i] | ||||||
|       // Find audiobook folder with matching inode or matching path
 |       // Find library item folder with matching inode or matching path
 | ||||||
|       var dataFound = audiobookDataFound.find(abd => abd.ino === audiobook.ino || comparePaths(abd.path, audiobook.path)) |       var dataFound = libraryItemDataFound.find(lid => lid.ino === libraryItem.ino || comparePaths(lid.relPath, libraryItem.relPath)) | ||||||
|       if (!dataFound) { |       if (!dataFound) { | ||||||
|         libraryScan.addLog(LogLevel.WARN, `Audiobook "${audiobook.title}" is missing`) |         libraryScan.addLog(LogLevel.WARN, `Library Item "${libraryItem.media.metadata.title}" is missing`) | ||||||
|         libraryScan.resultsMissing++ |         libraryScan.resultsMissing++ | ||||||
|         audiobook.setMissing() |         libraryItem.setMissing() | ||||||
|         audiobooksToUpdate.push(audiobook) |         itemsToUpdate.push(libraryItem) | ||||||
|         if (audiobooksToUpdate.length === NumScansPerChunk) { |         if (itemsToUpdate.length === NumScansPerChunk) { | ||||||
|           audiobooksToUpdateChunks.push(audiobooksToUpdate) |           itemsToUpdateChunks.push(itemsToUpdate) | ||||||
|           audiobooksToUpdate = [] |           itemsToUpdate = [] | ||||||
|         } |         } | ||||||
|       } else { |       } else { | ||||||
|         var checkRes = audiobook.checkScanData(dataFound, version) |         var checkRes = libraryItem.checkScanData(dataFound) | ||||||
|         if (checkRes.newAudioFileData.length || checkRes.newOtherFileData.length || libraryScan.scanOptions.forceRescan) { // Audiobook has new files
 |         if (checkRes.newLibraryFiles.length || libraryScan.scanOptions.forceRescan) { // Item has new files
 | ||||||
|           checkRes.audiobook = audiobook |           checkRes.libraryItem = libraryItem | ||||||
|           checkRes.bookScanData = dataFound |           checkRes.scanData = dataFound | ||||||
|           audiobookDataToRescan.push(checkRes) |           itemDataToRescan.push(checkRes) | ||||||
|           if (audiobookDataToRescan.length === NumScansPerChunk) { |           if (itemDataToRescan.length === NumScansPerChunk) { | ||||||
|             audiobookDataToRescanChunks.push(audiobookDataToRescan) |             itemDataToRescanChunks.push(itemDataToRescan) | ||||||
|             audiobookDataToRescan = [] |             itemDataToRescan = [] | ||||||
|           } |           } | ||||||
|         } else if (libraryScan.findCovers && audiobook.book.shouldSearchForCover) { |         } else if (libraryScan.findCovers && libraryItem.media.shouldSearchForCover) { | ||||||
|           libraryScan.resultsUpdated++ |           libraryScan.resultsUpdated++ | ||||||
|           audiobooksToFindCovers.push(audiobook) |           itemsToFindCovers.push(libraryItem) | ||||||
|           audiobooksToUpdate.push(audiobook) |           itemsToUpdate.push(libraryItem) | ||||||
|           if (audiobooksToUpdate.length === NumScansPerChunk) { |           if (itemsToUpdate.length === NumScansPerChunk) { | ||||||
|             audiobooksToUpdateChunks.push(audiobooksToUpdate) |             itemsToUpdateChunks.push(itemsToUpdate) | ||||||
|             audiobooksToUpdate = [] |             itemsToUpdate = [] | ||||||
|           } |           } | ||||||
|         } else if (checkRes.updated) { // Updated but no scan required
 |         } else if (checkRes.updated) { // Updated but no scan required
 | ||||||
|           libraryScan.resultsUpdated++ |           libraryScan.resultsUpdated++ | ||||||
|           audiobooksToUpdate.push(audiobook) |           itemsToUpdate.push(libraryItem) | ||||||
|           if (audiobooksToUpdate.length === NumScansPerChunk) { |           if (itemsToUpdate.length === NumScansPerChunk) { | ||||||
|             audiobooksToUpdateChunks.push(audiobooksToUpdate) |             itemsToUpdateChunks.push(itemsToUpdate) | ||||||
|             audiobooksToUpdate = [] |             itemsToUpdate = [] | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
|         audiobookDataFound = audiobookDataFound.filter(abf => abf.ino !== dataFound.ino) |         libraryItemDataFound = libraryItemDataFound.filter(lid => lid.ino !== dataFound.ino) | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     if (audiobooksToUpdate.length) audiobooksToUpdateChunks.push(audiobooksToUpdate) |     if (itemsToUpdate.length) itemsToUpdateChunks.push(itemsToUpdate) | ||||||
|     if (audiobookDataToRescan.length) audiobookDataToRescanChunks.push(audiobookDataToRescan) |     if (itemDataToRescan.length) itemDataToRescanChunks.push(itemDataToRescan) | ||||||
| 
 | 
 | ||||||
|     // Potential NEW Audiobooks
 |     // Potential NEW Library Items
 | ||||||
|     for (let i = 0; i < audiobookDataFound.length; i++) { |     for (let i = 0; i < libraryItemDataFound.length; i++) { | ||||||
|       var dataFound = audiobookDataFound[i] |       var dataFound = libraryItemDataFound[i] | ||||||
|       var hasEbook = dataFound.otherFiles.find(otherFile => otherFile.filetype === 'ebook') | 
 | ||||||
|       if (!hasEbook && !dataFound.audioFiles.length) { |       var hasMediaFile = dataFound.libraryFiles.some(lf => lf.isMediaFile) | ||||||
|         libraryScan.addLog(LogLevel.WARN, `Directory found "${audiobookDataFound.path}" has no ebook or audio files`) |       if (!hasMediaFile) { | ||||||
|  |         libraryScan.addLog(LogLevel.WARN, `Directory found "${libraryItemDataFound.path}" has no media files`) | ||||||
|       } else { |       } else { | ||||||
|         newAudiobookDataToScan.push(dataFound) |         newItemDataToScan.push(dataFound) | ||||||
|         if (newAudiobookDataToScan.length === NumScansPerChunk) { |         if (newItemDataToScan.length === NumScansPerChunk) { | ||||||
|           newAudiobookDataToScanChunks.push(newAudiobookDataToScan) |           newItemDataToScanChunks.push(newItemDataToScan) | ||||||
|           newAudiobookDataToScan = [] |           newItemDataToScan = [] | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     if (newAudiobookDataToScan.length) newAudiobookDataToScanChunks.push(newAudiobookDataToScan) |     if (newItemDataToScan.length) newItemDataToScanChunks.push(newItemDataToScan) | ||||||
| 
 | 
 | ||||||
|     // console.log('Num chunks to update', audiobooksToUpdateChunks.length)
 |     // Library Items not requiring a scan but require a search for cover
 | ||||||
|     // console.log('Num chunks to rescan', audiobookDataToRescanChunks.length)
 |     for (let i = 0; i < itemsToFindCovers.length; i++) { | ||||||
|     // console.log('Num chunks to new scan', newAudiobookDataToScanChunks.length)
 |       var libraryItem = itemsToFindCovers[i] | ||||||
| 
 |       var updatedCover = await this.searchForCover(libraryItem, libraryScan) | ||||||
|     // Audiobooks not requiring a scan but require a search for cover
 |       libraryItem.media.updateLastCoverSearch(updatedCover) | ||||||
|     for (let i = 0; i < audiobooksToFindCovers.length; i++) { |  | ||||||
|       var audiobook = audiobooksToFindCovers[i] |  | ||||||
|       var updatedCover = await this.searchForCover(audiobook, libraryScan) |  | ||||||
|       audiobook.book.updateLastCoverSearch(updatedCover) |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     for (let i = 0; i < audiobooksToUpdateChunks.length; i++) { |     for (let i = 0; i < itemsToUpdateChunks.length; i++) { | ||||||
|       await this.updateAudiobooksChunk(audiobooksToUpdateChunks[i]) |       await this.updateLibraryItemChunk(itemsToUpdateChunks[i]) | ||||||
|       if (this.cancelLibraryScan[libraryScan.libraryId]) return true |       if (this.cancelLibraryScan[libraryScan.libraryId]) return true | ||||||
|       // console.log('Update chunk done', i, 'of', audiobooksToUpdateChunks.length)
 |       // console.log('Update chunk done', i, 'of', itemsToUpdateChunks.length)
 | ||||||
|     } |     } | ||||||
|     for (let i = 0; i < audiobookDataToRescanChunks.length; i++) { |     for (let i = 0; i < itemDataToRescanChunks.length; i++) { | ||||||
|       await this.rescanAudiobookDataChunk(audiobookDataToRescanChunks[i], libraryScan) |       await this.rescanLibraryItemDataChunk(itemDataToRescanChunks[i], libraryScan) | ||||||
|       if (this.cancelLibraryScan[libraryScan.libraryId]) return true |       if (this.cancelLibraryScan[libraryScan.libraryId]) return true | ||||||
|       // console.log('Rescan chunk done', i, 'of', audiobookDataToRescanChunks.length)
 |       // console.log('Rescan chunk done', i, 'of', itemDataToRescanChunks.length)
 | ||||||
|     } |     } | ||||||
|     for (let i = 0; i < newAudiobookDataToScanChunks.length; i++) { |     for (let i = 0; i < newItemDataToScanChunks.length; i++) { | ||||||
|       await this.scanNewAudiobookDataChunk(newAudiobookDataToScanChunks[i], libraryScan) |       await this.scanNewLibraryItemDataChunk(newItemDataToScanChunks[i], libraryScan) | ||||||
|       // console.log('New scan chunk done', i, 'of', newAudiobookDataToScanChunks.length)
 |       // console.log('New scan chunk done', i, 'of', newItemDataToScanChunks.length)
 | ||||||
|       if (this.cancelLibraryScan[libraryScan.libraryId]) return true |       if (this.cancelLibraryScan[libraryScan.libraryId]) return true | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async updateAudiobooksChunk(audiobooksToUpdate) { |   async updateLibraryItemChunk(itemsToUpdate) { | ||||||
|     await this.db.updateEntities('audiobook', audiobooksToUpdate) |     await this.db.updateLibraryItems(itemsToUpdate) | ||||||
|     this.emitter('audiobooks_updated', audiobooksToUpdate.map(ab => ab.toJSONExpanded())) |     this.emitter('items_updated', itemsToUpdate.map(li => li.toJSONExpanded())) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async rescanAudiobookDataChunk(audiobookDataToRescan, libraryScan) { |   async rescanLibraryItemDataChunk(itemDataToRescan, libraryScan) { | ||||||
|     var audiobooksUpdated = await Promise.all(audiobookDataToRescan.map((abd) => { |     var itemsUpdated = await Promise.all(itemDataToRescan.map((lid) => { | ||||||
|       return this.rescanAudiobook(abd, libraryScan) |       return this.rescanLibraryItem(lid, libraryScan) | ||||||
|     })) |     })) | ||||||
|     audiobooksUpdated = audiobooksUpdated.filter(ab => ab) // Filter out nulls
 |     itemsUpdated = itemsUpdated.filter(li => li) // Filter out nulls
 | ||||||
|     if (audiobooksUpdated.length) { |     if (itemsUpdated.length) { | ||||||
|       libraryScan.resultsUpdated += audiobooksUpdated.length |       libraryScan.resultsUpdated += itemsUpdated.length | ||||||
|       await this.db.updateEntities('audiobook', audiobooksUpdated) |       await this.db.updateLibraryItems(itemsUpdated) | ||||||
|       this.emitter('audiobooks_updated', audiobooksUpdated.map(ab => ab.toJSONExpanded())) |       this.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded())) | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async scanNewAudiobookDataChunk(newAudiobookDataToScan, libraryScan) { |   async scanNewLibraryItemDataChunk(newLibraryItemsData, libraryScan) { | ||||||
|     var newAudiobooks = await Promise.all(newAudiobookDataToScan.map((abd) => { |     var newLibraryItems = await Promise.all(newLibraryItemsData.map((lid) => { | ||||||
|       return this.scanNewAudiobook(abd, libraryScan.preferAudioMetadata, libraryScan.preferOpfMetadata, libraryScan.findCovers, libraryScan) |       return this.scanNewLibraryItem(lid, libraryScan.libraryMediaType, libraryScan.preferAudioMetadata, libraryScan.preferOpfMetadata, libraryScan.findCovers, libraryScan) | ||||||
|     })) |     })) | ||||||
|     newAudiobooks = newAudiobooks.filter(ab => ab) // Filter out nulls
 |     newLibraryItems = newLibraryItems.filter(li => li) // Filter out nulls
 | ||||||
|     libraryScan.resultsAdded += newAudiobooks.length |     libraryScan.resultsAdded += newLibraryItems.length | ||||||
|     await this.db.insertAudiobooks(newAudiobooks) |     await this.db.insertLibraryItems(newLibraryItems) | ||||||
|     this.emitter('audiobooks_added', newAudiobooks.map(ab => ab.toJSONExpanded())) |     this.emitter('items_added', newLibraryItems.map(li => li.toJSONExpanded())) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async rescanAudiobook(audiobookCheckData, libraryScan) { |   async rescanLibraryItem(libraryItemCheckData, libraryScan) { | ||||||
|     const { newAudioFileData, audioFilesRemoved, newOtherFileData, audiobook, bookScanData, updated, existingAudioFileData, existingOtherFileData } = audiobookCheckData |     const { newLibraryFiles, filesRemoved, existingLibraryFiles, libraryItem, scanData, updated } = libraryItemCheckData | ||||||
|     libraryScan.addLog(LogLevel.DEBUG, `Library "${libraryScan.libraryName}" Re-scanning "${audiobook.path}"`) |     libraryScan.addLog(LogLevel.DEBUG, `Library "${libraryScan.libraryName}" Re-scanning "${libraryItem.path}"`) | ||||||
|     var hasUpdated = updated |     var hasUpdated = updated | ||||||
| 
 | 
 | ||||||
|     // Sync other files first to use local images as cover before extracting audio file cover
 |     // Sync other files first to use local images as cover before extracting audio file cover
 | ||||||
|     if (newOtherFileData.length || libraryScan.scanOptions.forceRescan) { |     if (await libraryItem.syncFiles(libraryScan.preferOpfMetadata)) { | ||||||
|       // TODO: Cleanup other file sync
 |       hasUpdated = true | ||||||
|       var allOtherFiles = newOtherFileData.concat(existingOtherFileData) |  | ||||||
|       if (await audiobook.syncOtherFiles(allOtherFiles, libraryScan.preferOpfMetadata)) { |  | ||||||
|         hasUpdated = true |  | ||||||
|       } |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // forceRescan all existing audio files - will probe and update ID3 tag metadata
 |     // forceRescan all existing audio files - will probe and update ID3 tag metadata
 | ||||||
|     if (libraryScan.scanOptions.forceRescan && existingAudioFileData.length) { |     var existingAudioFiles = existingLibraryFiles.filter(lf => lf.fileType === 'audio') | ||||||
|       if (await AudioFileScanner.scanAudioFiles(existingAudioFileData, bookScanData, audiobook, libraryScan.preferAudioMetadata, libraryScan)) { |     if (libraryScan.scanOptions.forceRescan && existingAudioFiles.length) { | ||||||
|  |       if (await AudioFileScanner.scanAudioFiles(existingAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) { | ||||||
|         hasUpdated = true |         hasUpdated = true | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     // Scan new audio files
 |     // Scan new audio files
 | ||||||
|     if (newAudioFileData.length || audioFilesRemoved.length) { |     var newAudioFiles = newLibraryFiles.filter(lf => lf.fileType === 'audio') | ||||||
|       if (await AudioFileScanner.scanAudioFiles(newAudioFileData, bookScanData, audiobook, libraryScan.preferAudioMetadata, libraryScan)) { |     var removedAudioFiles = filesRemoved.filter(lf => lf.fileType === 'audio') | ||||||
|  |     if (newAudioFiles.length || removedAudioFiles.length) { | ||||||
|  |       if (await AudioFileScanner.scanAudioFiles(newAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) { | ||||||
|         hasUpdated = true |         hasUpdated = true | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     // If an audio file has embedded cover art and no cover is set yet, extract & use it
 |     // If an audio file has embedded cover art and no cover is set yet, extract & use it
 | ||||||
|     if (newAudioFileData.length || libraryScan.scanOptions.forceRescan) { |     if (newAudioFiles.length || libraryScan.scanOptions.forceRescan) { | ||||||
|       if (audiobook.hasEmbeddedCoverArt && !audiobook.cover) { |       if (libraryItem.media.hasEmbeddedCoverArt && !libraryItem.media.coverPath) { | ||||||
|         var outputCoverDirs = this.getCoverDirectory(audiobook) |         var savedCoverPath = await this.coverController.saveEmbeddedCoverArt(libraryItem) | ||||||
|         var relativeDir = await audiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath) |         if (savedCoverPath) { | ||||||
|         if (relativeDir) { |  | ||||||
|           hasUpdated = true |           hasUpdated = true | ||||||
|           libraryScan.addLog(LogLevel.DEBUG, `Saved embedded cover art "${relativeDir}"`) |           libraryScan.addLog(LogLevel.DEBUG, `Saved embedded cover art "${savedCoverPath}"`) | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (!audiobook.audioFilesToInclude.length && !audiobook.ebooks.length) { // Audiobook is invalid
 |     if (!libraryItem.media.hasMediaFiles) { // Library item is invalid
 | ||||||
|       audiobook.setInvalid() |       libraryItem.setInvalid() | ||||||
|       hasUpdated = true |       hasUpdated = true | ||||||
|     } else if (audiobook.isInvalid) { |     } else if (libraryItem.isInvalid) { | ||||||
|       audiobook.isInvalid = false |       libraryItem.isInvalid = false | ||||||
|       hasUpdated = true |       hasUpdated = true | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // Scan for cover if enabled and has no cover (and author or title has changed OR has been 7 days since last lookup)
 |     // Scan for cover if enabled and has no cover (and author or title has changed OR has been 7 days since last lookup)
 | ||||||
|     if (audiobook && libraryScan.findCovers && !audiobook.cover && audiobook.book.shouldSearchForCover) { |     if (libraryScan.findCovers && !libraryItem.media.coverPath && libraryItem.media.shouldSearchForCover) { | ||||||
|       var updatedCover = await this.searchForCover(audiobook, libraryScan) |       var updatedCover = await this.searchForCover(libraryItem, libraryScan) | ||||||
|       audiobook.book.updateLastCoverSearch(updatedCover) |       libraryItem.media.updateLastCoverSearch(updatedCover) | ||||||
|       hasUpdated = true |       hasUpdated = true | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return hasUpdated ? audiobook : null |     return hasUpdated ? libraryItem : null | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async scanNewAudiobook(audiobookData, preferAudioMetadata, preferOpfMetadata, findCovers, libraryScan = null) { |   async scanNewLibraryItem(libraryItemData, libraryMediaType, preferAudioMetadata, preferOpfMetadata, findCovers, libraryScan = null) { | ||||||
|     if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Scanning new book "${audiobookData.path}"`) |     if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Scanning new library item "${libraryItemData.path}"`) | ||||||
|     else Logger.debug(`[Scanner] Scanning new book "${audiobookData.path}"`) |     else Logger.debug(`[Scanner] Scanning new item "${libraryItemData.path}"`) | ||||||
| 
 | 
 | ||||||
|     var audiobook = new Audiobook() |     var libraryItem = new LibraryItem() | ||||||
|     audiobook.setData(audiobookData) |     libraryItem.setData(libraryMediaType, libraryItemData) | ||||||
| 
 | 
 | ||||||
|     if (audiobookData.audioFiles.length) { |     var audioFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio') | ||||||
|       await AudioFileScanner.scanAudioFiles(audiobookData.audioFiles, audiobookData, audiobook, preferAudioMetadata, libraryScan) |     if (audioFiles.length) { | ||||||
|  |       await AudioFileScanner.scanAudioFiles(audioFiles, libraryItemData, libraryItem, preferAudioMetadata, libraryScan) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (!audiobook.audioFilesToInclude.length && !audiobook.ebooks.length) { |     if (!libraryItem.media.hasMediaFiles) { | ||||||
|       // Audiobook has no ebooks and no valid audio tracks do not continue
 |       Logger.warn(`[Scanner] Library item has no media files "${libraryItemData.path}"`) | ||||||
|       Logger.warn(`[Scanner] Audiobook has no ebooks and no valid audio tracks "${audiobook.path}"`) |  | ||||||
|       return null |       return null | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // Look for desc.txt and reader.txt and update
 |     await libraryItem.syncFiles(preferOpfMetadata) | ||||||
|     await audiobook.saveDataFromTextFiles(preferOpfMetadata) |  | ||||||
| 
 | 
 | ||||||
|     // Extract embedded cover art if cover is not already in directory
 |     // Extract embedded cover art if cover is not already in directory
 | ||||||
|     if (audiobook.hasEmbeddedCoverArt && !audiobook.cover) { |     if (libraryItem.media.hasEmbeddedCoverArt && !libraryItem.media.coverPath) { | ||||||
|       var outputCoverDirs = this.getCoverDirectory(audiobook) |       var coverPath = await this.coverController.saveEmbeddedCoverArt(libraryItem) | ||||||
|       var relativeDir = await audiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath) |       if (coverPath) { | ||||||
|       if (relativeDir) { |         if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Saved embedded cover art "${coverPath}"`) | ||||||
|         if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Saved embedded cover art "${relativeDir}"`) |         else Logger.debug(`[Scanner] Saved embedded cover art "${coverPath}"`) | ||||||
|         else Logger.debug(`[Scanner] Saved embedded cover art "${relativeDir}"`) |  | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // Scan for cover if enabled and has no cover
 |     // Scan for cover if enabled and has no cover
 | ||||||
|     if (audiobook && findCovers && !audiobook.cover && audiobook.book.shouldSearchForCover) { |     if (libraryMediaType !== 'podcast') { | ||||||
|       var updatedCover = await this.searchForCover(audiobook, libraryScan) |       if (libraryItem && findCovers && !libraryItem.media.coverPath && libraryItem.media.shouldSearchForCover) { | ||||||
|       audiobook.book.updateLastCoverSearch(updatedCover) |         var updatedCover = await this.searchForCover(libraryItem, libraryScan) | ||||||
|  |         libraryItem.media.updateLastCoverSearch(updatedCover) | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       // Create or match all new authors and series
 | ||||||
|  |       if (libraryItem.media.metadata.authors.some(au => au.id.startsWith('new'))) { | ||||||
|  |         var newAuthors = [] | ||||||
|  |         libraryItem.media.metadata.authors = libraryItem.media.metadata.authors.map((tempMinAuthor) => { | ||||||
|  |           var _author = this.db.authors.find(au => au.checkNameEquals(tempMinAuthor.name)) | ||||||
|  |           if (!_author) { | ||||||
|  |             _author = new Author() | ||||||
|  |             _author.setData(tempMinAuthor) | ||||||
|  |             newAuthors.push(_author) | ||||||
|  |           } | ||||||
|  |           return { | ||||||
|  |             id: _author.id, | ||||||
|  |             name: _author.name | ||||||
|  |           } | ||||||
|  |         }) | ||||||
|  |         if (newAuthors.length) { | ||||||
|  |           await this.db.insertEntities('author', newAuthors) | ||||||
|  |           this.emitter('authors_added', newAuthors.map(au => au.toJSON())) | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       if (libraryItem.media.metadata.series.some(se => se.id.startsWith('new'))) { | ||||||
|  |         var newSeries = [] | ||||||
|  |         libraryItem.media.metadata.series = libraryItem.media.metadata.series.map((tempMinSeries) => { | ||||||
|  |           var _series = this.db.series.find(se => se.checkNameEquals(tempMinSeries.name)) | ||||||
|  |           if (!_series) { | ||||||
|  |             _series = new Series() | ||||||
|  |             _series.setData(tempMinSeries) | ||||||
|  |             newSeries.push(_series) | ||||||
|  |           } | ||||||
|  |           return { | ||||||
|  |             id: _series.id, | ||||||
|  |             name: _series.name, | ||||||
|  |             sequence: tempMinSeries.sequence | ||||||
|  |           } | ||||||
|  |         }) | ||||||
|  |         if (newSeries.length) { | ||||||
|  |           await this.db.insertEntities('series', newSeries) | ||||||
|  |           this.emitter('series_added', newSeries.map(se => se.toJSON())) | ||||||
|  |         } | ||||||
|  |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return audiobook |     return libraryItem | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   getFileUpdatesGrouped(fileUpdates) { |   getFileUpdatesGrouped(fileUpdates) { | ||||||
| @ -448,113 +486,113 @@ class Scanner { | |||||||
|         continue; |         continue; | ||||||
|       } |       } | ||||||
|       var relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath) |       var relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath) | ||||||
|       var fileUpdateBookGroup = groupFilesIntoAudiobookPaths(relFilePaths, true) |       var fileUpdateGroup = groupFilesIntoLibraryItemPaths(relFilePaths, true) | ||||||
|       var folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateBookGroup) |       var folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup) | ||||||
|       Logger.debug(`[Scanner] Folder scan results`, folderScanResults) |       Logger.debug(`[Scanner] Folder scan results`, folderScanResults) | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async scanFolderUpdates(library, folder, fileUpdateBookGroup) { |   async scanFolderUpdates(library, folder, fileUpdateGroup) { | ||||||
|     Logger.debug(`[Scanner] Scanning file update groups in folder "${folder.id}" of library "${library.name}"`) |     Logger.debug(`[Scanner] Scanning file update groups in folder "${folder.id}" of library "${library.name}"`) | ||||||
| 
 | 
 | ||||||
|     // First pass - Remove files in parent dirs of audiobooks and remap the fileupdate group
 |     // First pass - Remove files in parent dirs of items and remap the fileupdate group
 | ||||||
|     //    Test Case: Moving audio files from audiobook folder to author folder should trigger a re-scan of audiobook
 |     //    Test Case: Moving audio files from library item folder to author folder should trigger a re-scan of the item
 | ||||||
|     var updateGroup = { ...fileUpdateBookGroup } |     var updateGroup = { ...fileUpdateGroup } | ||||||
|     for (const bookDir in updateGroup) { |     for (const itemDir in updateGroup) { | ||||||
|       var bookDirNestedFiles = fileUpdateBookGroup[bookDir].filter(b => b.includes('/')) |       var itemDirNestedFiles = fileUpdateGroup[itemDir].filter(b => b.includes('/')) | ||||||
|       if (!bookDirNestedFiles.length) continue; |       if (!itemDirNestedFiles.length) continue; | ||||||
| 
 | 
 | ||||||
|       var firstNest = bookDirNestedFiles[0].split('/').shift() |       var firstNest = itemDirNestedFiles[0].split('/').shift() | ||||||
|       var altDir = `${bookDir}/${firstNest}` |       var altDir = `${itemDir}/${firstNest}` | ||||||
| 
 | 
 | ||||||
|       var fullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), bookDir) |       var fullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), itemDir) | ||||||
|       var childAudiobook = this.db.audiobooks.find(ab => ab.fullPath !== fullPath && ab.fullPath.startsWith(fullPath)) |       var childLibraryItem = this.db.libraryItems.find(li => li.path !== fullPath && li.fullPath.startsWith(fullPath)) | ||||||
|       if (!childAudiobook) { |       if (!childLibraryItem) { | ||||||
|         continue; |         continue; | ||||||
|       } |       } | ||||||
|       var altFullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), altDir) |       var altFullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), altDir) | ||||||
|       var altChildAudiobook = this.db.audiobooks.find(ab => ab.fullPath !== altFullPath && ab.fullPath.startsWith(altFullPath)) |       var altChildLibraryItem = this.db.libraryItems.find(li => li.path !== altFullPath && li.path.startsWith(altFullPath)) | ||||||
|       if (altChildAudiobook) { |       if (altChildLibraryItem) { | ||||||
|         continue; |         continue; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       delete fileUpdateBookGroup[bookDir] |       delete fileUpdateGroup[itemDir] | ||||||
|       fileUpdateBookGroup[altDir] = bookDirNestedFiles.map((f) => f.split('/').slice(1).join('/')) |       fileUpdateGroup[altDir] = itemDirNestedFiles.map((f) => f.split('/').slice(1).join('/')) | ||||||
|       Logger.warn(`[Scanner] Some files were modified in a parent directory of an audiobook "${childAudiobook.title}" - ignoring`) |       Logger.warn(`[Scanner] Some files were modified in a parent directory of a library item "${childLibraryItem.title}" - ignoring`) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     // Second pass: Check for new/updated/removed audiobooks
 |     // Second pass: Check for new/updated/removed items
 | ||||||
|     var bookGroupingResults = {} |     var itemGroupingResults = {} | ||||||
|     for (const bookDir in fileUpdateBookGroup) { |     for (const itemDir in fileUpdateGroup) { | ||||||
|       var fullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), bookDir) |       var fullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), itemDir) | ||||||
| 
 | 
 | ||||||
|       // Check if book dir group is already an audiobook
 |       // Check if book dir group is already an item
 | ||||||
|       var existingAudiobook = this.db.audiobooks.find(ab => fullPath.startsWith(ab.fullPath)) |       var existingLibraryItem = this.db.libraryItems.find(li => fullPath.startsWith(li.path)) | ||||||
|       if (existingAudiobook) { |       if (existingLibraryItem) { | ||||||
| 
 | 
 | ||||||
|         // Is the audiobook exactly - check if was deleted
 |         // Is the item exactly - check if was deleted
 | ||||||
|         if (existingAudiobook.fullPath === fullPath) { |         if (existingLibraryItem.path === fullPath) { | ||||||
|           var exists = await fs.pathExists(fullPath) |           var exists = await fs.pathExists(fullPath) | ||||||
|           if (!exists) { |           if (!exists) { | ||||||
|             Logger.info(`[Scanner] Scanning file update group and audiobook was deleted "${existingAudiobook.title}" - marking as missing`) |             Logger.info(`[Scanner] Scanning file update group and library item was deleted "${existingLibraryItem.media.metadata.title}" - marking as missing`) | ||||||
|             existingAudiobook.setMissing() |             existingLibraryItem.setMissing() | ||||||
|             await this.db.updateAudiobook(existingAudiobook) |             await this.db.updateLibraryItem(existingLibraryItem) | ||||||
|             this.emitter('audiobook_updated', existingAudiobook.toJSONExpanded()) |             this.emitter('item_updated', existingLibraryItem.toJSONExpanded()) | ||||||
| 
 | 
 | ||||||
|             bookGroupingResults[bookDir] = ScanResult.REMOVED |             itemGroupingResults[itemDir] = ScanResult.REMOVED | ||||||
|             continue; |             continue; | ||||||
|           } |           } | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         // Scan audiobook for updates
 |         // Scan library item for updates
 | ||||||
|         Logger.debug(`[Scanner] Folder update for relative path "${bookDir}" is in audiobook "${existingAudiobook.title}" - scan for updates`) |         Logger.debug(`[Scanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.metadata.title}" - scan for updates`) | ||||||
|         bookGroupingResults[bookDir] = await this.scanAudiobook(folder, existingAudiobook) |         itemGroupingResults[itemDir] = await this.scanLibraryItem(library.mediaType, folder, existingLibraryItem) | ||||||
|         continue; |         continue; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       // Check if an audiobook is a subdirectory of this dir
 |       // Check if a library item is a subdirectory of this dir
 | ||||||
|       var childAudiobook = this.db.audiobooks.find(ab => ab.fullPath.startsWith(fullPath)) |       var childItem = this.db.libraryItems.find(li => li.path.startsWith(fullPath)) | ||||||
|       if (childAudiobook) { |       if (childItem) { | ||||||
|         Logger.warn(`[Scanner] Files were modified in a parent directory of an audiobook "${childAudiobook.title}" - ignoring`) |         Logger.warn(`[Scanner] Files were modified in a parent directory of a library item "${childItem.media.metadata.title}" - ignoring`) | ||||||
|         bookGroupingResults[bookDir] = ScanResult.NOTHING |         itemGroupingResults[itemDir] = ScanResult.NOTHING | ||||||
|         continue; |         continue; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       Logger.debug(`[Scanner] Folder update group must be a new book "${bookDir}" in library "${library.name}"`) |       Logger.debug(`[Scanner] Folder update group must be a new item "${itemDir}" in library "${library.name}"`) | ||||||
|       var newAudiobook = await this.scanPotentialNewAudiobook(folder, fullPath) |       var newLibraryItem = await this.scanPotentialNewLibraryItem(library.mediaType, folder, fullPath) | ||||||
|       if (newAudiobook) { |       if (newLibraryItem) { | ||||||
|         await this.db.insertAudiobook(newAudiobook) |         await this.db.insertLibraryItem(newLibraryItem) | ||||||
|         this.emitter('audiobook_added', newAudiobook.toJSONExpanded()) |         this.emitter('item_added', newLibraryItem.toJSONExpanded()) | ||||||
|       } |       } | ||||||
|       bookGroupingResults[bookDir] = newAudiobook ? ScanResult.ADDED : ScanResult.NOTHING |       itemGroupingResults[itemDir] = newLibraryItem ? ScanResult.ADDED : ScanResult.NOTHING | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     return bookGroupingResults |     return itemGroupingResults | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async scanPotentialNewAudiobook(folder, fullPath) { |   async scanPotentialNewLibraryItem(libraryMediaType, folder, fullPath) { | ||||||
|     var audiobookData = await getAudiobookFileData(folder, fullPath, this.db.serverSettings) |     var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, this.db.serverSettings) | ||||||
|     if (!audiobookData) return null |     if (!libraryItemData) return null | ||||||
|     var serverSettings = this.db.serverSettings |     var serverSettings = this.db.serverSettings | ||||||
|     return this.scanNewAudiobook(audiobookData, serverSettings.scannerPreferAudioMetadata, serverSettings.scannerPreferOpfMetadata, serverSettings.scannerFindCovers) |     return this.scanNewLibraryItem(libraryItemData, libraryMediaType, serverSettings.scannerPreferAudioMetadata, serverSettings.scannerPreferOpfMetadata, serverSettings.scannerFindCovers) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async searchForCover(audiobook, libraryScan = null) { |   async searchForCover(libraryItem, libraryScan = null) { | ||||||
|     var options = { |     var options = { | ||||||
|       titleDistance: 2, |       titleDistance: 2, | ||||||
|       authorDistance: 2 |       authorDistance: 2 | ||||||
|     } |     } | ||||||
|     var scannerCoverProvider = this.db.serverSettings.scannerCoverProvider |     var scannerCoverProvider = this.db.serverSettings.scannerCoverProvider | ||||||
|     var results = await this.bookFinder.findCovers(scannerCoverProvider, audiobook.title, audiobook.authorFL, options) |     var results = await this.bookFinder.findCovers(scannerCoverProvider, libraryItem.media.metadata.title, libraryItem.media.metadata.authorName, options) | ||||||
|     if (results.length) { |     if (results.length) { | ||||||
|       if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Found best cover for "${audiobook.title}"`) |       if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Found best cover for "${libraryItem.media.metadata.title}"`) | ||||||
|       else Logger.debug(`[Scanner] Found best cover for "${audiobook.title}"`) |       else Logger.debug(`[Scanner] Found best cover for "${libraryItem.media.metadata.title}"`) | ||||||
| 
 | 
 | ||||||
|       // If the first cover result fails, attempt to download the second
 |       // If the first cover result fails, attempt to download the second
 | ||||||
|       for (let i = 0; i < results.length && i < 2; i++) { |       for (let i = 0; i < results.length && i < 2; i++) { | ||||||
| 
 | 
 | ||||||
|         // Downloads and updates the book cover
 |         // Downloads and updates the book cover
 | ||||||
|         var result = await this.coverController.downloadCoverFromUrl(audiobook, results[i]) |         var result = await this.coverController.downloadCoverFromUrl(libraryItem, results[i]) | ||||||
| 
 | 
 | ||||||
|         if (result.error) { |         if (result.error) { | ||||||
|           Logger.error(`[Scanner] Failed to download cover from url "${results[i]}" | Attempt ${i + 1}`, result.error) |           Logger.error(`[Scanner] Failed to download cover from url "${results[i]}" | Attempt ${i + 1}`, result.error) | ||||||
|  | |||||||
| @ -8,8 +8,6 @@ const bookKeyMap = { | |||||||
|   subtitle: 'subtitle', |   subtitle: 'subtitle', | ||||||
|   author: 'authorFL', |   author: 'authorFL', | ||||||
|   narrator: 'narratorFL', |   narrator: 'narratorFL', | ||||||
|   series: 'series', |  | ||||||
|   volumeNumber: 'volumeNumber', |  | ||||||
|   publishYear: 'publishYear', |   publishYear: 'publishYear', | ||||||
|   publisher: 'publisher', |   publisher: 'publisher', | ||||||
|   description: 'description', |   description: 'description', | ||||||
| @ -39,7 +37,7 @@ function generate(audiobook, outputPath) { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   return fs.writeFile(outputPath, fileString).then(() => { |   return fs.writeFile(outputPath, fileString).then(() => { | ||||||
|     return filePerms(outputPath, 0o774, global.Uid, global.Gid, true).then((data) => true) |     return filePerms.setDefault(outputPath, true).then(() => true) | ||||||
|   }).catch((error) => { |   }).catch((error) => { | ||||||
|     Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error) |     Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error) | ||||||
|     return false |     return false | ||||||
|  | |||||||
| @ -77,7 +77,19 @@ const chmodr = (p, mode, uid, gid, cb) => { | |||||||
|   }) |   }) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| module.exports = (path, mode, uid, gid, silent = false) => { | // Set custom permissions
 | ||||||
|  | module.exports.set = (path, mode, uid, gid, silent = false) => { | ||||||
|  |   return new Promise((resolve) => { | ||||||
|  |     if (!silent) Logger.debug(`[FilePerms] Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`) | ||||||
|  |     chmodr(path, mode, uid, gid, resolve) | ||||||
|  |   }) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Default permissions 0o744 and global Uid/Gid
 | ||||||
|  | module.exports.setDefault = (path, silent = false) => { | ||||||
|  |   const mode = 0o744 | ||||||
|  |   const uid = global.Uid | ||||||
|  |   const gid = global.Gid | ||||||
|   return new Promise((resolve) => { |   return new Promise((resolve) => { | ||||||
|     if (!silent) Logger.debug(`[FilePerms] Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`) |     if (!silent) Logger.debug(`[FilePerms] Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`) | ||||||
|     chmodr(path, mode, uid, gid, resolve) |     chmodr(path, mode, uid, gid, resolve) | ||||||
|  | |||||||
| @ -3,7 +3,7 @@ const globals = { | |||||||
|   SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'mp4', 'aac'], |   SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'mp4', 'aac'], | ||||||
|   SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], |   SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], | ||||||
|   TextFileTypes: ['txt', 'nfo'], |   TextFileTypes: ['txt', 'nfo'], | ||||||
|   MetadataFileTypes: ['opf', 'abs'] |   MetadataFileTypes: ['opf', 'abs', 'xml'] | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| module.exports = globals | module.exports = globals | ||||||
|  | |||||||
| @ -1,75 +0,0 @@ | |||||||
| const parseFullName = require('./parseFullName') |  | ||||||
| 
 |  | ||||||
| function parseName(name) { |  | ||||||
|   var parts = parseFullName(name) |  | ||||||
|   var firstName = parts.first |  | ||||||
|   if (firstName && parts.middle) firstName += ' ' + parts.middle |  | ||||||
| 
 |  | ||||||
|   return { |  | ||||||
|     first_name: firstName, |  | ||||||
|     last_name: parts.last |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // Check if this name segment is of the format "Last, First" or "First Last"
 |  | ||||||
| // return true is "Last, First"
 |  | ||||||
| function checkIsALastName(name) { |  | ||||||
|   if (!name.includes(' ')) return true // No spaces must be a Last name
 |  | ||||||
| 
 |  | ||||||
|   var parsed = parseFullName(name) |  | ||||||
|   if (!parsed.first) return true // had spaces but not a first name i.e. "von Mises", must be last name only
 |  | ||||||
| 
 |  | ||||||
|   return false |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| module.exports = (author) => { |  | ||||||
|   if (!author) return null |  | ||||||
| 
 |  | ||||||
|   var splitAuthors = [] |  | ||||||
|   // Example &LF: Friedman, Milton & Friedman, Rose
 |  | ||||||
|   if (author.includes('&')) { |  | ||||||
|     author.split('&').forEach((asa) => splitAuthors = splitAuthors.concat(asa.split(','))) |  | ||||||
|   } else { |  | ||||||
|     splitAuthors = author.split(',') |  | ||||||
|   } |  | ||||||
|   if (splitAuthors.length) splitAuthors = splitAuthors.map(a => a.trim()) |  | ||||||
| 
 |  | ||||||
|   var authors = [] |  | ||||||
| 
 |  | ||||||
|   // 1 author FIRST LAST
 |  | ||||||
|   if (splitAuthors.length === 1) { |  | ||||||
|     authors.push(parseName(author)) |  | ||||||
|   } else { |  | ||||||
|     var firstChunkIsALastName = checkIsALastName(splitAuthors[0]) |  | ||||||
|     var isEvenNum = splitAuthors.length % 2 === 0 |  | ||||||
| 
 |  | ||||||
|     if (!isEvenNum && firstChunkIsALastName) { |  | ||||||
|       // console.error('Multi-author LAST,FIRST entry has a straggler (could be roman numerals or a suffix), ignore it')
 |  | ||||||
|       splitAuthors = splitAuthors.slice(0, splitAuthors.length - 1) |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     if (firstChunkIsALastName) { |  | ||||||
|       var numAuthors = splitAuthors.length / 2 |  | ||||||
|       for (let i = 0; i < numAuthors; i++) { |  | ||||||
|         var last = splitAuthors.shift() |  | ||||||
|         var first = splitAuthors.shift() |  | ||||||
|         authors.push({ |  | ||||||
|           first_name: first, |  | ||||||
|           last_name: last |  | ||||||
|         }) |  | ||||||
|       } |  | ||||||
|     } else { |  | ||||||
|       splitAuthors.forEach((segment) => { |  | ||||||
|         authors.push(parseName(segment)) |  | ||||||
|       }) |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   var firstLast = authors.length ? authors.map(a => a.first_name ? `${a.first_name} ${a.last_name}` : a.last_name).join(', ') : '' |  | ||||||
|   var lastFirst = authors.length ? authors.map(a => a.first_name ? `${a.last_name}, ${a.first_name}` : a.last_name).join(', ') : '' |  | ||||||
|   return { |  | ||||||
|     authorFL: firstLast, |  | ||||||
|     authorLF: lastFirst, |  | ||||||
|     authorsParsed: authors |  | ||||||
|   } |  | ||||||
| } |  | ||||||
							
								
								
									
										82
									
								
								server/utils/parseNameString.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								server/utils/parseNameString.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,82 @@ | |||||||
|  | //
 | ||||||
|  | // This takes a string and parsed out first and last names
 | ||||||
|  | //   accepts comma separated lists e.g. "Jon Smith, Jane Smith" or "Smith, Jon, Smith, Jane"
 | ||||||
|  | //   can be separated by "&" e.g. "Jon Smith & Jane Smith" or "Smith, Jon & Smith, Jane"
 | ||||||
|  | //
 | ||||||
|  | const parseFullName = require('./parseFullName') | ||||||
|  | 
 | ||||||
|  | function parseName(name) { | ||||||
|  |   var parts = parseFullName(name) | ||||||
|  |   var firstName = parts.first | ||||||
|  |   if (firstName && parts.middle) firstName += ' ' + parts.middle | ||||||
|  | 
 | ||||||
|  |   return { | ||||||
|  |     first_name: firstName, | ||||||
|  |     last_name: parts.last | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Check if this name segment is of the format "Last, First" or "First Last"
 | ||||||
|  | // return true is "Last, First"
 | ||||||
|  | function checkIsALastName(name) { | ||||||
|  |   if (!name.includes(' ')) return true // No spaces must be a Last name
 | ||||||
|  | 
 | ||||||
|  |   var parsed = parseFullName(name) | ||||||
|  |   if (!parsed.first) return true // had spaces but not a first name i.e. "von Mises", must be last name only
 | ||||||
|  | 
 | ||||||
|  |   return false | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | module.exports = (nameString) => { | ||||||
|  |   if (!nameString) return null | ||||||
|  | 
 | ||||||
|  |   var splitNames = [] | ||||||
|  |   // Example &LF: Friedman, Milton & Friedman, Rose
 | ||||||
|  |   if (nameString.includes('&')) { | ||||||
|  |     nameString.split('&').forEach((asa) => splitNames = splitNames.concat(asa.split(','))) | ||||||
|  |   } else { | ||||||
|  |     splitNames = nameString.split(',') | ||||||
|  |   } | ||||||
|  |   if (splitNames.length) splitNames = splitNames.map(a => a.trim()) | ||||||
|  | 
 | ||||||
|  |   var names = [] | ||||||
|  | 
 | ||||||
|  |   // 1 name FIRST LAST
 | ||||||
|  |   if (splitNames.length === 1) { | ||||||
|  |     names.push(parseName(nameString)) | ||||||
|  |   } else { | ||||||
|  |     var firstChunkIsALastName = checkIsALastName(splitNames[0]) | ||||||
|  |     var isEvenNum = splitNames.length % 2 === 0 | ||||||
|  | 
 | ||||||
|  |     if (!isEvenNum && firstChunkIsALastName) { | ||||||
|  |       // console.error('Multi-name LAST,FIRST entry has a straggler (could be roman numerals or a suffix), ignore it')
 | ||||||
|  |       splitNames = splitNames.slice(0, splitNames.length - 1) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (firstChunkIsALastName) { | ||||||
|  |       var num = splitNames.length / 2 | ||||||
|  |       for (let i = 0; i < num; i++) { | ||||||
|  |         var last = splitNames.shift() | ||||||
|  |         var first = splitNames.shift() | ||||||
|  |         names.push({ | ||||||
|  |           first_name: first, | ||||||
|  |           last_name: last | ||||||
|  |         }) | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       splitNames.forEach((segment) => { | ||||||
|  |         names.push(parseName(segment)) | ||||||
|  |       }) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   var namesArray = names.map(a => a.first_name ? `${a.first_name} ${a.last_name}` : a.last_name) | ||||||
|  |   var firstLast = names.length ? namesArray.join(', ') : '' | ||||||
|  |   var lastFirst = names.length ? names.map(a => a.first_name ? `${a.last_name}, ${a.first_name}` : a.last_name).join(', ') : '' | ||||||
|  | 
 | ||||||
|  |   return { | ||||||
|  |     nameFL: firstLast, // String of comma separated first last
 | ||||||
|  |     nameLF: lastFirst, // String of comma separated last, first
 | ||||||
|  |     names: namesArray // Array of first last
 | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -71,20 +71,20 @@ function fetchLanguage(metadata) { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function fetchSeries(metadata) { | function fetchSeries(metadata) { | ||||||
|   if(typeof metadata.meta == "undefined") return null |   if (typeof metadata.meta == "undefined") return null | ||||||
|   return fetchTagString(metadata.meta, "calibre:series") |   return fetchTagString(metadata.meta, "calibre:series") | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function fetchVolumeNumber(metadata) { | function fetchVolumeNumber(metadata) { | ||||||
|   if(typeof metadata.meta == "undefined") return null |   if (typeof metadata.meta == "undefined") return null | ||||||
|   return fetchTagString(metadata.meta, "calibre:series_index") |   return fetchTagString(metadata.meta, "calibre:series_index") | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function fetchNarrators(creators, metadata) { | function fetchNarrators(creators, metadata) { | ||||||
|   var roleNrt = fetchCreator(creators, 'nrt') |   var roleNrt = fetchCreator(creators, 'nrt') | ||||||
|   if(typeof metadata.meta == "undefined" || roleNrt != null) return roleNrt |   if (typeof metadata.meta == "undefined" || roleNrt != null) return roleNrt | ||||||
|   try { |   try { | ||||||
|     var narratorsJSON = JSON.parse(fetchTagString(metadata.meta, "calibre:user_metadata:#narrators").replace(/"/g,'"')) |     var narratorsJSON = JSON.parse(fetchTagString(metadata.meta, "calibre:user_metadata:#narrators").replace(/"/g, '"')) | ||||||
|     return narratorsJSON["#value#"].join(", ") |     return narratorsJSON["#value#"].join(", ") | ||||||
|   } catch { |   } catch { | ||||||
|     return null |     return null | ||||||
| @ -103,7 +103,7 @@ module.exports.parseOpfMetadataXML = async (xml) => { | |||||||
| 
 | 
 | ||||||
|   if (typeof metadata.meta != "undefined") { |   if (typeof metadata.meta != "undefined") { | ||||||
|     metadata.meta = {} |     metadata.meta = {} | ||||||
|     for(var match of xml.matchAll(/<meta name="(?<name>.+)" content="(?<content>.+)"\/>/g)) { |     for (var match of xml.matchAll(/<meta name="(?<name>.+)" content="(?<content>.+)"\/>/g)) { | ||||||
|       metadata.meta[match.groups['name']] = [match.groups['content']] |       metadata.meta[match.groups['name']] = [match.groups['content']] | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| @ -120,7 +120,7 @@ module.exports.parseOpfMetadataXML = async (xml) => { | |||||||
|     genres: fetchGenres(metadata), |     genres: fetchGenres(metadata), | ||||||
|     language: fetchLanguage(metadata), |     language: fetchLanguage(metadata), | ||||||
|     series: fetchSeries(metadata), |     series: fetchSeries(metadata), | ||||||
|     volumeNumber: fetchVolumeNumber(metadata) |     sequence: fetchVolumeNumber(metadata) | ||||||
|   } |   } | ||||||
|   return data |   return data | ||||||
| } | } | ||||||
| @ -3,8 +3,9 @@ const fs = require('fs-extra') | |||||||
| const Logger = require('../Logger') | const Logger = require('../Logger') | ||||||
| const { recurseFiles, getFileTimestampsWithIno } = require('./fileUtils') | const { recurseFiles, getFileTimestampsWithIno } = require('./fileUtils') | ||||||
| const globals = require('./globals') | const globals = require('./globals') | ||||||
|  | const LibraryFile = require('../objects/files/LibraryFile') | ||||||
| 
 | 
 | ||||||
| function isBookFile(path) { | function isMediaFile(path) { | ||||||
|   if (!path) return false |   if (!path) return false | ||||||
|   var ext = Path.extname(path) |   var ext = Path.extname(path) | ||||||
|   if (!ext) return false |   if (!ext) return false | ||||||
| @ -14,8 +15,8 @@ function isBookFile(path) { | |||||||
| 
 | 
 | ||||||
| // TODO: Function needs to be re-done
 | // TODO: Function needs to be re-done
 | ||||||
| // Input: array of relative file paths
 | // Input: array of relative file paths
 | ||||||
| // Output: map of files grouped into potential audiobook dirs
 | // Output: map of files grouped into potential item dirs
 | ||||||
| function groupFilesIntoAudiobookPaths(paths) { | function groupFilesIntoLibraryItemPaths(paths) { | ||||||
|   // Step 1: Clean path, Remove leading "/", Filter out files in root dir
 |   // Step 1: Clean path, Remove leading "/", Filter out files in root dir
 | ||||||
|   var pathsFiltered = paths.map(path => { |   var pathsFiltered = paths.map(path => { | ||||||
|     return path.startsWith('/') ? path.slice(1) : path |     return path.startsWith('/') ? path.slice(1) : path | ||||||
| @ -29,7 +30,7 @@ function groupFilesIntoAudiobookPaths(paths) { | |||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|   // Step 3: Group files in dirs
 |   // Step 3: Group files in dirs
 | ||||||
|   var audiobookGroup = {} |   var itemGroup = {} | ||||||
|   pathsFiltered.forEach((path) => { |   pathsFiltered.forEach((path) => { | ||||||
|     var dirparts = Path.dirname(path).split('/') |     var dirparts = Path.dirname(path).split('/') | ||||||
|     var numparts = dirparts.length |     var numparts = dirparts.length | ||||||
| @ -40,41 +41,41 @@ function groupFilesIntoAudiobookPaths(paths) { | |||||||
|       var dirpart = dirparts.shift() |       var dirpart = dirparts.shift() | ||||||
|       _path = Path.posix.join(_path, dirpart) |       _path = Path.posix.join(_path, dirpart) | ||||||
| 
 | 
 | ||||||
|       if (audiobookGroup[_path]) { // Directory already has files, add file
 |       if (itemGroup[_path]) { // Directory already has files, add file
 | ||||||
|         var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path)) |         var relpath = Path.posix.join(dirparts.join('/'), Path.basename(path)) | ||||||
|         audiobookGroup[_path].push(relpath) |         itemGroup[_path].push(relpath) | ||||||
|         return |         return | ||||||
|       } else if (!dirparts.length) { // This is the last directory, create group
 |       } else if (!dirparts.length) { // This is the last directory, create group
 | ||||||
|         audiobookGroup[_path] = [Path.basename(path)] |         itemGroup[_path] = [Path.basename(path)] | ||||||
|         return |         return | ||||||
|       } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group
 |       } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group
 | ||||||
|         audiobookGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))] |         itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))] | ||||||
|         return |         return | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   }) |   }) | ||||||
|   return audiobookGroup |   return itemGroup | ||||||
| } | } | ||||||
| module.exports.groupFilesIntoAudiobookPaths = groupFilesIntoAudiobookPaths | module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths | ||||||
| 
 | 
 | ||||||
| // Input: array of relative file items (see recurseFiles)
 | // Input: array of relative file items (see recurseFiles)
 | ||||||
| // Output: map of files grouped into potential audiobook dirs
 | // Output: map of files grouped into potential libarary item dirs
 | ||||||
| function groupFileItemsIntoBooks(fileItems) { | function groupFileItemsIntoLibraryItemDirs(fileItems) { | ||||||
|   // Step 1: Filter out files in root dir (with depth of 0)
 |   // Step 1: Filter out files in root dir (with depth of 0)
 | ||||||
|   var itemsFiltered = fileItems.filter(i => i.deep > 0) |   var itemsFiltered = fileItems.filter(i => i.deep > 0) | ||||||
| 
 | 
 | ||||||
|   // Step 2: Seperate audio/ebook files and other files
 |   // Step 2: Seperate media files and other files
 | ||||||
|   //     - Directories without an audio or ebook file will not be included
 |   //     - Directories without a media file will not be included
 | ||||||
|   var bookFileItems = [] |   var mediaFileItems = [] | ||||||
|   var otherFileItems = [] |   var otherFileItems = [] | ||||||
|   itemsFiltered.forEach(item => { |   itemsFiltered.forEach(item => { | ||||||
|     if (isBookFile(item.fullpath)) bookFileItems.push(item) |     if (isMediaFile(item.fullpath)) mediaFileItems.push(item) | ||||||
|     else otherFileItems.push(item) |     else otherFileItems.push(item) | ||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|   // Step 3: Group audio files in audiobooks
 |   // Step 3: Group audio files in library items
 | ||||||
|   var audiobookGroup = {} |   var libraryItemGroup = {} | ||||||
|   bookFileItems.forEach((item) => { |   mediaFileItems.forEach((item) => { | ||||||
|     var dirparts = item.reldirpath.split('/') |     var dirparts = item.reldirpath.split('/') | ||||||
|     var numparts = dirparts.length |     var numparts = dirparts.length | ||||||
|     var _path = '' |     var _path = '' | ||||||
| @ -84,21 +85,21 @@ function groupFileItemsIntoBooks(fileItems) { | |||||||
|       var dirpart = dirparts.shift() |       var dirpart = dirparts.shift() | ||||||
|       _path = Path.posix.join(_path, dirpart) |       _path = Path.posix.join(_path, dirpart) | ||||||
| 
 | 
 | ||||||
|       if (audiobookGroup[_path]) { // Directory already has files, add file
 |       if (libraryItemGroup[_path]) { // Directory already has files, add file
 | ||||||
|         var relpath = Path.posix.join(dirparts.join('/'), item.name) |         var relpath = Path.posix.join(dirparts.join('/'), item.name) | ||||||
|         audiobookGroup[_path].push(relpath) |         libraryItemGroup[_path].push(relpath) | ||||||
|         return |         return | ||||||
|       } else if (!dirparts.length) { // This is the last directory, create group
 |       } else if (!dirparts.length) { // This is the last directory, create group
 | ||||||
|         audiobookGroup[_path] = [item.name] |         libraryItemGroup[_path] = [item.name] | ||||||
|         return |         return | ||||||
|       } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group
 |       } else if (dirparts.length === 1 && /^cd\d{1,3}$/i.test(dirparts[0])) { // Next directory is the last and is a CD dir, create group
 | ||||||
|         audiobookGroup[_path] = [Path.posix.join(dirparts[0], item.name)] |         libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)] | ||||||
|         return |         return | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|   // Step 4: Add other files into audiobook groups
 |   // Step 4: Add other files into library item groups
 | ||||||
|   otherFileItems.forEach((item) => { |   otherFileItems.forEach((item) => { | ||||||
|     var dirparts = item.reldirpath.split('/') |     var dirparts = item.reldirpath.split('/') | ||||||
|     var numparts = dirparts.length |     var numparts = dirparts.length | ||||||
| @ -108,30 +109,23 @@ function groupFileItemsIntoBooks(fileItems) { | |||||||
|     for (let i = 0; i < numparts; i++) { |     for (let i = 0; i < numparts; i++) { | ||||||
|       var dirpart = dirparts.shift() |       var dirpart = dirparts.shift() | ||||||
|       _path = Path.posix.join(_path, dirpart) |       _path = Path.posix.join(_path, dirpart) | ||||||
|       if (audiobookGroup[_path]) { // Directory is audiobook group
 |       if (libraryItemGroup[_path]) { // Directory is audiobook group
 | ||||||
|         var relpath = Path.posix.join(dirparts.join('/'), item.name) |         var relpath = Path.posix.join(dirparts.join('/'), item.name) | ||||||
|         audiobookGroup[_path].push(relpath) |         libraryItemGroup[_path].push(relpath) | ||||||
|         return |         return | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|   }) |   }) | ||||||
|   return audiobookGroup |   return libraryItemGroup | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function cleanFileObjects(basepath, abrelpath, files) { | function cleanFileObjects(libraryItemPath, libraryItemRelPath, files) { | ||||||
|   return Promise.all(files.map(async (file) => { |   return Promise.all(files.map(async (file) => { | ||||||
|     var fullPath = Path.posix.join(basepath, file) |     var filePath = Path.posix.join(libraryItemPath, file) | ||||||
|     var fileTsData = await getFileTimestampsWithIno(fullPath) |     var relFilePath = Path.posix.join(libraryItemRelPath, file) | ||||||
| 
 |     var newLibraryFile = new LibraryFile() | ||||||
|     var ext = Path.extname(file) |     await newLibraryFile.setDataFromPath(filePath, relFilePath) | ||||||
|     return { |     return newLibraryFile | ||||||
|       filetype: getFileType(ext), |  | ||||||
|       filename: Path.basename(file), |  | ||||||
|       path: Path.posix.join(abrelpath, file), // /AUDIOBOOK/PATH/filename.mp3
 |  | ||||||
|       fullPath, // /audiobooks/AUDIOBOOK/PATH/filename.mp3
 |  | ||||||
|       ext: ext, |  | ||||||
|       ...fileTsData |  | ||||||
|     } |  | ||||||
|   })) |   })) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @ -148,9 +142,8 @@ function getFileType(ext) { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Scan folder
 | // Scan folder
 | ||||||
| async function scanRootDir(folder, serverSettings = {}) { | async function scanFolder(libraryMediaType, folder, serverSettings = {}) { | ||||||
|   var folderPath = folder.fullPath.replace(/\\/g, '/') |   var folderPath = folder.fullPath.replace(/\\/g, '/') | ||||||
|   var parseSubtitle = !!serverSettings.scannerParseSubtitle |  | ||||||
| 
 | 
 | ||||||
|   var pathExists = await fs.pathExists(folderPath) |   var pathExists = await fs.pathExists(folderPath) | ||||||
|   if (!pathExists) { |   if (!pathExists) { | ||||||
| @ -160,39 +153,38 @@ async function scanRootDir(folder, serverSettings = {}) { | |||||||
| 
 | 
 | ||||||
|   var fileItems = await recurseFiles(folderPath) |   var fileItems = await recurseFiles(folderPath) | ||||||
| 
 | 
 | ||||||
|   var audiobookGrouping = groupFileItemsIntoBooks(fileItems) |   var libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(fileItems) | ||||||
| 
 | 
 | ||||||
|   if (!Object.keys(audiobookGrouping).length) { |   if (!Object.keys(libraryItemGrouping).length) { | ||||||
|     Logger.error('Root path has no books', fileItems.length) |     Logger.error('Root path has no media folders', fileItems.length) | ||||||
|     return [] |     return [] | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   var audiobooks = [] |   var items = [] | ||||||
|   for (const audiobookPath in audiobookGrouping) { |   for (const libraryItemPath in libraryItemGrouping) { | ||||||
|     var audiobookData = getAudiobookDataFromDir(folderPath, audiobookPath, parseSubtitle) |     var libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings) | ||||||
| 
 | 
 | ||||||
|     var fileObjs = await cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath]) |     var fileObjs = await cleanFileObjects(libraryItemData.path, libraryItemData.relPath, libraryItemGrouping[libraryItemPath]) | ||||||
|     var audiobookFolderStats = await getFileTimestampsWithIno(audiobookData.fullPath) |     var libraryItemFolderStats = await getFileTimestampsWithIno(libraryItemData.path) | ||||||
|     audiobooks.push({ |     items.push({ | ||||||
|       folderId: folder.id, |       folderId: folder.id, | ||||||
|       libraryId: folder.libraryId, |       libraryId: folder.libraryId, | ||||||
|       ino: audiobookFolderStats.ino, |       ino: libraryItemFolderStats.ino, | ||||||
|       mtimeMs: audiobookFolderStats.mtimeMs || 0, |       mtimeMs: libraryItemFolderStats.mtimeMs || 0, | ||||||
|       ctimeMs: audiobookFolderStats.ctimeMs || 0, |       ctimeMs: libraryItemFolderStats.ctimeMs || 0, | ||||||
|       birthtimeMs: audiobookFolderStats.birthtimeMs || 0, |       birthtimeMs: libraryItemFolderStats.birthtimeMs || 0, | ||||||
|       ...audiobookData, |       ...libraryItemData, | ||||||
|       audioFiles: fileObjs.filter(f => f.filetype === 'audio'), |       libraryFiles: fileObjs | ||||||
|       otherFiles: fileObjs.filter(f => f.filetype !== 'audio') |  | ||||||
|     }) |     }) | ||||||
|   } |   } | ||||||
|   return audiobooks |   return items | ||||||
| } | } | ||||||
| module.exports.scanRootDir = scanRootDir | module.exports.scanFolder = scanFolder | ||||||
| 
 | 
 | ||||||
| // Input relative filepath, output all details that can be parsed
 | // Input relative filepath, output all details that can be parsed
 | ||||||
| function getAudiobookDataFromDir(folderPath, dir, parseSubtitle = false) { | function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) { | ||||||
|   dir = dir.replace(/\\/g, '/') |   relPath = relPath.replace(/\\/g, '/') | ||||||
|   var splitDir = dir.split('/') |   var splitDir = relPath.split('/') | ||||||
| 
 | 
 | ||||||
|   // Audio files will always be in the directory named for the title
 |   // Audio files will always be in the directory named for the title
 | ||||||
|   var title = splitDir.pop() |   var title = splitDir.pop() | ||||||
| @ -244,7 +236,6 @@ function getAudiobookDataFromDir(folderPath, dir, parseSubtitle = false) { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
|   var publishYear = null |   var publishYear = null | ||||||
|   // If Title is of format 1999 OR (1999) - Title, then use 1999 as publish year
 |   // If Title is of format 1999 OR (1999) - Title, then use 1999 as publish year
 | ||||||
|   var publishYearMatch = title.match(/^(\(?[0-9]{4}\)?) - (.+)/) |   var publishYearMatch = title.match(/^(\(?[0-9]{4}\)?) - (.+)/) | ||||||
| @ -270,58 +261,52 @@ function getAudiobookDataFromDir(folderPath, dir, parseSubtitle = false) { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   return { |   return { | ||||||
|     author, |     mediaMetadata: { | ||||||
|     title, |       author, | ||||||
|     subtitle, |       title, | ||||||
|     series, |       subtitle, | ||||||
|     volumeNumber, |       series, | ||||||
|     publishYear, |       sequence: volumeNumber, | ||||||
|     path: dir, // relative audiobook path i.e. /Author Name/Book Name/..
 |       publishYear, | ||||||
|     fullPath: Path.posix.join(folderPath, dir) // i.e. /audiobook/Author Name/Book Name/..
 |     }, | ||||||
|  |     relPath: relPath, // relative audiobook path i.e. /Author Name/Book Name/..
 | ||||||
|  |     path: Path.posix.join(folderPath, relPath) // i.e. /audiobook/Author Name/Book Name/..
 | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async function getAudiobookFileData(folder, audiobookPath, serverSettings = {}) { | function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettings) { | ||||||
|   var parseSubtitle = !!serverSettings.scannerParseSubtitle |   var parseSubtitle = !!serverSettings.scannerParseSubtitle | ||||||
|  |   return getBookDataFromDir(folderPath, relPath, parseSubtitle) | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
|   var fileItems = await recurseFiles(audiobookPath, folder.fullPath) |  | ||||||
| 
 | 
 | ||||||
|   audiobookPath = audiobookPath.replace(/\\/g, '/') | async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, serverSettings = {}) { | ||||||
|  |   var fileItems = await recurseFiles(libraryItemPath, folder.fullPath) | ||||||
|  | 
 | ||||||
|  |   libraryItemPath = libraryItemPath.replace(/\\/g, '/') | ||||||
|   var folderFullPath = folder.fullPath.replace(/\\/g, '/') |   var folderFullPath = folder.fullPath.replace(/\\/g, '/') | ||||||
| 
 | 
 | ||||||
|   var audiobookDir = audiobookPath.replace(folderFullPath, '').slice(1) |   var libraryItemDir = libraryItemPath.replace(folderFullPath, '').slice(1) | ||||||
|   var audiobookData = getAudiobookDataFromDir(folderFullPath, audiobookDir, parseSubtitle) |   var libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir, serverSettings) | ||||||
|   var audiobookFolderStats = await getFileTimestampsWithIno(audiobookData.fullPath) |   var libraryItemDirStats = await getFileTimestampsWithIno(libraryItemData.path) | ||||||
|   var audiobook = { |   var libraryItem = { | ||||||
|     ino: audiobookFolderStats.ino, |     ino: libraryItemDirStats.ino, | ||||||
|     mtimeMs: audiobookFolderStats.mtimeMs || 0, |     mtimeMs: libraryItemDirStats.mtimeMs || 0, | ||||||
|     ctimeMs: audiobookFolderStats.ctimeMs || 0, |     ctimeMs: libraryItemDirStats.ctimeMs || 0, | ||||||
|     birthtimeMs: audiobookFolderStats.birthtimeMs || 0, |     birthtimeMs: libraryItemDirStats.birthtimeMs || 0, | ||||||
|     folderId: folder.id, |     folderId: folder.id, | ||||||
|     libraryId: folder.libraryId, |     libraryId: folder.libraryId, | ||||||
|     ...audiobookData, |     ...libraryItemData, | ||||||
|     audioFiles: [], |     libraryFiles: [] | ||||||
|     otherFiles: [] |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   for (let i = 0; i < fileItems.length; i++) { |   for (let i = 0; i < fileItems.length; i++) { | ||||||
|     var fileItem = fileItems[i] |     var fileItem = fileItems[i] | ||||||
| 
 |     var newLibraryFile = new LibraryFile() | ||||||
|     var fileStatData = await getFileTimestampsWithIno(fileItem.fullpath) |     // fileItem.path is the relative path
 | ||||||
|     var fileObj = { |     await newLibraryFile.setDataFromPath(fileItem.fullpath, fileItem.path) | ||||||
|       filetype: getFileType(fileItem.extension), |     libraryItem.libraryFiles.push(newLibraryFile) | ||||||
|       filename: fileItem.name, |  | ||||||
|       path: fileItem.path, |  | ||||||
|       fullPath: fileItem.fullpath, |  | ||||||
|       ext: fileItem.extension, |  | ||||||
|       ...fileStatData |  | ||||||
|     } |  | ||||||
|     if (fileObj.filetype === 'audio') { |  | ||||||
|       audiobook.audioFiles.push(fileObj) |  | ||||||
|     } else { |  | ||||||
|       audiobook.otherFiles.push(fileObj) |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
|   return audiobook |   return libraryItem | ||||||
| } | } | ||||||
| module.exports.getAudiobookFileData = getAudiobookFileData | module.exports.getLibraryItemFileData = getLibraryItemFileData | ||||||
		Loading…
	
		Reference in New Issue
	
	Block a user