diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue
index ea692428..2985ffc3 100644
--- a/client/components/app/LazyBookshelf.vue
+++ b/client/components/app/LazyBookshelf.vue
@@ -425,42 +425,42 @@ export default {
this.handleScroll(scrollTop)
// }, 250)
},
- audiobookAdded(audiobook) {
- console.log('Audiobook added', audiobook)
+ libraryItemAdded(libraryItem) {
+ console.log('libraryItem added', libraryItem)
// TODO: Check if audiobook would be on this shelf
this.resetEntities()
},
- audiobookUpdated(audiobook) {
- console.log('Audiobook updated', audiobook)
+ libraryItemUpdated(libraryItem) {
+ console.log('Item updated', libraryItem)
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) {
- this.entities[indexOf] = audiobook
+ this.entities[indexOf] = libraryItem
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') {
- 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) {
- 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.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
- this.remountEntities()
+ this.executeRebuild()
}
}
},
- audiobooksAdded(audiobooks) {
- console.log('audiobooks added', audiobooks)
+ libraryItemsAdded(libraryItems) {
+ console.log('items added', libraryItems)
// TODO: Check if audiobook would be on this shelf
this.resetEntities()
},
- audiobooksUpdated(audiobooks) {
- audiobooks.forEach((ab) => {
- this.audiobookUpdated(ab)
+ libraryItemsUpdated(libraryItems) {
+ libraryItems.forEach((ab) => {
+ this.libraryItemUpdated(ab)
})
},
initSizeData(_bookshelf) {
@@ -525,11 +525,11 @@ export default {
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
if (this.$root.socket) {
- this.$root.socket.on('audiobook_updated', this.audiobookUpdated)
- this.$root.socket.on('audiobook_added', this.audiobookAdded)
- this.$root.socket.on('audiobook_removed', this.audiobookRemoved)
- this.$root.socket.on('audiobooks_updated', this.audiobooksUpdated)
- this.$root.socket.on('audiobooks_added', this.audiobooksAdded)
+ this.$root.socket.on('item_updated', this.libraryItemUpdated)
+ this.$root.socket.on('item_added', this.libraryItemAdded)
+ this.$root.socket.on('item_removed', this.libraryItemRemoved)
+ this.$root.socket.on('items_updated', this.libraryItemsUpdated)
+ this.$root.socket.on('items_added', this.libraryItemsAdded)
} else {
console.error('Bookshelf - Socket not initialized')
}
@@ -546,11 +546,11 @@ export default {
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
if (this.$root.socket) {
- this.$root.socket.off('audiobook_updated', this.audiobookUpdated)
- this.$root.socket.off('audiobook_added', this.audiobookAdded)
- this.$root.socket.off('audiobook_removed', this.audiobookRemoved)
- this.$root.socket.off('audiobooks_updated', this.audiobooksUpdated)
- this.$root.socket.off('audiobooks_added', this.audiobooksAdded)
+ this.$root.socket.off('item_updated', this.libraryItemUpdated)
+ this.$root.socket.off('item_added', this.libraryItemAdded)
+ this.$root.socket.off('item_removed', this.libraryItemRemoved)
+ this.$root.socket.off('items_updated', this.libraryItemsUpdated)
+ this.$root.socket.off('items_added', this.libraryItemsAdded)
} else {
console.error('Bookshelf - Socket not initialized')
}
diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue
index 2a9cd2c0..2422479e 100644
--- a/client/components/cards/LazyBookCard.vue
+++ b/client/components/cards/LazyBookCard.vue
@@ -379,8 +379,8 @@ export default {
this.isSelectionMode = val
if (!val) this.selected = false
},
- setEntity(audiobook) {
- this.audiobook = audiobook
+ setEntity(libraryItem) {
+ this.audiobook = libraryItem
},
clickCard(e) {
if (this.isSelectionMode) {
diff --git a/client/components/modals/edit-tabs/Cover.vue b/client/components/modals/edit-tabs/Cover.vue
index 70eedeab..1caac83a 100644
--- a/client/components/modals/edit-tabs/Cover.vue
+++ b/client/components/modals/edit-tabs/Cover.vue
@@ -157,7 +157,7 @@ export default {
.filter((f) => f.fileType === 'image')
.map((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
})
}
@@ -169,7 +169,7 @@ export default {
form.set('cover', this.selectedFile)
this.$axios
- .$post(`/api/books/${this.libraryItemId}/cover`, form)
+ .$post(`/api/items/${this.libraryItemId}/cover`, form)
.then((data) => {
if (data.error) {
this.$toast.error(data.error)
@@ -230,8 +230,20 @@ export default {
this.isProcessing = true
var success = false
- // Download cover from url and use
- if (cover.startsWith('http:') || cover.startsWith('https:')) {
+ if (!cover) {
+ // 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) => {
console.error('Failed to download cover from url', error)
if (error.response && error.response.data) {
@@ -242,11 +254,9 @@ export default {
} else {
// Update local cover url
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)
if (error.response && error.response.data) {
this.$toast.error(error.response.data)
@@ -256,7 +266,7 @@ export default {
}
if (success) {
this.$toast.success('Update Successful')
- this.$emit('close')
+ // this.$emit('close')
} else {
this.imageUrl = this.media.coverPath || ''
}
diff --git a/client/components/modals/edit-tabs/Details.vue b/client/components/modals/edit-tabs/Details.vue
index 35a7bd0a..7643b0a5 100644
--- a/client/components/modals/edit-tabs/Details.vue
+++ b/client/components/modals/edit-tabs/Details.vue
@@ -287,22 +287,22 @@ export default {
this.quickMatching = false
})
},
- audiobookScanComplete(result) {
+ libraryScanComplete(result) {
this.rescanning = false
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
} 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') {
- 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') {
- this.$toast.error(`Re-Scan complete audiobook was removed`)
+ this.$toast.error(`Re-Scan complete item was removed`)
}
},
rescan() {
this.rescanning = true
- this.$root.socket.once('audiobook_scan_complete', this.audiobookScanComplete)
- this.$root.socket.emit('scan_audiobook', this.audiobookId)
+ this.$root.socket.once('item_scan_complete', this.libraryScanComplete)
+ this.$root.socket.emit('scan_item', this.libraryItemId)
},
saveMetadataComplete(result) {
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`)) {
this.isProcessing = true
this.$axios
- .$delete(`/api/books/${this.libraryItemId}`)
+ .$delete(`/api/items/${this.libraryItemId}`)
.then(() => {
console.log('Item removed')
this.$toast.success('Item Removed')
diff --git a/client/components/modals/edit-tabs/Files.vue b/client/components/modals/edit-tabs/Files.vue
index 4644b2c4..df17b441 100644
--- a/client/components/modals/edit-tabs/Files.vue
+++ b/client/components/modals/edit-tabs/Files.vue
@@ -13,20 +13,20 @@
# | +# | Filename | -Size | -Duration | -Download | +Size | +Duration | +Download | ||||
---|---|---|---|---|---|---|---|---|---|---|---|---|
{{ track.index }} |
- {{ showFullPath ? track.path : track.filename }} | +{{ showFullPath ? track.metadata.path : track.metadata.filename }} | {{ $bytesPretty(track.metadata.size) }} | diff --git a/client/components/tables/LibraryFilesTable.vue b/client/components/tables/LibraryFilesTable.vue index 92aa907c..25a66926 100644 --- a/client/components/tables/LibraryFilesTable.vue +++ b/client/components/tables/LibraryFilesTable.vue @@ -16,18 +16,22 @@
Path | +Size | Filetype | Download | |
---|---|---|---|---|
+ | {{ showFullPath ? file.metadata.path : file.metadata.relPath }} | ++ {{ $bytesPretty(file.metadata.size) }} + |
{{ file.metadata.ext }} +{{ file.fileType }} |
diff --git a/client/layouts/default.vue b/client/layouts/default.vue index a417abfa..ece8963f 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -171,24 +171,6 @@ export default { // this.$store.commit('audiobooks/addUpdate', 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) { audiobooks.forEach((ab) => { this.audiobookAdded(ab) @@ -215,6 +197,13 @@ export default { } 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) { console.log('Scan complete received', data) @@ -403,6 +392,7 @@ export default { // Library Item Listeners this.socket.on('item_updated', this.libraryItemUpdated) + this.socket.on('item_removed', this.libraryItemRemoved) // User Listeners this.socket.on('user_updated', this.userUpdated) diff --git a/server/ApiController.js b/server/ApiController.js index f757207f..9b1a2569 100644 --- a/server/ApiController.js +++ b/server/ApiController.js @@ -76,8 +76,12 @@ class ApiController { // 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.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.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 @@ -437,18 +441,18 @@ class ApiController { return json } - async handleDeleteAudiobook(audiobook) { - // Remove audiobook from users + async handleDeleteLibraryItem(libraryItem) { + // Remove libraryItem from users for (let i = 0; i < this.db.users.length; i++) { var user = this.db.users[i] - var madeUpdates = user.deleteAudiobookData(audiobook.id) + var madeUpdates = user.deleteAudiobookData(libraryItem.id) if (madeUpdates) { await this.db.updateEntity('user', user) } } // 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++) { var stream = streams[i] var client = stream.client @@ -461,22 +465,22 @@ class ApiController { } // 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++) { var collection = collectionsWithBook[i] - collection.removeBook(audiobook.id) + collection.removeBook(libraryItem.id) 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 - if (audiobook.cover) { - await this.cacheManager.purgeCoverCache(audiobook.id) + if (libraryItem.media.coverPath) { + await this.cacheManager.purgeCoverCache(libraryItem.id) } - var audiobookJSON = audiobook.toJSONMinified() - await this.db.removeEntity('audiobook', audiobook.id) - this.emitter('audiobook_removed', audiobookJSON) + var json = libraryItem.toJSONExpanded() + await this.db.removeLibraryItem(libraryItem.id) + this.emitter('item_removed', json) } async getUserListeningSessionsHelper(userId) { diff --git a/server/BackupManager.js b/server/BackupManager.js index 22be4278..96f85757 100644 --- a/server/BackupManager.js +++ b/server/BackupManager.js @@ -13,12 +13,10 @@ const Logger = require('./Logger') const Backup = require('./objects/Backup') class BackupManager { - constructor(Uid, Gid, db) { + constructor(db) { this.BackupPath = Path.join(global.MetadataPath, 'backups') this.MetadataBooksPath = Path.join(global.MetadataPath, 'books') - this.Uid = Uid - this.Gid = Gid this.db = db this.scheduleTask = null @@ -37,7 +35,7 @@ class BackupManager { var backupsDirExists = await fs.pathExists(this.BackupPath) if (!backupsDirExists) { await fs.ensureDir(this.BackupPath) - await filePerms(this.BackupPath, 0o774, this.Uid, this.Gid) + await filePerms.setDefault(this.BackupPath) } await this.loadBackups() @@ -211,7 +209,7 @@ class BackupManager { }) if (zipResult) { 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) var existingIndex = this.backups.findIndex(b => b.id === newBackup.id) if (existingIndex >= 0) { diff --git a/server/CacheManager.js b/server/CacheManager.js index cf5f611e..d702340f 100644 --- a/server/CacheManager.js +++ b/server/CacheManager.js @@ -42,11 +42,11 @@ class CacheManager { readStream.pipe(res) } - async purgeCoverCache(audiobookId) { + async purgeCoverCache(libraryItemId) { // If purgeAll has been called... The cover cache directory no longer exists await fs.ensureDir(this.CoverCachePath) 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}`); promises.push(this.removeCache(Path.join(this.CoverCachePath, file))) } diff --git a/server/CoverController.js b/server/CoverController.js index fc8de935..4af620b9 100644 --- a/server/CoverController.js +++ b/server/CoverController.js @@ -4,9 +4,11 @@ const axios = require('axios') const Logger = require('./Logger') const readChunk = require('read-chunk') const imageType = require('image-type') +const filePerms = require('./utils/filePerms') const globals = require('./utils/globals') const { downloadFile } = require('./utils/fileUtils') +const { extractCoverArt } = require('./utils/ffmpegHelpers') class CoverController { constructor(db, cacheManager) { @@ -16,17 +18,11 @@ class CoverController { this.BookMetadataPath = Path.posix.join(global.MetadataPath, 'books') } - getCoverDirectory(audiobook) { + getCoverDirectory(libraryItem) { if (this.db.serverSettings.storeCoverWithBook) { - return { - fullPath: audiobook.fullPath, - relPath: '/s/book/' + audiobook.id - } + return libraryItem.path } else { - return { - fullPath: Path.posix.join(this.BookMetadataPath, audiobook.id), - relPath: Path.posix.join('/metadata', 'books', audiobook.id) - } + return Path.posix.join(this.BookMetadataPath, libraryItem.id) } } @@ -67,18 +63,18 @@ class CoverController { } } - async checkFileIsValidImage(imagepath) { + async checkFileIsValidImage(imagepath, removeOnInvalid = false) { const buffer = await readChunk(imagepath, 0, 12) const imgType = imageType(buffer) if (!imgType) { - await this.removeFile(imagepath) + if (removeOnInvalid) await this.removeFile(imagepath) return { error: 'Invalid image' } } if (!globals.SupportedImageTypes.includes(imgType.ext)) { - await this.removeFile(imagepath) + if (removeOnInvalid) await this.removeFile(imagepath) return { error: `Invalid image type ${imgType.ext} (Supported: ${globals.SupportedImageTypes.join(',')})` } @@ -86,7 +82,7 @@ class CoverController { return imgType } - async uploadCover(audiobook, coverFile) { + async uploadCover(libraryItem, coverFile) { var extname = Path.extname(coverFile.name.toLowerCase()) if (!extname || !globals.SupportedImageTypes.includes(extname.slice(1))) { return { @@ -94,12 +90,10 @@ class CoverController { } } - var { fullPath, relPath } = this.getCoverDirectory(audiobook) - await fs.ensureDir(fullPath) + var coverDirPath = this.getCoverDirectory(libraryItem) + await fs.ensureDir(coverDirPath) - var coverFilename = `cover${extname}` - var coverFullPath = Path.posix.join(fullPath, coverFilename) - var coverPath = Path.posix.join(relPath, coverFilename) + var coverFullPath = Path.posix.join(coverDirPath, `cover${extname}`) // Move cover from temp upload dir to destination var success = await coverFile.mv(coverFullPath).then(() => true).catch((error) => { @@ -113,23 +107,23 @@ class CoverController { } } - await this.removeOldCovers(fullPath, extname) - await this.cacheManager.purgeCoverCache(audiobook.id) + await this.removeOldCovers(coverDirPath, extname) + 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 { - cover: coverPath + cover: coverFullPath } } - async downloadCoverFromUrl(audiobook, url) { + async downloadCoverFromUrl(libraryItem, url) { try { - var { fullPath, relPath } = this.getCoverDirectory(audiobook) - await fs.ensureDir(fullPath) + var coverDirPath = this.getCoverDirectory(libraryItem) + 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) => { Logger.error(`[CoverController] Download image file failed for "${url}"`, err) return false @@ -140,25 +134,24 @@ class CoverController { } } - var imgtype = await this.checkFileIsValidImage(temppath) + var imgtype = await this.checkFileIsValidImage(temppath, true) if (imgtype.error) { return imgtype } var coverFilename = `cover.${imgtype.ext}` - var coverPath = Path.posix.join(relPath, coverFilename) - var coverFullPath = Path.posix.join(fullPath, coverFilename) + var coverFullPath = Path.posix.join(coverDirPath, coverFilename) await fs.rename(temppath, coverFullPath) - await this.removeOldCovers(fullPath, '.' + imgtype.ext) - await this.cacheManager.purgeCoverCache(audiobook.id) + await this.removeOldCovers(coverDirPath, '.' + imgtype.ext) + 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 { - cover: coverPath + cover: coverFullPath } } catch (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 \ No newline at end of file diff --git a/server/Db.js b/server/Db.js index d3e31096..311829cf 100644 --- a/server/Db.js +++ b/server/Db.js @@ -185,19 +185,56 @@ class Db { } async updateLibraryItem(libraryItem) { - if (libraryItem && libraryItem.saveMetadata) { - await libraryItem.saveMetadata() - } + return this.updateLibraryItems([libraryItem]) + } - return this.libraryItemsDb.update((record) => record.id === libraryItem.id, () => libraryItem).then((results) => { - Logger.debug(`[DB] Library Item updated ${results.updated}`) + async updateLibraryItems(libraryItems) { + 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 }).catch((error) => { - Logger.error(`[DB] Library Item update failed ${error}`) + Logger.error(`[DB] Library Items update failed ${error}`) 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) { if (audiobook && audiobook.saveAbMetadata) { // TODO: Book may have updates where this save is not necessary diff --git a/server/DownloadManager.js b/server/DownloadManager.js index dce93901..fff84d21 100644 --- a/server/DownloadManager.js +++ b/server/DownloadManager.js @@ -11,9 +11,7 @@ const { writeConcatFile, writeMetadataFile } = require('./utils/ffmpegHelpers') const { getFileSize } = require('./utils/fileUtils') const TAG = 'DownloadManager' class DownloadManager { - constructor(db, Uid, Gid) { - this.Uid = Uid - this.Gid = Gid + constructor(db) { this.db = db this.downloadDirPath = Path.join(global.MetadataPath, 'downloads') @@ -344,7 +342,7 @@ class DownloadManager { } // 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) download.setComplete(filesize) diff --git a/server/Server.js b/server/Server.js index d151454c..35053eae 100644 --- a/server/Server.js +++ b/server/Server.js @@ -32,11 +32,9 @@ const CacheManager = require('./CacheManager') class Server { constructor(PORT, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) { this.Port = PORT - this.Uid = isNaN(UID) ? 0 : Number(UID) - this.Gid = isNaN(GID) ? 0 : Number(GID) this.Host = '0.0.0.0' - global.Uid = this.Uid - global.Gid = this.Gid + global.Uid = isNaN(UID) ? 0 : Number(UID) + global.Gid = isNaN(GID) ? 0 : Number(GID) global.ConfigPath = Path.normalize(CONFIG_PATH) global.AudiobookPath = Path.normalize(AUDIOBOOK_PATH) global.MetadataPath = Path.normalize(METADATA_PATH) @@ -53,7 +51,7 @@ class Server { this.db = new 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.cacheManager = new CacheManager() this.watcher = new Watcher() @@ -61,7 +59,7 @@ class Server { 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.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.hlsController = new HlsController(this.db, this.auth, this.streamManager, this.emitter.bind(this), this.streamManager.StreamsPath) @@ -268,8 +266,8 @@ class Server { // Scanning socket.on('scan', this.scan.bind(this)) socket.on('cancel_scan', this.cancelScan.bind(this)) - socket.on('scan_audiobook', (audiobookId) => this.scanAudiobook(socket, audiobookId)) - socket.on('save_metadata', (audiobookId) => this.saveMetadata(socket, audiobookId)) + socket.on('scan_item', (libraryItemId) => this.scanLibraryItem(socket, libraryItemId)) + socket.on('save_metadata', (libraryItemId) => this.saveMetadata(socket, libraryItemId)) // Streaming (only still used in the mobile app) socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId)) @@ -329,15 +327,15 @@ class Server { Logger.info('[Server] Scan complete') } - async scanAudiobook(socket, audiobookId) { - var result = await this.scanner.scanAudiobookById(audiobookId) + async scanLibraryItem(socket, libraryItemId) { + var result = await this.scanner.scanLibraryItemById(libraryItemId) var scanResultName = '' for (const key in ScanResult) { if (ScanResult[key] === result) { scanResultName = key } } - socket.emit('audiobook_scan_complete', scanResultName) + socket.emit('item_scan_complete', scanResultName) } cancelScan(id) { @@ -459,8 +457,7 @@ class Server { }) } - Logger.info(`[Server] Setting owner/perms for first dir "${firstDirPath}"`) - await filePerms(firstDirPath, 0o774, this.Uid, this.Gid) + await filePerms.setDefault(firstDirPath) res.sendStatus(200) } diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 52f2ea80..a701b35c 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -57,12 +57,12 @@ class LibraryController { // Update watcher this.watcher.updateLibrary(library) - // Remove audiobooks no longer in library - var audiobooksToRemove = this.db.audiobooks.filter(ab => ab.libraryId === library.id && !library.checkFullPathInLibrary(ab.fullPath)) - if (audiobooksToRemove.length) { - Logger.info(`[Scanner] Updating library, removing ${audiobooksToRemove.length} audiobooks`) - for (let i = 0; i < audiobooksToRemove.length; i++) { - await this.handleDeleteAudiobook(audiobooksToRemove[i]) + // Remove libraryItems no longer in library + var itemsToRemove = this.db.libraryItems.filter(li => li.libraryId === library.id && !library.checkFullPathInLibrary(li.path)) + if (itemsToRemove.length) { + Logger.info(`[Scanner] Updating library, removing ${itemsToRemove.length} items`) + for (let i = 0; i < itemsToRemove.length; i++) { + await this.handleDeleteLibraryItem(itemsToRemove[i]) } } await this.db.updateEntity('library', library) @@ -77,11 +77,11 @@ class LibraryController { // Remove library watcher this.watcher.removeLibrary(library) - // Remove audiobooks in this library - var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === library.id) - Logger.info(`[Server] deleting library "${library.name}" with ${audiobooks.length} audiobooks"`) - for (let i = 0; i < audiobooks.length; i++) { - await this.handleDeleteAudiobook(audiobooks[i]) + // Remove items in this library + var libraryItems = this.db.libraryItems.filter(li => li.libraryId === library.id) + Logger.info(`[Server] deleting library "${library.name}" with ${libraryItems.length} items"`) + for (let i = 0; i < libraryItems.length; i++) { + await this.handleDeleteLibraryItem(libraryItems[i]) } var libraryJson = library.toJSON() @@ -91,7 +91,7 @@ class LibraryController { } // 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) { var libraryId = req.library.id var media = req.query.media || 'all' diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index e18c390b..e3032e60 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -12,10 +12,6 @@ class LibraryItemController { } 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 // 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)) { @@ -31,15 +27,15 @@ class LibraryItemController { 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 // 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 mediaPayload = req.body // Item has cover and update is removing cover so purge it from cache @@ -100,6 +96,75 @@ class LibraryItemController { 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 async getCover(req, res) { let { query: { width, height, format }, libraryItem } = req @@ -114,13 +179,21 @@ class LibraryItemController { middleware(req, res, next) { 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 if (!req.user.checkCanAccessLibrary(item.libraryId)) { 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 next() } diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index 53e9a6c5..051571b2 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -1,3 +1,4 @@ +const { version } = require('../../package.json') const Logger = require('../Logger') const LibraryFile = require('./files/LibraryFile') const Book = require('./entities/Book') @@ -22,8 +23,10 @@ class LibraryItem { this.lastScan = null this.scanVersion = null - // Entity was scanned and not found + // Was scanned and no longer exists this.isMissing = false + // Was scanned and no longer has media files + this.isInvalid = false this.mediaType = null this.media = null @@ -51,6 +54,7 @@ class LibraryItem { this.scanVersion = libraryItem.scanVersion || null this.isMissing = !!libraryItem.isMissing + this.isInvalid = !!libraryItem.isInvalid this.mediaType = libraryItem.mediaType if (this.mediaType === 'book') { @@ -78,6 +82,7 @@ class LibraryItem { lastScan: this.lastScan, scanVersion: this.scanVersion, isMissing: !!this.isMissing, + isInvalid: !!this.isInvalid, mediaType: this.mediaType, media: this.media.toJSON(), libraryFiles: this.libraryFiles.map(f => f.toJSON()) @@ -98,6 +103,7 @@ class LibraryItem { addedAt: this.addedAt, updatedAt: this.updatedAt, isMissing: !!this.isMissing, + isInvalid: !!this.isInvalid, mediaType: this.mediaType, media: this.media.toJSONMinified(), numFiles: this.libraryFiles.length @@ -121,6 +127,7 @@ class LibraryItem { lastScan: this.lastScan, scanVersion: this.scanVersion, isMissing: !!this.isMissing, + isInvalid: !!this.isInvalid, mediaType: this.mediaType, media: this.media.toJSONExpanded(), libraryFiles: this.libraryFiles.map(f => f.toJSON()), @@ -133,6 +140,42 @@ class LibraryItem { this.libraryFiles.forEach((lf) => total += lf.metadata.size) 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) { var json = this.toJSON() @@ -149,7 +192,214 @@ class LibraryItem { } } } + if (hasUpdates) { + this.updatedAt = Date.now() + } 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 \ No newline at end of file diff --git a/server/objects/entities/Author.js b/server/objects/entities/Author.js index 06d44e6e..5f60d88f 100644 --- a/server/objects/entities/Author.js +++ b/server/objects/entities/Author.js @@ -53,5 +53,10 @@ class Author { this.addedAt = Date.now() this.updatedAt = Date.now() } + + checkNameEquals(name) { + if (!name) return false + return this.name.toLowerCase() == name.toLowerCase().trim() + } } module.exports = Author \ No newline at end of file diff --git a/server/objects/entities/Book.js b/server/objects/entities/Book.js index f560db2b..b9a407fb 100644 --- a/server/objects/entities/Book.js +++ b/server/objects/entities/Book.js @@ -2,7 +2,10 @@ const Logger = require('../../Logger') const BookMetadata = require('../metadata/BookMetadata') const AudioFile = require('../files/AudioFile') const EBookFile = require('../files/EBookFile') +const abmetadataGenerator = require('../../utils/abmetadataGenerator') const { areEquivalent, copyValue } = require('../../utils/index') +const { parseOpfMetadataXML } = require('../../utils/parseOpfMetadata') +const { readTextFile } = require('../../utils/fileUtils') class Book { constructor(book) { @@ -13,6 +16,10 @@ class Book { this.audioFiles = [] this.ebookFiles = [] this.chapters = [] + this.missingParts = [] + + this.lastCoverSearch = null + this.lastCoverSearchQuery = null if (book) { this.construct(book) @@ -26,6 +33,9 @@ class Book { this.audioFiles = book.audioFiles.map(f => new AudioFile(f)) this.ebookFiles = book.ebookFiles.map(f => new EBookFile(f)) this.chapters = book.chapters.map(c => ({ ...c })) + this.missingParts = book.missingParts ? [...book.missingParts] : [] + this.lastCoverSearch = book.lastCoverSearch || null + this.lastCoverSearchQuery = book.lastCoverSearchQuery || null } toJSON() { @@ -35,7 +45,8 @@ class Book { tags: [...this.tags], audioFiles: this.audioFiles.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, numEbooks: this.ebookFiles.length, numChapters: this.chapters.length, + numMissingParts: this.missingParts.length, duration: this.duration, size: this.size } @@ -63,7 +75,8 @@ class Book { chapters: this.chapters.map(c => ({ ...c })), duration: this.duration, 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) 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) { var json = this.toJSON() @@ -99,5 +123,195 @@ class Book { } 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 \ No newline at end of file diff --git a/server/objects/entities/Podcast.js b/server/objects/entities/Podcast.js index 9e3cc6b8..4caa318c 100644 --- a/server/objects/entities/Podcast.js +++ b/server/objects/entities/Podcast.js @@ -1,5 +1,6 @@ const PodcastEpisode = require('./PodcastEpisode') const PodcastMetadata = require('../metadata/PodcastMetadata') +const { areEquivalent, copyValue } = require('../../utils/index') class Podcast { constructor(podcast) { @@ -10,8 +11,8 @@ class Podcast { this.tags = [] this.episodes = [] - this.createdAt = null - this.lastUpdate = null + this.lastCoverSearch = null + this.lastCoverSearchQuery = null if (podcast) { this.construct(podcast) @@ -24,8 +25,6 @@ class Podcast { this.coverPath = podcast.coverPath this.tags = [...podcast.tags] this.episodes = podcast.episodes.map((e) => new PodcastEpisode(e)) - this.createdAt = podcast.createdAt - this.lastUpdate = podcast.lastUpdate } toJSON() { @@ -35,8 +34,6 @@ class Podcast { coverPath: this.coverPath, tags: [...this.tags], episodes: this.episodes.map(e => e.toJSON()), - createdAt: this.createdAt, - lastUpdate: this.lastUpdate } } @@ -47,8 +44,7 @@ class Podcast { coverPath: this.coverPath, tags: [...this.tags], episodes: this.episodes.map(e => e.toJSON()), - createdAt: this.createdAt, - lastUpdate: this.lastUpdate + } } @@ -59,9 +55,74 @@ class Podcast { coverPath: this.coverPath, tags: [...this.tags], 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 \ No newline at end of file diff --git a/server/objects/entities/Series.js b/server/objects/entities/Series.js index 49db731b..cf4450ce 100644 --- a/server/objects/entities/Series.js +++ b/server/objects/entities/Series.js @@ -42,5 +42,10 @@ class Series { this.addedAt = Date.now() this.updatedAt = Date.now() } + + checkNameEquals(name) { + if (!name) return false + return this.name.toLowerCase() == name.toLowerCase().trim() + } } module.exports = Series \ No newline at end of file diff --git a/server/objects/files/AudioFile.js b/server/objects/files/AudioFile.js index 83327169..92d3ab8e 100644 --- a/server/objects/files/AudioFile.js +++ b/server/objects/files/AudioFile.js @@ -72,6 +72,9 @@ class AudioFile { this.index = data.index this.ino = data.ino 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.updatedAt = data.updatedAt this.manuallyVerified = !!data.manuallyVerified @@ -101,19 +104,13 @@ class AudioFile { } // New scanner creates AudioFile from AudioFileScanner - setDataFromProbe(fileData, probeData) { - this.index = fileData.index || null - this.ino = fileData.ino || null + setDataFromProbe(libraryFile, probeData) { + this.ino = libraryFile.ino || null - // TODO: Update file metadata for set data from probe + this.metadata = libraryFile.metadata.clone() this.addedAt = 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.duration = probeData.duration this.bitRate = probeData.bitRate || null @@ -196,9 +193,13 @@ class AudioFile { newjson.addedAt = this.addedAt for (const key in newjson) { - if (key === 'metaTags') { - if (!this.metaTags || !this.metaTags.isEqual(scannedAudioFile.metadata)) { - this.metaTags = scannedAudioFile.metadata + if (key === 'metadata') { + if (this.metadata.update(newjson[key])) { + hasUpdated = true + } + } else if (key === 'metaTags') { + if (!this.metaTags || !this.metaTags.isEqual(scannedAudioFile.metaTags)) { + this.metaTags = scannedAudioFile.metaTags.clone() hasUpdated = true } } else if (key === 'chapters') { @@ -206,7 +207,6 @@ class AudioFile { hasUpdated = true } } else if (this[key] !== newjson[key]) { - // console.log(this.filename, 'key', key, 'updated', this[key], newjson[key]) this[key] = newjson[key] hasUpdated = true } diff --git a/server/objects/files/LibraryFile.js b/server/objects/files/LibraryFile.js index dfbf002d..9cbdc5f9 100644 --- a/server/objects/files/LibraryFile.js +++ b/server/objects/files/LibraryFile.js @@ -1,3 +1,5 @@ +const Path = require('path') +const { getFileTimestampsWithIno } = require('../../utils/fileUtils') const globals = require('../../utils/globals') const FileMetadata = require('../metadata/FileMetadata') @@ -30,6 +32,10 @@ class LibraryFile { } } + clone() { + return new LibraryFile(this.toJSON()) + } + get fileType() { if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image' if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio' @@ -38,5 +44,27 @@ class LibraryFile { if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata' 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 \ No newline at end of file diff --git a/server/objects/legacy/Book.js b/server/objects/legacy/Book.js index 39a5992c..092d04df 100644 --- a/server/objects/legacy/Book.js +++ b/server/objects/legacy/Book.js @@ -1,6 +1,6 @@ const Path = require('path') const Logger = require('../../Logger') -const parseAuthors = require('../../utils/parseAuthors') +const parseAuthors = require('../../utils/parseNameString') class Book { constructor(book = null) { diff --git a/server/objects/metadata/AudioMetaTags.js b/server/objects/metadata/AudioMetaTags.js index 4b1d3891..65ac0d9c 100644 --- a/server/objects/metadata/AudioMetaTags.js +++ b/server/objects/metadata/AudioMetaTags.js @@ -118,6 +118,10 @@ class AudioMetaTags { return hasUpdates } + clone() { + return new AudioMetaTags(this.toJSON()) + } + isEqual(audioFileMetadata) { if (!audioFileMetadata || !audioFileMetadata.toJSON) return false for (const key in audioFileMetadata.toJSON()) { diff --git a/server/objects/metadata/BookMetadata.js b/server/objects/metadata/BookMetadata.js index 38e6f7da..5ca6c372 100644 --- a/server/objects/metadata/BookMetadata.js +++ b/server/objects/metadata/BookMetadata.js @@ -1,6 +1,6 @@ const Logger = require('../../Logger') const { areEquivalent, copyValue } = require('../../utils/index') - +const parseNameString = require('../../utils/parseNameString') class BookMetadata { constructor(metadata) { this.title = null @@ -88,11 +88,16 @@ class BookMetadata { return this.title } get authorName() { + if (!this.authors.length) return '' return this.authors.map(au => au.name).join(', ') } get narratorName() { return this.narrators.join(', ') } + get coverSearchQuery() { + if (!this.authorName) return this.title + return this.title + '&' + this.authorName + } hasAuthor(authorName) { return !!this.authors.find(au => au.name == authorName) @@ -118,5 +123,150 @@ class BookMetadata { } 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 \ No newline at end of file diff --git a/server/objects/metadata/FileMetadata.js b/server/objects/metadata/FileMetadata.js index 4cdd7f6f..cdc271d5 100644 --- a/server/objects/metadata/FileMetadata.js +++ b/server/objects/metadata/FileMetadata.js @@ -12,6 +12,9 @@ class FileMetadata { if (metadata) { this.construct(metadata) } + + // Temp flag used in scans + this.wasModified = false } construct(metadata) { @@ -46,5 +49,24 @@ class FileMetadata { if (!this.ext) return '' 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 \ No newline at end of file diff --git a/server/scanner/AudioFileScanner.js b/server/scanner/AudioFileScanner.js index d876a917..a58dd7c7 100644 --- a/server/scanner/AudioFileScanner.js +++ b/server/scanner/AudioFileScanner.js @@ -9,9 +9,9 @@ const { LogLevel } = require('../utils/constants') class AudioFileScanner { constructor() { } - getTrackAndDiscNumberFromFilename(bookScanData, audioFileData) { - const { title, author, series, publishYear } = bookScanData - const { filename, path } = audioFileData + getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, audioLibraryFile) { + const { title, author, series, publishYear } = mediaMetadataFromScan + const { filename, path } = audioLibraryFile.metadata var partbasename = Path.basename(filename, Path.extname(filename)) // Remove title, author, series, and publishYear from filename if there @@ -54,25 +54,23 @@ class AudioFileScanner { return Math.floor(total / results.length) } - async scan(audioFileData, bookScanData, verbose = false) { + async scan(audioLibraryFile, mediaMetadataFromScan, verbose = false) { var probeStart = Date.now() - // Logger.debug(`[AudioFileScanner] Start Probe ${audioFileData.fullPath}`) - var probeData = await prober.probe(audioFileData.fullPath, verbose) + var probeData = await prober.probe(audioLibraryFile.metadata.path, verbose) if (probeData.error) { - Logger.error(`[AudioFileScanner] ${probeData.error} : "${audioFileData.fullPath}"`) + Logger.error(`[AudioFileScanner] ${probeData.error} : "${audioLibraryFile.metadata.path}"`) return null } - // Logger.debug(`[AudioFileScanner] Finished Probe ${audioFileData.fullPath} elapsed ${msToTimestamp(Date.now() - probeStart, true)}`) var audioFile = new AudioFile() - audioFileData.trackNumFromMeta = probeData.trackNumber - audioFileData.discNumFromMeta = probeData.discNumber + audioFile.trackNumFromMeta = probeData.trackNumber + audioFile.discNumFromMeta = probeData.discNumber - const { trackNumber, discNumber } = this.getTrackAndDiscNumberFromFilename(bookScanData, audioFileData) - audioFileData.trackNumFromFilename = trackNumber - audioFileData.discNumFromFilename = discNumber + const { trackNumber, discNumber } = this.getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, audioLibraryFile) + audioFile.trackNumFromFilename = trackNumber + audioFile.discNumFromFilename = discNumber - audioFile.setDataFromProbe(audioFileData, probeData) + audioFile.setDataFromProbe(audioLibraryFile, probeData) return { audioFile, @@ -81,10 +79,11 @@ class AudioFileScanner { } // 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 = [] - for (let i = 0; i < audioFileDataArray.length; i++) { - proms.push(this.scan(audioFileDataArray[i], bookScanData)) + for (let i = 0; i < audioLibraryFiles.length; i++) { + proms.push(this.scan(audioLibraryFiles[i], mediaMetadataFromScan)) } var scanStart = Date.now() var results = await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr)) @@ -117,7 +116,7 @@ class AudioFileScanner { return nodupes } - runSmartTrackOrder(audiobook, audioFiles) { + runSmartTrackOrder(libraryItem, audioFiles) { var discsFromFilename = [] var tracksFromFilename = [] var discsFromMeta = [] @@ -153,75 +152,78 @@ class AudioFileScanner { 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) => { let Dx = a[discKey] - b[discKey] if (Dx === 0) Dx = a[trackKey] - b[trackKey] return Dx }) } 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]) } for (let i = 0; i < audioFiles.length; i++) { audioFiles[i].index = i + 1 - var existingAF = audiobook.getAudioFileByIno(audioFiles[i].ino) + var existingAF = libraryItem.media.findFileWithInode(audioFiles[i].ino) if (existingAF) { - audiobook.updateAudioFile(audioFiles[i]) + if (existingAF.updateFromScan) existingAF.updateFromScan(audioFiles[i]) } 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 audioScanResult = await this.executeAudioFileScans(audioFileDataArray, bookScanData) + var audioScanResult = await this.executeAudioFileScans(audioLibraryFiles, scanData) if (audioScanResult.audioFiles.length) { 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 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) { - // Single Track Audiobooks - if (totalAudioFilesToInclude === 1) { - var af = audioScanResult.audioFiles[0] - af.index = 1 - audiobook.addAudioFile(af) - hasUpdated = true + // Adding audio files to book media + if (libraryItem.mediaType === 'book') { + if (newAudioFiles.length) { + // Single Track Audiobooks + if (totalAudioFilesToInclude === 1) { + var af = audioScanResult.audioFiles[0] + af.index = 1 + libraryItem.media.audioFiles.push(af) + hasUpdated = true + } else { + this.runSmartTrackOrder(libraryItem, audioScanResult.audioFiles) + hasUpdated = true + } } 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 } - } 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 (audiobook.setDetailsFromFileMetadata(preferAudioMetadata)) { - hasUpdated = true - } - - if (hasUpdated) { - audiobook.rebuildTracks() - } + if (hasUpdated) { + libraryItem.media.rebuildTracks() + } + } // End Book media type } return hasUpdated } diff --git a/server/scanner/LibraryScan.js b/server/scanner/LibraryScan.js index d21574e1..e089e844 100644 --- a/server/scanner/LibraryScan.js +++ b/server/scanner/LibraryScan.js @@ -13,6 +13,7 @@ class LibraryScan { this.type = null this.libraryId = null this.libraryName = null + this.libraryMediaType = null this.folders = null this.verbose = false @@ -69,6 +70,7 @@ class LibraryScan { type: this.type, libraryId: this.libraryId, libraryName: this.libraryName, + libraryMediaType: this.libraryMediaType, folders: this.folders.map(f => f.toJSON()), scanOptions: this.scanOptions ? this.scanOptions.toJSON() : null, startedAt: this.startedAt, @@ -85,6 +87,7 @@ class LibraryScan { this.type = type this.libraryId = library.id this.libraryName = library.name + this.libraryMediaType = library.mediaType this.folders = library.folders.map(folder => new Folder(folder.toJSON())) this.scanOptions = scanOptions diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 5145da2d..d2182d55 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -4,16 +4,20 @@ const Path = require('path') // Utils const Logger = require('../Logger') 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 { ScanResult, LogLevel } = require('../utils/constants') const AudioFileScanner = require('./AudioFileScanner') const BookFinder = require('../finders/BookFinder') const Audiobook = require('../objects/legacy/Audiobook') +const LibraryItem = require('../objects/LibraryItem') const LibraryScan = require('./LibraryScan') const ScanOptions = require('./ScanOptions') +const Author = require('../objects/entities/Author') +const Series = require('../objects/entities/Series') + class Scanner { constructor(db, coverController, emitter) { this.BookMetadataPath = Path.posix.join(global.MetadataPath, 'books') @@ -53,71 +57,69 @@ class Scanner { this.cancelLibraryScan[libraryId] = true } - async scanAudiobookById(audiobookId) { - var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId) - if (!audiobook) { - Logger.error(`[Scanner] Scan audiobook by id not found ${audiobookId}`) + async scanLibraryItemById(libraryItemId) { + var libraryItem = this.db.libraryItems.find(li => li.id === libraryItemId) + if (!libraryItem) { + Logger.error(`[Scanner] Scan libraryItem by id not found ${libraryItemId}`) 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) { - 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 } - const folder = library.folders.find(f => f.id === audiobook.folderId) + const folder = library.folders.find(f => f.id === libraryItem.folderId) 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 } - Logger.info(`[Scanner] Scanning Audiobook "${audiobook.title}"`) - return this.scanAudiobook(folder, audiobook) + Logger.info(`[Scanner] Scanning Library Item "${libraryItem.media.metadata.title}"`) + return this.scanLibraryItem(library.mediaType, folder, libraryItem) } - async scanAudiobook(folder, audiobook) { - var audiobookData = await getAudiobookFileData(folder, audiobook.fullPath, this.db.serverSettings) - if (!audiobookData) { + async scanLibraryItem(libraryMediaType, folder, libraryItem) { + var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, this.db.serverSettings) + if (!libraryItemData) { return ScanResult.NOTHING } var hasUpdated = false - var checkRes = audiobook.checkScanData(audiobookData, version) + var checkRes = libraryItem.checkScanData(libraryItemData) if (checkRes.updated) hasUpdated = true // Sync other files first so that local images are used as cover art - // TODO: Cleanup other file sync - var allOtherFiles = checkRes.newOtherFileData.concat(checkRes.existingOtherFileData) - if (await audiobook.syncOtherFiles(allOtherFiles, this.db.serverSettings.scannerPreferOpfMetadata)) { + if (await libraryItem.syncFiles(this.db.serverSettings.scannerPreferOpfMetadata)) { hasUpdated = true } // Scan all audio files - if (audiobookData.audioFiles.length) { - if (await AudioFileScanner.scanAudioFiles(audiobookData.audioFiles, audiobookData, audiobook, this.db.serverSettings.scannerPreferAudioMetadata)) { + if (libraryItem.hasAudioFiles) { + var libraryAudioFiles = libraryItem.libraryFiles.filter(lf => lf.fileType === 'audio') + if (await AudioFileScanner.scanAudioFiles(libraryAudioFiles, libraryItemData, libraryItem, this.db.serverSettings.scannerPreferAudioMetadata)) { hasUpdated = true } // Extract embedded cover art if cover is not already in directory - if (audiobook.hasEmbeddedCoverArt && !audiobook.cover) { - var outputCoverDirs = this.getCoverDirectory(audiobook) - var relativeDir = await audiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath) - if (relativeDir) { - Logger.debug(`[Scanner] Saved embedded cover art "${relativeDir}"`) + if (libraryItem.media.hasEmbeddedCoverArt && !libraryItem.media.coverPath) { + var coverPath = await this.coverController.saveEmbeddedCoverArt(libraryItem) + if (coverPath) { + Logger.debug(`[Scanner] Saved embedded cover art "${coverPath}"`) hasUpdated = true } } } - - if (!audiobook.audioFilesToInclude.length && !audiobook.ebooks.length) { // Audiobook is invalid - audiobook.setInvalid() + console.log('Finished library item scan', libraryItem.hasMediaFiles, hasUpdated) + if (!libraryItem.hasMediaFiles) { // Library Item is invalid + libraryItem.setInvalid() hasUpdated = true - } else if (audiobook.isInvalid) { - audiobook.isInvalid = false + } else if (libraryItem.isInvalid) { + libraryItem.isInvalid = false hasUpdated = true } if (hasUpdated) { - this.emitter('audiobook_updated', audiobook.toJSONExpanded()) - await this.db.updateAudiobook(audiobook) + this.emitter('item_updated', libraryItem.toJSONExpanded()) + await this.db.updateLibraryItem(libraryItem) return ScanResult.UPDATED } return ScanResult.UPTODATE @@ -177,241 +179,277 @@ class Scanner { } async scanLibrary(libraryScan) { - var audiobookDataFound = [] + var libraryItemDataFound = [] // Scan each library for (let i = 0; i < libraryScan.folders.length; i++) { var folder = libraryScan.folders[i] - var abDataFoundInFolder = await scanRootDir(folder, this.db.serverSettings) - libraryScan.addLog(LogLevel.INFO, `${abDataFoundInFolder.length} ab data found in folder "${folder.fullPath}"`) - audiobookDataFound = audiobookDataFound.concat(abDataFoundInFolder) + var itemDataFoundInFolder = await scanFolder(libraryScan.libraryMediaType, folder, this.db.serverSettings) + libraryScan.addLog(LogLevel.INFO, `${itemDataFoundInFolder.length} item data found in folder "${folder.fullPath}"`) + libraryItemDataFound = libraryItemDataFound.concat(itemDataFoundInFolder) } if (this.cancelLibraryScan[libraryScan.libraryId]) return true // Remove audiobooks with no inode - audiobookDataFound = audiobookDataFound.filter(abd => abd.ino) - var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === libraryScan.libraryId) + libraryItemDataFound = libraryItemDataFound.filter(lid => lid.ino) + var libraryItemsInLibrary = this.db.libraryItems.filter(li => li.libraryId === libraryScan.libraryId) const NumScansPerChunk = 25 - const audiobooksToUpdateChunks = [] - const audiobookDataToRescanChunks = [] - const newAudiobookDataToScanChunks = [] - var audiobooksToUpdate = [] - var audiobookDataToRescan = [] - var newAudiobookDataToScan = [] - var audiobooksToFindCovers = [] + const itemsToUpdateChunks = [] + const itemDataToRescanChunks = [] + const newItemDataToScanChunks = [] + var itemsToUpdate = [] + var itemDataToRescan = [] + var newItemDataToScan = [] + var itemsToFindCovers = [] - // Check for existing & removed audiobooks - for (let i = 0; i < audiobooksInLibrary.length; i++) { - var audiobook = audiobooksInLibrary[i] - // Find audiobook folder with matching inode or matching path - var dataFound = audiobookDataFound.find(abd => abd.ino === audiobook.ino || comparePaths(abd.path, audiobook.path)) + // Check for existing & removed library items + for (let i = 0; i < libraryItemsInLibrary.length; i++) { + var libraryItem = libraryItemsInLibrary[i] + // Find library item folder with matching inode or matching path + var dataFound = libraryItemDataFound.find(lid => lid.ino === libraryItem.ino || comparePaths(lid.relPath, libraryItem.relPath)) 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++ - audiobook.setMissing() - audiobooksToUpdate.push(audiobook) - if (audiobooksToUpdate.length === NumScansPerChunk) { - audiobooksToUpdateChunks.push(audiobooksToUpdate) - audiobooksToUpdate = [] + libraryItem.setMissing() + itemsToUpdate.push(libraryItem) + if (itemsToUpdate.length === NumScansPerChunk) { + itemsToUpdateChunks.push(itemsToUpdate) + itemsToUpdate = [] } } else { - var checkRes = audiobook.checkScanData(dataFound, version) - if (checkRes.newAudioFileData.length || checkRes.newOtherFileData.length || libraryScan.scanOptions.forceRescan) { // Audiobook has new files - checkRes.audiobook = audiobook - checkRes.bookScanData = dataFound - audiobookDataToRescan.push(checkRes) - if (audiobookDataToRescan.length === NumScansPerChunk) { - audiobookDataToRescanChunks.push(audiobookDataToRescan) - audiobookDataToRescan = [] + var checkRes = libraryItem.checkScanData(dataFound) + if (checkRes.newLibraryFiles.length || libraryScan.scanOptions.forceRescan) { // Item has new files + checkRes.libraryItem = libraryItem + checkRes.scanData = dataFound + itemDataToRescan.push(checkRes) + if (itemDataToRescan.length === NumScansPerChunk) { + itemDataToRescanChunks.push(itemDataToRescan) + itemDataToRescan = [] } - } else if (libraryScan.findCovers && audiobook.book.shouldSearchForCover) { + } else if (libraryScan.findCovers && libraryItem.media.shouldSearchForCover) { libraryScan.resultsUpdated++ - audiobooksToFindCovers.push(audiobook) - audiobooksToUpdate.push(audiobook) - if (audiobooksToUpdate.length === NumScansPerChunk) { - audiobooksToUpdateChunks.push(audiobooksToUpdate) - audiobooksToUpdate = [] + itemsToFindCovers.push(libraryItem) + itemsToUpdate.push(libraryItem) + if (itemsToUpdate.length === NumScansPerChunk) { + itemsToUpdateChunks.push(itemsToUpdate) + itemsToUpdate = [] } } else if (checkRes.updated) { // Updated but no scan required libraryScan.resultsUpdated++ - audiobooksToUpdate.push(audiobook) - if (audiobooksToUpdate.length === NumScansPerChunk) { - audiobooksToUpdateChunks.push(audiobooksToUpdate) - audiobooksToUpdate = [] + itemsToUpdate.push(libraryItem) + if (itemsToUpdate.length === NumScansPerChunk) { + itemsToUpdateChunks.push(itemsToUpdate) + itemsToUpdate = [] } } - audiobookDataFound = audiobookDataFound.filter(abf => abf.ino !== dataFound.ino) + libraryItemDataFound = libraryItemDataFound.filter(lid => lid.ino !== dataFound.ino) } } - if (audiobooksToUpdate.length) audiobooksToUpdateChunks.push(audiobooksToUpdate) - if (audiobookDataToRescan.length) audiobookDataToRescanChunks.push(audiobookDataToRescan) + if (itemsToUpdate.length) itemsToUpdateChunks.push(itemsToUpdate) + if (itemDataToRescan.length) itemDataToRescanChunks.push(itemDataToRescan) - // Potential NEW Audiobooks - for (let i = 0; i < audiobookDataFound.length; i++) { - var dataFound = audiobookDataFound[i] - var hasEbook = dataFound.otherFiles.find(otherFile => otherFile.filetype === 'ebook') - if (!hasEbook && !dataFound.audioFiles.length) { - libraryScan.addLog(LogLevel.WARN, `Directory found "${audiobookDataFound.path}" has no ebook or audio files`) + // Potential NEW Library Items + for (let i = 0; i < libraryItemDataFound.length; i++) { + var dataFound = libraryItemDataFound[i] + + var hasMediaFile = dataFound.libraryFiles.some(lf => lf.isMediaFile) + if (!hasMediaFile) { + libraryScan.addLog(LogLevel.WARN, `Directory found "${libraryItemDataFound.path}" has no media files`) } else { - newAudiobookDataToScan.push(dataFound) - if (newAudiobookDataToScan.length === NumScansPerChunk) { - newAudiobookDataToScanChunks.push(newAudiobookDataToScan) - newAudiobookDataToScan = [] + newItemDataToScan.push(dataFound) + if (newItemDataToScan.length === NumScansPerChunk) { + newItemDataToScanChunks.push(newItemDataToScan) + newItemDataToScan = [] } } } - if (newAudiobookDataToScan.length) newAudiobookDataToScanChunks.push(newAudiobookDataToScan) + if (newItemDataToScan.length) newItemDataToScanChunks.push(newItemDataToScan) - // console.log('Num chunks to update', audiobooksToUpdateChunks.length) - // console.log('Num chunks to rescan', audiobookDataToRescanChunks.length) - // console.log('Num chunks to new scan', newAudiobookDataToScanChunks.length) - - // Audiobooks not requiring a scan but require a search for cover - for (let i = 0; i < audiobooksToFindCovers.length; i++) { - var audiobook = audiobooksToFindCovers[i] - var updatedCover = await this.searchForCover(audiobook, libraryScan) - audiobook.book.updateLastCoverSearch(updatedCover) + // Library Items not requiring a scan but require a search for cover + for (let i = 0; i < itemsToFindCovers.length; i++) { + var libraryItem = itemsToFindCovers[i] + var updatedCover = await this.searchForCover(libraryItem, libraryScan) + libraryItem.media.updateLastCoverSearch(updatedCover) } - for (let i = 0; i < audiobooksToUpdateChunks.length; i++) { - await this.updateAudiobooksChunk(audiobooksToUpdateChunks[i]) + for (let i = 0; i < itemsToUpdateChunks.length; i++) { + await this.updateLibraryItemChunk(itemsToUpdateChunks[i]) 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++) { - await this.rescanAudiobookDataChunk(audiobookDataToRescanChunks[i], libraryScan) + for (let i = 0; i < itemDataToRescanChunks.length; i++) { + await this.rescanLibraryItemDataChunk(itemDataToRescanChunks[i], libraryScan) 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++) { - await this.scanNewAudiobookDataChunk(newAudiobookDataToScanChunks[i], libraryScan) - // console.log('New scan chunk done', i, 'of', newAudiobookDataToScanChunks.length) + for (let i = 0; i < newItemDataToScanChunks.length; i++) { + await this.scanNewLibraryItemDataChunk(newItemDataToScanChunks[i], libraryScan) + // console.log('New scan chunk done', i, 'of', newItemDataToScanChunks.length) if (this.cancelLibraryScan[libraryScan.libraryId]) return true } } - async updateAudiobooksChunk(audiobooksToUpdate) { - await this.db.updateEntities('audiobook', audiobooksToUpdate) - this.emitter('audiobooks_updated', audiobooksToUpdate.map(ab => ab.toJSONExpanded())) + async updateLibraryItemChunk(itemsToUpdate) { + await this.db.updateLibraryItems(itemsToUpdate) + this.emitter('items_updated', itemsToUpdate.map(li => li.toJSONExpanded())) } - async rescanAudiobookDataChunk(audiobookDataToRescan, libraryScan) { - var audiobooksUpdated = await Promise.all(audiobookDataToRescan.map((abd) => { - return this.rescanAudiobook(abd, libraryScan) + async rescanLibraryItemDataChunk(itemDataToRescan, libraryScan) { + var itemsUpdated = await Promise.all(itemDataToRescan.map((lid) => { + return this.rescanLibraryItem(lid, libraryScan) })) - audiobooksUpdated = audiobooksUpdated.filter(ab => ab) // Filter out nulls - if (audiobooksUpdated.length) { - libraryScan.resultsUpdated += audiobooksUpdated.length - await this.db.updateEntities('audiobook', audiobooksUpdated) - this.emitter('audiobooks_updated', audiobooksUpdated.map(ab => ab.toJSONExpanded())) + itemsUpdated = itemsUpdated.filter(li => li) // Filter out nulls + if (itemsUpdated.length) { + libraryScan.resultsUpdated += itemsUpdated.length + await this.db.updateLibraryItems(itemsUpdated) + this.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded())) } } - async scanNewAudiobookDataChunk(newAudiobookDataToScan, libraryScan) { - var newAudiobooks = await Promise.all(newAudiobookDataToScan.map((abd) => { - return this.scanNewAudiobook(abd, libraryScan.preferAudioMetadata, libraryScan.preferOpfMetadata, libraryScan.findCovers, libraryScan) + async scanNewLibraryItemDataChunk(newLibraryItemsData, libraryScan) { + var newLibraryItems = await Promise.all(newLibraryItemsData.map((lid) => { + return this.scanNewLibraryItem(lid, libraryScan.libraryMediaType, libraryScan.preferAudioMetadata, libraryScan.preferOpfMetadata, libraryScan.findCovers, libraryScan) })) - newAudiobooks = newAudiobooks.filter(ab => ab) // Filter out nulls - libraryScan.resultsAdded += newAudiobooks.length - await this.db.insertAudiobooks(newAudiobooks) - this.emitter('audiobooks_added', newAudiobooks.map(ab => ab.toJSONExpanded())) + newLibraryItems = newLibraryItems.filter(li => li) // Filter out nulls + libraryScan.resultsAdded += newLibraryItems.length + await this.db.insertLibraryItems(newLibraryItems) + this.emitter('items_added', newLibraryItems.map(li => li.toJSONExpanded())) } - async rescanAudiobook(audiobookCheckData, libraryScan) { - const { newAudioFileData, audioFilesRemoved, newOtherFileData, audiobook, bookScanData, updated, existingAudioFileData, existingOtherFileData } = audiobookCheckData - libraryScan.addLog(LogLevel.DEBUG, `Library "${libraryScan.libraryName}" Re-scanning "${audiobook.path}"`) + async rescanLibraryItem(libraryItemCheckData, libraryScan) { + const { newLibraryFiles, filesRemoved, existingLibraryFiles, libraryItem, scanData, updated } = libraryItemCheckData + libraryScan.addLog(LogLevel.DEBUG, `Library "${libraryScan.libraryName}" Re-scanning "${libraryItem.path}"`) var hasUpdated = updated // Sync other files first to use local images as cover before extracting audio file cover - if (newOtherFileData.length || libraryScan.scanOptions.forceRescan) { - // TODO: Cleanup other file sync - var allOtherFiles = newOtherFileData.concat(existingOtherFileData) - if (await audiobook.syncOtherFiles(allOtherFiles, libraryScan.preferOpfMetadata)) { - hasUpdated = true - } + if (await libraryItem.syncFiles(libraryScan.preferOpfMetadata)) { + hasUpdated = true } // forceRescan all existing audio files - will probe and update ID3 tag metadata - if (libraryScan.scanOptions.forceRescan && existingAudioFileData.length) { - if (await AudioFileScanner.scanAudioFiles(existingAudioFileData, bookScanData, audiobook, libraryScan.preferAudioMetadata, libraryScan)) { + var existingAudioFiles = existingLibraryFiles.filter(lf => lf.fileType === 'audio') + if (libraryScan.scanOptions.forceRescan && existingAudioFiles.length) { + if (await AudioFileScanner.scanAudioFiles(existingAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) { hasUpdated = true } } // Scan new audio files - if (newAudioFileData.length || audioFilesRemoved.length) { - if (await AudioFileScanner.scanAudioFiles(newAudioFileData, bookScanData, audiobook, libraryScan.preferAudioMetadata, libraryScan)) { + var newAudioFiles = newLibraryFiles.filter(lf => lf.fileType === 'audio') + 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 } } // If an audio file has embedded cover art and no cover is set yet, extract & use it - if (newAudioFileData.length || libraryScan.scanOptions.forceRescan) { - if (audiobook.hasEmbeddedCoverArt && !audiobook.cover) { - var outputCoverDirs = this.getCoverDirectory(audiobook) - var relativeDir = await audiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath) - if (relativeDir) { + if (newAudioFiles.length || libraryScan.scanOptions.forceRescan) { + if (libraryItem.media.hasEmbeddedCoverArt && !libraryItem.media.coverPath) { + var savedCoverPath = await this.coverController.saveEmbeddedCoverArt(libraryItem) + if (savedCoverPath) { 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 - audiobook.setInvalid() + if (!libraryItem.media.hasMediaFiles) { // Library item is invalid + libraryItem.setInvalid() hasUpdated = true - } else if (audiobook.isInvalid) { - audiobook.isInvalid = false + } else if (libraryItem.isInvalid) { + libraryItem.isInvalid = false 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) - if (audiobook && libraryScan.findCovers && !audiobook.cover && audiobook.book.shouldSearchForCover) { - var updatedCover = await this.searchForCover(audiobook, libraryScan) - audiobook.book.updateLastCoverSearch(updatedCover) + if (libraryScan.findCovers && !libraryItem.media.coverPath && libraryItem.media.shouldSearchForCover) { + var updatedCover = await this.searchForCover(libraryItem, libraryScan) + libraryItem.media.updateLastCoverSearch(updatedCover) hasUpdated = true } - return hasUpdated ? audiobook : null + return hasUpdated ? libraryItem : null } - async scanNewAudiobook(audiobookData, preferAudioMetadata, preferOpfMetadata, findCovers, libraryScan = null) { - if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Scanning new book "${audiobookData.path}"`) - else Logger.debug(`[Scanner] Scanning new book "${audiobookData.path}"`) + async scanNewLibraryItem(libraryItemData, libraryMediaType, preferAudioMetadata, preferOpfMetadata, findCovers, libraryScan = null) { + if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Scanning new library item "${libraryItemData.path}"`) + else Logger.debug(`[Scanner] Scanning new item "${libraryItemData.path}"`) - var audiobook = new Audiobook() - audiobook.setData(audiobookData) + var libraryItem = new LibraryItem() + libraryItem.setData(libraryMediaType, libraryItemData) - if (audiobookData.audioFiles.length) { - await AudioFileScanner.scanAudioFiles(audiobookData.audioFiles, audiobookData, audiobook, preferAudioMetadata, libraryScan) + var audioFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio') + if (audioFiles.length) { + await AudioFileScanner.scanAudioFiles(audioFiles, libraryItemData, libraryItem, preferAudioMetadata, libraryScan) } - if (!audiobook.audioFilesToInclude.length && !audiobook.ebooks.length) { - // Audiobook has no ebooks and no valid audio tracks do not continue - Logger.warn(`[Scanner] Audiobook has no ebooks and no valid audio tracks "${audiobook.path}"`) + if (!libraryItem.media.hasMediaFiles) { + Logger.warn(`[Scanner] Library item has no media files "${libraryItemData.path}"`) return null } - // Look for desc.txt and reader.txt and update - await audiobook.saveDataFromTextFiles(preferOpfMetadata) + await libraryItem.syncFiles(preferOpfMetadata) // Extract embedded cover art if cover is not already in directory - if (audiobook.hasEmbeddedCoverArt && !audiobook.cover) { - var outputCoverDirs = this.getCoverDirectory(audiobook) - var relativeDir = await audiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath) - if (relativeDir) { - if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Saved embedded cover art "${relativeDir}"`) - else Logger.debug(`[Scanner] Saved embedded cover art "${relativeDir}"`) + if (libraryItem.media.hasEmbeddedCoverArt && !libraryItem.media.coverPath) { + var coverPath = await this.coverController.saveEmbeddedCoverArt(libraryItem) + if (coverPath) { + if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Saved embedded cover art "${coverPath}"`) + else Logger.debug(`[Scanner] Saved embedded cover art "${coverPath}"`) } } // Scan for cover if enabled and has no cover - if (audiobook && findCovers && !audiobook.cover && audiobook.book.shouldSearchForCover) { - var updatedCover = await this.searchForCover(audiobook, libraryScan) - audiobook.book.updateLastCoverSearch(updatedCover) + if (libraryMediaType !== 'podcast') { + if (libraryItem && findCovers && !libraryItem.media.coverPath && libraryItem.media.shouldSearchForCover) { + 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) { @@ -448,113 +486,113 @@ class Scanner { continue; } var relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath) - var fileUpdateBookGroup = groupFilesIntoAudiobookPaths(relFilePaths, true) - var folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateBookGroup) + var fileUpdateGroup = groupFilesIntoLibraryItemPaths(relFilePaths, true) + var folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup) 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}"`) - // First pass - Remove files in parent dirs of audiobooks and remap the fileupdate group - // Test Case: Moving audio files from audiobook folder to author folder should trigger a re-scan of audiobook - var updateGroup = { ...fileUpdateBookGroup } - for (const bookDir in updateGroup) { - var bookDirNestedFiles = fileUpdateBookGroup[bookDir].filter(b => b.includes('/')) - if (!bookDirNestedFiles.length) continue; + // First pass - Remove files in parent dirs of items and remap the fileupdate group + // Test Case: Moving audio files from library item folder to author folder should trigger a re-scan of the item + var updateGroup = { ...fileUpdateGroup } + for (const itemDir in updateGroup) { + var itemDirNestedFiles = fileUpdateGroup[itemDir].filter(b => b.includes('/')) + if (!itemDirNestedFiles.length) continue; - var firstNest = bookDirNestedFiles[0].split('/').shift() - var altDir = `${bookDir}/${firstNest}` + var firstNest = itemDirNestedFiles[0].split('/').shift() + var altDir = `${itemDir}/${firstNest}` - var fullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), bookDir) - var childAudiobook = this.db.audiobooks.find(ab => ab.fullPath !== fullPath && ab.fullPath.startsWith(fullPath)) - if (!childAudiobook) { + var fullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), itemDir) + var childLibraryItem = this.db.libraryItems.find(li => li.path !== fullPath && li.fullPath.startsWith(fullPath)) + if (!childLibraryItem) { continue; } var altFullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), altDir) - var altChildAudiobook = this.db.audiobooks.find(ab => ab.fullPath !== altFullPath && ab.fullPath.startsWith(altFullPath)) - if (altChildAudiobook) { + var altChildLibraryItem = this.db.libraryItems.find(li => li.path !== altFullPath && li.path.startsWith(altFullPath)) + if (altChildLibraryItem) { continue; } - delete fileUpdateBookGroup[bookDir] - fileUpdateBookGroup[altDir] = bookDirNestedFiles.map((f) => f.split('/').slice(1).join('/')) - Logger.warn(`[Scanner] Some files were modified in a parent directory of an audiobook "${childAudiobook.title}" - ignoring`) + delete fileUpdateGroup[itemDir] + fileUpdateGroup[altDir] = itemDirNestedFiles.map((f) => f.split('/').slice(1).join('/')) + 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 - var bookGroupingResults = {} - for (const bookDir in fileUpdateBookGroup) { - var fullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), bookDir) + // Second pass: Check for new/updated/removed items + var itemGroupingResults = {} + for (const itemDir in fileUpdateGroup) { + var fullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), itemDir) - // Check if book dir group is already an audiobook - var existingAudiobook = this.db.audiobooks.find(ab => fullPath.startsWith(ab.fullPath)) - if (existingAudiobook) { + // Check if book dir group is already an item + var existingLibraryItem = this.db.libraryItems.find(li => fullPath.startsWith(li.path)) + if (existingLibraryItem) { - // Is the audiobook exactly - check if was deleted - if (existingAudiobook.fullPath === fullPath) { + // Is the item exactly - check if was deleted + if (existingLibraryItem.path === fullPath) { var exists = await fs.pathExists(fullPath) if (!exists) { - Logger.info(`[Scanner] Scanning file update group and audiobook was deleted "${existingAudiobook.title}" - marking as missing`) - existingAudiobook.setMissing() - await this.db.updateAudiobook(existingAudiobook) - this.emitter('audiobook_updated', existingAudiobook.toJSONExpanded()) + Logger.info(`[Scanner] Scanning file update group and library item was deleted "${existingLibraryItem.media.metadata.title}" - marking as missing`) + existingLibraryItem.setMissing() + await this.db.updateLibraryItem(existingLibraryItem) + this.emitter('item_updated', existingLibraryItem.toJSONExpanded()) - bookGroupingResults[bookDir] = ScanResult.REMOVED + itemGroupingResults[itemDir] = ScanResult.REMOVED continue; } } - // Scan audiobook for updates - Logger.debug(`[Scanner] Folder update for relative path "${bookDir}" is in audiobook "${existingAudiobook.title}" - scan for updates`) - bookGroupingResults[bookDir] = await this.scanAudiobook(folder, existingAudiobook) + // Scan library item for updates + Logger.debug(`[Scanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.metadata.title}" - scan for updates`) + itemGroupingResults[itemDir] = await this.scanLibraryItem(library.mediaType, folder, existingLibraryItem) continue; } - // Check if an audiobook is a subdirectory of this dir - var childAudiobook = this.db.audiobooks.find(ab => ab.fullPath.startsWith(fullPath)) - if (childAudiobook) { - Logger.warn(`[Scanner] Files were modified in a parent directory of an audiobook "${childAudiobook.title}" - ignoring`) - bookGroupingResults[bookDir] = ScanResult.NOTHING + // Check if a library item is a subdirectory of this dir + var childItem = this.db.libraryItems.find(li => li.path.startsWith(fullPath)) + if (childItem) { + Logger.warn(`[Scanner] Files were modified in a parent directory of a library item "${childItem.media.metadata.title}" - ignoring`) + itemGroupingResults[itemDir] = ScanResult.NOTHING continue; } - Logger.debug(`[Scanner] Folder update group must be a new book "${bookDir}" in library "${library.name}"`) - var newAudiobook = await this.scanPotentialNewAudiobook(folder, fullPath) - if (newAudiobook) { - await this.db.insertAudiobook(newAudiobook) - this.emitter('audiobook_added', newAudiobook.toJSONExpanded()) + Logger.debug(`[Scanner] Folder update group must be a new item "${itemDir}" in library "${library.name}"`) + var newLibraryItem = await this.scanPotentialNewLibraryItem(library.mediaType, folder, fullPath) + if (newLibraryItem) { + await this.db.insertLibraryItem(newLibraryItem) + 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) { - var audiobookData = await getAudiobookFileData(folder, fullPath, this.db.serverSettings) - if (!audiobookData) return null + async scanPotentialNewLibraryItem(libraryMediaType, folder, fullPath) { + var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, this.db.serverSettings) + if (!libraryItemData) return null 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 = { titleDistance: 2, authorDistance: 2 } 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 (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Found best cover for "${audiobook.title}"`) - else Logger.debug(`[Scanner] 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 "${libraryItem.media.metadata.title}"`) // If the first cover result fails, attempt to download the second for (let i = 0; i < results.length && i < 2; i++) { // 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) { Logger.error(`[Scanner] Failed to download cover from url "${results[i]}" | Attempt ${i + 1}`, result.error) diff --git a/server/utils/abmetadataGenerator.js b/server/utils/abmetadataGenerator.js index 914461b2..21c4be5c 100644 --- a/server/utils/abmetadataGenerator.js +++ b/server/utils/abmetadataGenerator.js @@ -8,8 +8,6 @@ const bookKeyMap = { subtitle: 'subtitle', author: 'authorFL', narrator: 'narratorFL', - series: 'series', - volumeNumber: 'volumeNumber', publishYear: 'publishYear', publisher: 'publisher', description: 'description', @@ -39,7 +37,7 @@ function generate(audiobook, outputPath) { } 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) => { Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error) return false diff --git a/server/utils/filePerms.js b/server/utils/filePerms.js index 962a08cf..1a511bb4 100644 --- a/server/utils/filePerms.js +++ b/server/utils/filePerms.js @@ -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) => { if (!silent) Logger.debug(`[FilePerms] Setting permission "${mode}" for uid ${uid} and gid ${gid} | "${path}"`) chmodr(path, mode, uid, gid, resolve) diff --git a/server/utils/globals.js b/server/utils/globals.js index 6c0a4d44..781a0804 100644 --- a/server/utils/globals.js +++ b/server/utils/globals.js @@ -3,7 +3,7 @@ const globals = { SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'mp4', 'aac'], SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], TextFileTypes: ['txt', 'nfo'], - MetadataFileTypes: ['opf', 'abs'] + MetadataFileTypes: ['opf', 'abs', 'xml'] } module.exports = globals diff --git a/server/utils/parseAuthors.js b/server/utils/parseAuthors.js deleted file mode 100644 index 3a73fd12..00000000 --- a/server/utils/parseAuthors.js +++ /dev/null @@ -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 - } -} \ No newline at end of file diff --git a/server/utils/parseNameString.js b/server/utils/parseNameString.js new file mode 100644 index 00000000..7a407393 --- /dev/null +++ b/server/utils/parseNameString.js @@ -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 + } +} \ No newline at end of file diff --git a/server/utils/parseOpfMetadata.js b/server/utils/parseOpfMetadata.js index 546cfb54..276f1682 100644 --- a/server/utils/parseOpfMetadata.js +++ b/server/utils/parseOpfMetadata.js @@ -71,20 +71,20 @@ function fetchLanguage(metadata) { } function fetchSeries(metadata) { - if(typeof metadata.meta == "undefined") return null + if (typeof metadata.meta == "undefined") return null return fetchTagString(metadata.meta, "calibre:series") } function fetchVolumeNumber(metadata) { - if(typeof metadata.meta == "undefined") return null + if (typeof metadata.meta == "undefined") return null return fetchTagString(metadata.meta, "calibre:series_index") } function fetchNarrators(creators, metadata) { var roleNrt = fetchCreator(creators, 'nrt') - if(typeof metadata.meta == "undefined" || roleNrt != null) return roleNrt + if (typeof metadata.meta == "undefined" || roleNrt != null) return roleNrt 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(", ") } catch { return null @@ -103,7 +103,7 @@ module.exports.parseOpfMetadataXML = async (xml) => { if (typeof metadata.meta != "undefined") { metadata.meta = {} - for(var match of xml.matchAll(//g)) { + for (var match of xml.matchAll(//g)) { metadata.meta[match.groups['name']] = [match.groups['content']] } } @@ -120,7 +120,7 @@ module.exports.parseOpfMetadataXML = async (xml) => { genres: fetchGenres(metadata), language: fetchLanguage(metadata), series: fetchSeries(metadata), - volumeNumber: fetchVolumeNumber(metadata) + sequence: fetchVolumeNumber(metadata) } return data } \ No newline at end of file diff --git a/server/utils/scandir.js b/server/utils/scandir.js index 2e84d3c2..32bc52cf 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -3,8 +3,9 @@ const fs = require('fs-extra') const Logger = require('../Logger') const { recurseFiles, getFileTimestampsWithIno } = require('./fileUtils') const globals = require('./globals') +const LibraryFile = require('../objects/files/LibraryFile') -function isBookFile(path) { +function isMediaFile(path) { if (!path) return false var ext = Path.extname(path) if (!ext) return false @@ -14,8 +15,8 @@ function isBookFile(path) { // TODO: Function needs to be re-done // Input: array of relative file paths -// Output: map of files grouped into potential audiobook dirs -function groupFilesIntoAudiobookPaths(paths) { +// Output: map of files grouped into potential item dirs +function groupFilesIntoLibraryItemPaths(paths) { // Step 1: Clean path, Remove leading "/", Filter out files in root dir var pathsFiltered = paths.map(path => { return path.startsWith('/') ? path.slice(1) : path @@ -29,7 +30,7 @@ function groupFilesIntoAudiobookPaths(paths) { }) // Step 3: Group files in dirs - var audiobookGroup = {} + var itemGroup = {} pathsFiltered.forEach((path) => { var dirparts = Path.dirname(path).split('/') var numparts = dirparts.length @@ -40,41 +41,41 @@ function groupFilesIntoAudiobookPaths(paths) { var dirpart = dirparts.shift() _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)) - audiobookGroup[_path].push(relpath) + itemGroup[_path].push(relpath) return } else if (!dirparts.length) { // This is the last directory, create group - audiobookGroup[_path] = [Path.basename(path)] + itemGroup[_path] = [Path.basename(path)] 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 - audiobookGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))] + itemGroup[_path] = [Path.posix.join(dirparts[0], Path.basename(path))] return } } }) - return audiobookGroup + return itemGroup } -module.exports.groupFilesIntoAudiobookPaths = groupFilesIntoAudiobookPaths +module.exports.groupFilesIntoLibraryItemPaths = groupFilesIntoLibraryItemPaths // Input: array of relative file items (see recurseFiles) -// Output: map of files grouped into potential audiobook dirs -function groupFileItemsIntoBooks(fileItems) { +// Output: map of files grouped into potential libarary item dirs +function groupFileItemsIntoLibraryItemDirs(fileItems) { // Step 1: Filter out files in root dir (with depth of 0) var itemsFiltered = fileItems.filter(i => i.deep > 0) - // Step 2: Seperate audio/ebook files and other files - // - Directories without an audio or ebook file will not be included - var bookFileItems = [] + // Step 2: Seperate media files and other files + // - Directories without a media file will not be included + var mediaFileItems = [] var otherFileItems = [] itemsFiltered.forEach(item => { - if (isBookFile(item.fullpath)) bookFileItems.push(item) + if (isMediaFile(item.fullpath)) mediaFileItems.push(item) else otherFileItems.push(item) }) - // Step 3: Group audio files in audiobooks - var audiobookGroup = {} - bookFileItems.forEach((item) => { + // Step 3: Group audio files in library items + var libraryItemGroup = {} + mediaFileItems.forEach((item) => { var dirparts = item.reldirpath.split('/') var numparts = dirparts.length var _path = '' @@ -84,21 +85,21 @@ function groupFileItemsIntoBooks(fileItems) { var dirpart = dirparts.shift() _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) - audiobookGroup[_path].push(relpath) + libraryItemGroup[_path].push(relpath) return } else if (!dirparts.length) { // This is the last directory, create group - audiobookGroup[_path] = [item.name] + libraryItemGroup[_path] = [item.name] 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 - audiobookGroup[_path] = [Path.posix.join(dirparts[0], item.name)] + libraryItemGroup[_path] = [Path.posix.join(dirparts[0], item.name)] return } } }) - // Step 4: Add other files into audiobook groups + // Step 4: Add other files into library item groups otherFileItems.forEach((item) => { var dirparts = item.reldirpath.split('/') var numparts = dirparts.length @@ -108,30 +109,23 @@ function groupFileItemsIntoBooks(fileItems) { for (let i = 0; i < numparts; i++) { var dirpart = dirparts.shift() _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) - audiobookGroup[_path].push(relpath) + libraryItemGroup[_path].push(relpath) return } } }) - return audiobookGroup + return libraryItemGroup } -function cleanFileObjects(basepath, abrelpath, files) { +function cleanFileObjects(libraryItemPath, libraryItemRelPath, files) { return Promise.all(files.map(async (file) => { - var fullPath = Path.posix.join(basepath, file) - var fileTsData = await getFileTimestampsWithIno(fullPath) - - var ext = Path.extname(file) - return { - 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 - } + var filePath = Path.posix.join(libraryItemPath, file) + var relFilePath = Path.posix.join(libraryItemRelPath, file) + var newLibraryFile = new LibraryFile() + await newLibraryFile.setDataFromPath(filePath, relFilePath) + return newLibraryFile })) } @@ -148,9 +142,8 @@ function getFileType(ext) { } // Scan folder -async function scanRootDir(folder, serverSettings = {}) { +async function scanFolder(libraryMediaType, folder, serverSettings = {}) { var folderPath = folder.fullPath.replace(/\\/g, '/') - var parseSubtitle = !!serverSettings.scannerParseSubtitle var pathExists = await fs.pathExists(folderPath) if (!pathExists) { @@ -160,39 +153,38 @@ async function scanRootDir(folder, serverSettings = {}) { var fileItems = await recurseFiles(folderPath) - var audiobookGrouping = groupFileItemsIntoBooks(fileItems) + var libraryItemGrouping = groupFileItemsIntoLibraryItemDirs(fileItems) - if (!Object.keys(audiobookGrouping).length) { - Logger.error('Root path has no books', fileItems.length) + if (!Object.keys(libraryItemGrouping).length) { + Logger.error('Root path has no media folders', fileItems.length) return [] } - var audiobooks = [] - for (const audiobookPath in audiobookGrouping) { - var audiobookData = getAudiobookDataFromDir(folderPath, audiobookPath, parseSubtitle) + var items = [] + for (const libraryItemPath in libraryItemGrouping) { + var libraryItemData = getDataFromMediaDir(libraryMediaType, folderPath, libraryItemPath, serverSettings) - var fileObjs = await cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath]) - var audiobookFolderStats = await getFileTimestampsWithIno(audiobookData.fullPath) - audiobooks.push({ + var fileObjs = await cleanFileObjects(libraryItemData.path, libraryItemData.relPath, libraryItemGrouping[libraryItemPath]) + var libraryItemFolderStats = await getFileTimestampsWithIno(libraryItemData.path) + items.push({ folderId: folder.id, libraryId: folder.libraryId, - ino: audiobookFolderStats.ino, - mtimeMs: audiobookFolderStats.mtimeMs || 0, - ctimeMs: audiobookFolderStats.ctimeMs || 0, - birthtimeMs: audiobookFolderStats.birthtimeMs || 0, - ...audiobookData, - audioFiles: fileObjs.filter(f => f.filetype === 'audio'), - otherFiles: fileObjs.filter(f => f.filetype !== 'audio') + ino: libraryItemFolderStats.ino, + mtimeMs: libraryItemFolderStats.mtimeMs || 0, + ctimeMs: libraryItemFolderStats.ctimeMs || 0, + birthtimeMs: libraryItemFolderStats.birthtimeMs || 0, + ...libraryItemData, + libraryFiles: fileObjs }) } - return audiobooks + return items } -module.exports.scanRootDir = scanRootDir +module.exports.scanFolder = scanFolder // Input relative filepath, output all details that can be parsed -function getAudiobookDataFromDir(folderPath, dir, parseSubtitle = false) { - dir = dir.replace(/\\/g, '/') - var splitDir = dir.split('/') +function getBookDataFromDir(folderPath, relPath, parseSubtitle = false) { + relPath = relPath.replace(/\\/g, '/') + var splitDir = relPath.split('/') // Audio files will always be in the directory named for the title var title = splitDir.pop() @@ -244,7 +236,6 @@ function getAudiobookDataFromDir(folderPath, dir, parseSubtitle = false) { } } - var publishYear = null // If Title is of format 1999 OR (1999) - Title, then use 1999 as publish year var publishYearMatch = title.match(/^(\(?[0-9]{4}\)?) - (.+)/) @@ -270,58 +261,52 @@ function getAudiobookDataFromDir(folderPath, dir, parseSubtitle = false) { } return { - author, - title, - subtitle, - series, - volumeNumber, - publishYear, - path: dir, // relative audiobook path i.e. /Author Name/Book Name/.. - fullPath: Path.posix.join(folderPath, dir) // i.e. /audiobook/Author Name/Book Name/.. + mediaMetadata: { + author, + title, + subtitle, + series, + sequence: volumeNumber, + publishYear, + }, + 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 + 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 audiobookDir = audiobookPath.replace(folderFullPath, '').slice(1) - var audiobookData = getAudiobookDataFromDir(folderFullPath, audiobookDir, parseSubtitle) - var audiobookFolderStats = await getFileTimestampsWithIno(audiobookData.fullPath) - var audiobook = { - ino: audiobookFolderStats.ino, - mtimeMs: audiobookFolderStats.mtimeMs || 0, - ctimeMs: audiobookFolderStats.ctimeMs || 0, - birthtimeMs: audiobookFolderStats.birthtimeMs || 0, + var libraryItemDir = libraryItemPath.replace(folderFullPath, '').slice(1) + var libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir, serverSettings) + var libraryItemDirStats = await getFileTimestampsWithIno(libraryItemData.path) + var libraryItem = { + ino: libraryItemDirStats.ino, + mtimeMs: libraryItemDirStats.mtimeMs || 0, + ctimeMs: libraryItemDirStats.ctimeMs || 0, + birthtimeMs: libraryItemDirStats.birthtimeMs || 0, folderId: folder.id, libraryId: folder.libraryId, - ...audiobookData, - audioFiles: [], - otherFiles: [] + ...libraryItemData, + libraryFiles: [] } for (let i = 0; i < fileItems.length; i++) { var fileItem = fileItems[i] - - var fileStatData = await getFileTimestampsWithIno(fileItem.fullpath) - var fileObj = { - filetype: getFileType(fileItem.extension), - 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) - } + var newLibraryFile = new LibraryFile() + // fileItem.path is the relative path + await newLibraryFile.setDataFromPath(fileItem.fullpath, fileItem.path) + libraryItem.libraryFiles.push(newLibraryFile) } - return audiobook + return libraryItem } -module.exports.getAudiobookFileData = getAudiobookFileData \ No newline at end of file +module.exports.getLibraryItemFileData = getLibraryItemFileData \ No newline at end of file |