mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-03-28 00:21:47 +01:00
Updates for new book scanner
This commit is contained in:
parent
4ad1cd2968
commit
2df95c1712
@ -761,6 +761,7 @@ export default {
|
|||||||
if (this.libraryId) {
|
if (this.libraryId) {
|
||||||
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
|
this.$store.commit('libraries/setCurrentLibrary', this.libraryId)
|
||||||
}
|
}
|
||||||
|
this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||||
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
|
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
|
||||||
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
|
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
|
||||||
@ -769,6 +770,7 @@ export default {
|
|||||||
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
|
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
|
||||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||||
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
|
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
|
||||||
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
|
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
|
||||||
|
@ -234,8 +234,8 @@ class AuthorController {
|
|||||||
return this.cacheManager.handleAuthorCache(res, author, options)
|
return this.cacheManager.handleAuthorCache(res, author, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
middleware(req, res, next) {
|
async middleware(req, res, next) {
|
||||||
const author = Database.authors.find(au => au.id === req.params.id)
|
const author = await Database.authorModel.getOldById(req.params.id)
|
||||||
if (!author) return res.sendStatus(404)
|
if (!author) return res.sendStatus(404)
|
||||||
|
|
||||||
if (req.method == 'DELETE' && !req.user.canDelete) {
|
if (req.method == 'DELETE' && !req.user.canDelete) {
|
||||||
|
@ -573,18 +573,19 @@ class LibraryController {
|
|||||||
* rssfeed: adds `rssFeed` to series object if a feed is open
|
* rssfeed: adds `rssFeed` to series object if a feed is open
|
||||||
* progress: adds `progress` to series object with { libraryItemIds:Array<llid>, libraryItemIdsFinished:Array<llid>, isFinished:boolean }
|
* progress: adds `progress` to series object with { libraryItemIds:Array<llid>, libraryItemIdsFinished:Array<llid>, isFinished:boolean }
|
||||||
*
|
*
|
||||||
* @param {*} req
|
* @param {import('express').Request} req
|
||||||
* @param {*} res - Series
|
* @param {import('express').Response} res - Series
|
||||||
*/
|
*/
|
||||||
async getSeriesForLibrary(req, res) {
|
async getSeriesForLibrary(req, res) {
|
||||||
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
|
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
|
||||||
|
|
||||||
const series = Database.series.find(se => se.id === req.params.seriesId)
|
const series = await Database.seriesModel.findByPk(req.params.seriesId)
|
||||||
if (!series) return res.sendStatus(404)
|
if (!series) return res.sendStatus(404)
|
||||||
|
const oldSeries = series.getOldSeries()
|
||||||
|
|
||||||
const libraryItemsInSeries = await libraryItemsBookFilters.getLibraryItemsForSeries(series, req.user)
|
const libraryItemsInSeries = await libraryItemsBookFilters.getLibraryItemsForSeries(oldSeries, req.user)
|
||||||
|
|
||||||
const seriesJson = series.toJSON()
|
const seriesJson = oldSeries.toJSON()
|
||||||
if (include.includes('progress')) {
|
if (include.includes('progress')) {
|
||||||
const libraryItemsFinished = libraryItemsInSeries.filter(li => !!req.user.getMediaProgress(li.id)?.isFinished)
|
const libraryItemsFinished = libraryItemsInSeries.filter(li => !!req.user.getMediaProgress(li.id)?.isFinished)
|
||||||
seriesJson.progress = {
|
seriesJson.progress = {
|
||||||
|
@ -279,7 +279,7 @@ class CoverManager {
|
|||||||
if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {
|
if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {
|
||||||
coverDirPath = libraryItemPath
|
coverDirPath = libraryItemPath
|
||||||
} else {
|
} else {
|
||||||
coverDirPath = Path.posix.join(this.ItemMetadataPath, libraryItemId)
|
coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId)
|
||||||
}
|
}
|
||||||
await fs.ensureDir(coverDirPath)
|
await fs.ensureDir(coverDirPath)
|
||||||
|
|
||||||
|
@ -83,6 +83,17 @@ class Author extends Model {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get oldAuthor by id
|
||||||
|
* @param {string} authorId
|
||||||
|
* @returns {oldAuthor}
|
||||||
|
*/
|
||||||
|
static async getOldById(authorId) {
|
||||||
|
const author = await this.findByPk(authorId)
|
||||||
|
if (!author) return null
|
||||||
|
return author.getOldAuthor()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if author exists
|
* Check if author exists
|
||||||
* @param {string} authorId
|
* @param {string} authorId
|
||||||
|
@ -118,7 +118,12 @@ class AudioFile {
|
|||||||
setDataFromProbe(libraryFile, probeData) {
|
setDataFromProbe(libraryFile, probeData) {
|
||||||
this.ino = libraryFile.ino || null
|
this.ino = libraryFile.ino || null
|
||||||
|
|
||||||
this.metadata = libraryFile.metadata.clone()
|
if (libraryFile.metadata instanceof FileMetadata) {
|
||||||
|
this.metadata = libraryFile.metadata.clone()
|
||||||
|
} else {
|
||||||
|
this.metadata = new FileMetadata(libraryFile.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
this.addedAt = Date.now()
|
this.addedAt = Date.now()
|
||||||
this.updatedAt = Date.now()
|
this.updatedAt = Date.now()
|
||||||
|
|
||||||
|
@ -1,15 +1,18 @@
|
|||||||
const uuidv4 = require("uuid").v4
|
const uuidv4 = require("uuid").v4
|
||||||
|
const { Sequelize } = require('sequelize')
|
||||||
const { LogLevel } = require('../utils/constants')
|
const { LogLevel } = require('../utils/constants')
|
||||||
const { getTitleIgnorePrefix } = require('../utils/index')
|
const { getTitleIgnorePrefix, areEquivalent } = require('../utils/index')
|
||||||
const { parseOpfMetadataXML } = require('../utils/parsers/parseOpfMetadata')
|
const { parseOpfMetadataXML } = require('../utils/parsers/parseOpfMetadata')
|
||||||
const { parseOverdriveMediaMarkersAsChapters } = require('../utils/parsers/parseOverdriveMediaMarkers')
|
const { parseOverdriveMediaMarkersAsChapters } = require('../utils/parsers/parseOverdriveMediaMarkers')
|
||||||
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
|
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
|
||||||
const parseNameString = require('../utils/parsers/parseNameString')
|
const parseNameString = require('../utils/parsers/parseNameString')
|
||||||
|
const globals = require('../utils/globals')
|
||||||
const AudioFileScanner = require('./AudioFileScanner')
|
const AudioFileScanner = require('./AudioFileScanner')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
const { readTextFile } = require('../utils/fileUtils')
|
const { readTextFile } = require('../utils/fileUtils')
|
||||||
const AudioFile = require('../objects/files/AudioFile')
|
const AudioFile = require('../objects/files/AudioFile')
|
||||||
const CoverManager = require('../managers/CoverManager')
|
const CoverManager = require('../managers/CoverManager')
|
||||||
|
const fsExtra = require("../libs/fsExtra")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Metadata for books pulled from files
|
* Metadata for books pulled from files
|
||||||
@ -37,6 +40,313 @@ const CoverManager = require('../managers/CoverManager')
|
|||||||
class BookScanner {
|
class BookScanner {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('../models/LibraryItem')} existingLibraryItem
|
||||||
|
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||||
|
* @param {import('./LibraryScan')} libraryScan
|
||||||
|
* @returns {import('../models/LibraryItem')}
|
||||||
|
*/
|
||||||
|
async rescanExistingBookLibraryItem(existingLibraryItem, libraryItemData, libraryScan) {
|
||||||
|
/** @type {import('../models/Book')} */
|
||||||
|
const media = await existingLibraryItem.getMedia({
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.authorModel,
|
||||||
|
through: {
|
||||||
|
attributes: ['id', 'createdAt']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.seriesModel,
|
||||||
|
through: {
|
||||||
|
attributes: ['id', 'sequence', 'createdAt']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [
|
||||||
|
[Database.authorModel, Database.bookAuthorModel, 'createdAt', 'ASC'],
|
||||||
|
[Database.seriesModel, 'bookSeries', 'createdAt', 'ASC']
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
let hasMediaChanges = libraryItemData.hasAudioFileChanges
|
||||||
|
if (libraryItemData.hasAudioFileChanges || libraryItemData.audioLibraryFiles.length !== media.audioFiles.length) {
|
||||||
|
// Filter out audio files that were removed
|
||||||
|
media.audioFiles = media.audioFiles.filter(af => !libraryItemData.checkAudioFileRemoved(af))
|
||||||
|
|
||||||
|
// Update audio files that were modified
|
||||||
|
if (libraryItemData.audioLibraryFilesModified.length) {
|
||||||
|
let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, libraryItemData.audioLibraryFilesModified)
|
||||||
|
media.audioFiles = media.audioFiles.map((audioFileObj) => {
|
||||||
|
let matchedScannedAudioFile = scannedAudioFiles.find(saf => saf.metadata.path === audioFileObj.metadata.path)
|
||||||
|
if (!matchedScannedAudioFile) {
|
||||||
|
matchedScannedAudioFile = scannedAudioFiles.find(saf => saf.ino === audioFileObj.ino)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedScannedAudioFile) {
|
||||||
|
scannedAudioFiles = scannedAudioFiles.filter(saf => saf !== matchedScannedAudioFile)
|
||||||
|
const audioFile = new AudioFile(audioFileObj)
|
||||||
|
audioFile.updateFromScan(matchedScannedAudioFile)
|
||||||
|
return audioFile.toJSON()
|
||||||
|
}
|
||||||
|
return audioFileObj
|
||||||
|
})
|
||||||
|
// Modified audio files that were not found on the book
|
||||||
|
if (scannedAudioFiles.length) {
|
||||||
|
media.audioFiles.push(...scannedAudioFiles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new audio files scanned in
|
||||||
|
if (libraryItemData.audioLibraryFilesAdded.length) {
|
||||||
|
const scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, libraryItemData.audioLibraryFilesAdded)
|
||||||
|
media.audioFiles.push(...scannedAudioFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add audio library files that are not already set on the book (safety check)
|
||||||
|
let audioLibraryFilesToAdd = []
|
||||||
|
for (const audioLibraryFile of libraryItemData.audioLibraryFiles) {
|
||||||
|
if (!media.audioFiles.some(af => af.ino === audioLibraryFile.ino)) {
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `Existing audio library file "${audioLibraryFile.metadata.relPath}" was not set on book "${media.title}" so setting it now`)
|
||||||
|
|
||||||
|
audioLibraryFilesToAdd.push(audioLibraryFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (audioLibraryFilesToAdd.length) {
|
||||||
|
const scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, audioLibraryFilesToAdd)
|
||||||
|
media.audioFiles.push(...scannedAudioFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
media.audioFiles = AudioFileScanner.runSmartTrackOrder(existingLibraryItem.relPath, media.audioFiles)
|
||||||
|
|
||||||
|
media.duration = 0
|
||||||
|
media.audioFiles.forEach((af) => {
|
||||||
|
if (!isNaN(af.duration)) {
|
||||||
|
media.duration += af.duration
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
media.changed('audioFiles', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cover was removed
|
||||||
|
if (media.coverPath && !libraryItemData.imageLibraryFiles.some(lf => lf.metadata.path === media.coverPath)) {
|
||||||
|
media.coverPath = null
|
||||||
|
hasMediaChanges = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cover is not set and image files were found
|
||||||
|
if (!media.coverPath && libraryItemData.imageLibraryFiles.length) {
|
||||||
|
// Prefer using a cover image with the name "cover" otherwise use the first image
|
||||||
|
const coverMatch = libraryItemData.imageLibraryFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
||||||
|
media.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path
|
||||||
|
hasMediaChanges = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if ebook was removed
|
||||||
|
if (media.ebookFile && (libraryScan.library.settings.audiobooksOnly || libraryItemData.checkEbookFileRemoved(media.ebookFile))) {
|
||||||
|
media.ebookFile = null
|
||||||
|
hasMediaChanges = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if ebook is not set and ebooks were found
|
||||||
|
if (!media.ebookFile && !libraryScan.library.settings.audiobooksOnly && libraryItemData.ebookLibraryFiles.length) {
|
||||||
|
// Prefer to use an epub ebook then fallback to the first ebook found
|
||||||
|
let ebookLibraryFile = libraryItemData.ebookLibraryFiles.find(lf => lf.metadata.ext.slice(1).toLowerCase() === 'epub')
|
||||||
|
if (!ebookLibraryFile) ebookLibraryFile = libraryItemData.ebookLibraryFiles[0]
|
||||||
|
// Ebook file is the same as library file except for additional `ebookFormat`
|
||||||
|
ebookLibraryFile.ebookFormat = ebookLibraryFile.metadata.ext.slice(1).toLowerCase()
|
||||||
|
media.ebookFile = ebookLibraryFile
|
||||||
|
media.changed('ebookFile', true)
|
||||||
|
hasMediaChanges = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check/update the isSupplementary flag on libraryFiles for the LibraryItem
|
||||||
|
let libraryItemUpdated = false
|
||||||
|
for (const libraryFile of existingLibraryItem.libraryFiles) {
|
||||||
|
if (globals.SupportedEbookTypes.includes(libraryFile.metadata.ext.slice(1).toLowerCase())) {
|
||||||
|
if (media.ebookFile && libraryFile.ino === media.ebookFile.ino) {
|
||||||
|
if (libraryFile.isSupplementary !== false) {
|
||||||
|
libraryFile.isSupplementary = false
|
||||||
|
libraryItemUpdated = true
|
||||||
|
}
|
||||||
|
} else if (libraryFile.isSupplementary !== true) {
|
||||||
|
libraryFile.isSupplementary = true
|
||||||
|
libraryItemUpdated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (libraryItemUpdated) {
|
||||||
|
existingLibraryItem.changed('libraryFiles', true)
|
||||||
|
await existingLibraryItem.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: When metadata file is stored in /metadata/items/{libraryItemId}.[abs|json] we should load this
|
||||||
|
// TODO: store an additional array of metadata keys that the user has changed manually so we know what not to override
|
||||||
|
const bookMetadata = await this.getBookMetadataFromScanData(media.audioFiles, libraryItemData, libraryScan)
|
||||||
|
let authorsUpdated = false
|
||||||
|
const bookAuthorsRemoved = []
|
||||||
|
let seriesUpdated = false
|
||||||
|
const bookSeriesRemoved = []
|
||||||
|
|
||||||
|
for (const key in bookMetadata) {
|
||||||
|
// Ignore unset metadata and empty arrays
|
||||||
|
if (bookMetadata[key] === undefined || (Array.isArray(bookMetadata[key]) && !bookMetadata[key].length)) continue
|
||||||
|
|
||||||
|
if (key === 'authors') {
|
||||||
|
// Check for authors added
|
||||||
|
for (const authorName of bookMetadata.authors) {
|
||||||
|
if (!media.authors.some(au => au.name === authorName)) {
|
||||||
|
const existingAuthor = Database.libraryFilterData[libraryScan.libraryId].authors.find(au => au.name === authorName)
|
||||||
|
if (existingAuthor) {
|
||||||
|
await Database.bookAuthorModel.create({
|
||||||
|
bookId: media.id,
|
||||||
|
authorId: existingAuthor.id
|
||||||
|
})
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added author "${authorName}"`)
|
||||||
|
authorsUpdated = true
|
||||||
|
} else {
|
||||||
|
const newAuthor = await Database.authorModel.create({
|
||||||
|
name: authorName,
|
||||||
|
lastFirst: parseNameString.nameToLastFirst(authorName),
|
||||||
|
libraryId: libraryScan.libraryId
|
||||||
|
})
|
||||||
|
await media.addAuthor(newAuthor)
|
||||||
|
Database.addAuthorToFilterData(libraryScan.libraryId, newAuthor.name, newAuthor.id)
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added new author "${authorName}"`)
|
||||||
|
authorsUpdated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for authors removed
|
||||||
|
for (const author of media.authors) {
|
||||||
|
if (!bookMetadata.authors.includes(author.name)) {
|
||||||
|
await author.bookAuthor.destroy()
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" removed author "${author.name}"`)
|
||||||
|
authorsUpdated = true
|
||||||
|
bookAuthorsRemoved.push(author.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (key === 'series') {
|
||||||
|
// Check for series added
|
||||||
|
for (const seriesObj of bookMetadata.series) {
|
||||||
|
if (!media.series.some(se => se.name === seriesObj.name)) {
|
||||||
|
const existingSeries = Database.libraryFilterData[libraryScan.libraryId].series.find(se => se.name === seriesObj.name)
|
||||||
|
if (existingSeries) {
|
||||||
|
await Database.bookSeriesModel.create({
|
||||||
|
bookId: media.id,
|
||||||
|
seriesId: existingSeries.id,
|
||||||
|
sequence: seriesObj.sequence
|
||||||
|
})
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added series "${seriesObj.name}"${seriesObj.sequence ? ` with sequence "${seriesObj.sequence}"` : ''}`)
|
||||||
|
seriesUpdated = true
|
||||||
|
} else {
|
||||||
|
const newSeries = await Database.seriesModel.create({
|
||||||
|
name: seriesObj.name,
|
||||||
|
nameIgnorePrefix: getTitleIgnorePrefix(seriesObj.name),
|
||||||
|
libraryId: libraryScan.libraryId
|
||||||
|
})
|
||||||
|
await media.addSeries(newSeries)
|
||||||
|
Database.addSeriesToFilterData(libraryScan.libraryId, newSeries.name, newSeries.id)
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added new series "${seriesObj.name}"${seriesObj.sequence ? ` with sequence "${seriesObj.sequence}"` : ''}`)
|
||||||
|
seriesUpdated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check for series removed
|
||||||
|
for (const series of media.series) {
|
||||||
|
if (!bookMetadata.series.some(se => se.name === series.name)) {
|
||||||
|
await series.bookSeries.destroy()
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" removed series "${series.name}"`)
|
||||||
|
seriesUpdated = true
|
||||||
|
bookSeriesRemoved.push(series.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (key === 'genres') {
|
||||||
|
const existingGenres = media.genres || []
|
||||||
|
if (bookMetadata.genres.some(g => !existingGenres.includes(g)) || existingGenres.some(g => !bookMetadata.genres.includes(g))) {
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `Updating book genres "${existingGenres.join(',')}" => "${bookMetadata.genres.join(',')}" for book "${bookMetadata.title}"`)
|
||||||
|
media.genres = bookMetadata.genres
|
||||||
|
hasMediaChanges = true
|
||||||
|
}
|
||||||
|
} else if (key === 'tags') {
|
||||||
|
const existingTags = media.tags || []
|
||||||
|
if (bookMetadata.tags.some(t => !existingTags.includes(t)) || existingTags.some(t => !bookMetadata.tags.includes(t))) {
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `Updating book tags "${existingTags.join(',')}" => "${bookMetadata.tags.join(',')}" for book "${bookMetadata.title}"`)
|
||||||
|
media.tags = bookMetadata.tags
|
||||||
|
hasMediaChanges = true
|
||||||
|
}
|
||||||
|
} else if (key === 'narrators') {
|
||||||
|
const existingNarrators = media.narrators || []
|
||||||
|
if (bookMetadata.narrators.some(t => !existingNarrators.includes(t)) || existingNarrators.some(t => !bookMetadata.narrators.includes(t))) {
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `Updating book narrators "${existingNarrators.join(',')}" => "${bookMetadata.narrators.join(',')}" for book "${bookMetadata.title}"`)
|
||||||
|
media.narrators = bookMetadata.narrators
|
||||||
|
hasMediaChanges = true
|
||||||
|
}
|
||||||
|
} else if (key === 'chapters') {
|
||||||
|
if (!areEquivalent(media.chapters, bookMetadata.chapters)) {
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `Updating book chapters for book "${bookMetadata.title}"`)
|
||||||
|
media.chapters = bookMetadata.chapters
|
||||||
|
hasMediaChanges = true
|
||||||
|
}
|
||||||
|
} else if (key === 'coverPath') {
|
||||||
|
if (media.coverPath && media.coverPath !== bookMetadata.coverPath && !(await fsExtra.pathExists(media.coverPath))) {
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `Updating book cover "${media.coverPath}" => "${bookMetadata.coverPath}" for book "${bookMetadata.title}" - original cover path does not exist`)
|
||||||
|
media.coverPath = bookMetadata.coverPath
|
||||||
|
hasMediaChanges = true
|
||||||
|
} else if (!media.coverPath) {
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `Updating book cover "unset" => "${bookMetadata.coverPath}" for book "${bookMetadata.title}"`)
|
||||||
|
media.coverPath = bookMetadata.coverPath
|
||||||
|
hasMediaChanges = true
|
||||||
|
}
|
||||||
|
} else if (bookMetadata[key] !== media[key]) {
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `Updating book ${key} "${media[key]}" => "${bookMetadata[key]}" for book "${bookMetadata.title}"`)
|
||||||
|
media[key] = bookMetadata[key]
|
||||||
|
hasMediaChanges = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no cover then extract cover from audio file if available
|
||||||
|
if (!media.coverPath && media.audioFiles.length) {
|
||||||
|
const libraryItemDir = existingLibraryItem.isFile ? null : existingLibraryItem.path
|
||||||
|
const extractedCoverPath = await CoverManager.saveEmbeddedCoverArtNew(media.audioFiles, existingLibraryItem.id, libraryItemDir)
|
||||||
|
if (extractedCoverPath) {
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" extracted embedded cover art from audio file to path "${extractedCoverPath}"`)
|
||||||
|
media.coverPath = extractedCoverPath
|
||||||
|
hasMediaChanges = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save Book changes to db
|
||||||
|
if (hasMediaChanges) {
|
||||||
|
await media.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load authors/series again if updated (for sending back to client)
|
||||||
|
if (authorsUpdated) {
|
||||||
|
media.authors = await media.getAuthors({
|
||||||
|
joinTableAttributes: ['createdAt'],
|
||||||
|
order: [
|
||||||
|
Sequelize.literal(`bookAuthor.createdAt ASC`)
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (seriesUpdated) {
|
||||||
|
media.series = await media.getSeries({
|
||||||
|
joinTableAttributes: ['sequence', 'createdAt'],
|
||||||
|
order: [
|
||||||
|
Sequelize.literal(`bookSeries.createdAt ASC`)
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryScan.seriesRemovedFromBooks.push(...bookSeriesRemoved)
|
||||||
|
libraryScan.authorsRemovedFromBooks.push(...bookAuthorsRemoved)
|
||||||
|
|
||||||
|
existingLibraryItem.media = media
|
||||||
|
return existingLibraryItem
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||||
@ -49,7 +359,7 @@ class BookScanner {
|
|||||||
scannedAudioFiles = AudioFileScanner.runSmartTrackOrder(libraryItemData.relPath, scannedAudioFiles)
|
scannedAudioFiles = AudioFileScanner.runSmartTrackOrder(libraryItemData.relPath, scannedAudioFiles)
|
||||||
|
|
||||||
// Find ebook file (prefer epub)
|
// Find ebook file (prefer epub)
|
||||||
let ebookLibraryFile = libraryItemData.ebookLibraryFiles.find(lf => lf.metadata.ext.slice(1).toLowerCase() === 'epub') || libraryItemData.ebookLibraryFiles[0]
|
let ebookLibraryFile = libraryScan.library.settings.audiobooksOnly ? null : libraryItemData.ebookLibraryFiles.find(lf => lf.metadata.ext.slice(1).toLowerCase() === 'epub') || libraryItemData.ebookLibraryFiles[0]
|
||||||
|
|
||||||
// Do not add library items that have no valid audio files and no ebook file
|
// Do not add library items that have no valid audio files and no ebook file
|
||||||
if (!ebookLibraryFile && !scannedAudioFiles.length) {
|
if (!ebookLibraryFile && !scannedAudioFiles.length) {
|
||||||
@ -62,6 +372,8 @@ class BookScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const bookMetadata = await this.getBookMetadataFromScanData(scannedAudioFiles, libraryItemData, libraryScan)
|
const bookMetadata = await this.getBookMetadataFromScanData(scannedAudioFiles, libraryItemData, libraryScan)
|
||||||
|
bookMetadata.explicit = !!bookMetadata.explicit // Ensure boolean
|
||||||
|
bookMetadata.abridged = !!bookMetadata.abridged // Ensure boolean
|
||||||
|
|
||||||
let duration = 0
|
let duration = 0
|
||||||
scannedAudioFiles.forEach((af) => duration += (!isNaN(af.duration) ? Number(af.duration) : 0))
|
scannedAudioFiles.forEach((af) => duration += (!isNaN(af.duration) ? Number(af.duration) : 0))
|
||||||
@ -116,6 +428,15 @@ class BookScanner {
|
|||||||
|
|
||||||
const libraryItemObj = libraryItemData.libraryItemObject
|
const libraryItemObj = libraryItemData.libraryItemObject
|
||||||
libraryItemObj.id = uuidv4() // Generate library item id ahead of time to use for saving extracted cover image
|
libraryItemObj.id = uuidv4() // Generate library item id ahead of time to use for saving extracted cover image
|
||||||
|
libraryItemObj.isMissing = false
|
||||||
|
libraryItemObj.isInvalid = false
|
||||||
|
|
||||||
|
// Set isSupplementary flag on ebook library files
|
||||||
|
for (const libraryFile of libraryItemObj.libraryFiles) {
|
||||||
|
if (globals.SupportedEbookTypes.includes(libraryFile.metadata.ext.slice(1).toLowerCase())) {
|
||||||
|
libraryFile.isSupplementary = libraryFile.ino !== ebookLibraryFile?.ino
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If cover was not found in folder then check embedded covers in audio files
|
// If cover was not found in folder then check embedded covers in audio files
|
||||||
if (!bookObject.coverPath && scannedAudioFiles.length) {
|
if (!bookObject.coverPath && scannedAudioFiles.length) {
|
||||||
@ -166,37 +487,59 @@ class BookScanner {
|
|||||||
Database.addPublisherToFilterData(libraryScan.libraryId, libraryItem.book.publisher)
|
Database.addPublisherToFilterData(libraryScan.libraryId, libraryItem.book.publisher)
|
||||||
Database.addLanguageToFilterData(libraryScan.libraryId, libraryItem.book.language)
|
Database.addLanguageToFilterData(libraryScan.libraryId, libraryItem.book.language)
|
||||||
|
|
||||||
|
// Load for emitting to client
|
||||||
|
libraryItem.media = await libraryItem.getMedia({
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.authorModel,
|
||||||
|
through: {
|
||||||
|
attributes: ['id', 'createdAt']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.seriesModel,
|
||||||
|
through: {
|
||||||
|
attributes: ['id', 'sequence', 'createdAt']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
order: [
|
||||||
|
[Database.authorModel, Database.bookAuthorModel, 'createdAt', 'ASC'],
|
||||||
|
[Database.seriesModel, 'bookSeries', 'createdAt', 'ASC']
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
return libraryItem
|
return libraryItem
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {import('../objects/files/AudioFile')[]} scannedAudioFiles
|
* @param {import('../models/Book').AudioFileObject[]} audioFiles
|
||||||
* @param {import('./LibraryItemScanData')} libraryItemData
|
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||||
* @param {import('./LibraryScan')} libraryScan
|
* @param {import('./LibraryScan')} libraryScan
|
||||||
* @returns {Promise<BookMetadataObject>}
|
* @returns {Promise<BookMetadataObject>}
|
||||||
*/
|
*/
|
||||||
async getBookMetadataFromScanData(scannedAudioFiles, libraryItemData, libraryScan) {
|
async getBookMetadataFromScanData(audioFiles, libraryItemData, libraryScan) {
|
||||||
// First set book metadata from folder/file names
|
// First set book metadata from folder/file names
|
||||||
const bookMetadata = {
|
const bookMetadata = {
|
||||||
title: libraryItemData.mediaMetadata.title,
|
title: libraryItemData.mediaMetadata.title,
|
||||||
titleIgnorePrefix: getTitleIgnorePrefix(libraryItemData.mediaMetadata.title),
|
titleIgnorePrefix: getTitleIgnorePrefix(libraryItemData.mediaMetadata.title),
|
||||||
subtitle: libraryItemData.mediaMetadata.subtitle,
|
subtitle: libraryItemData.mediaMetadata.subtitle || undefined,
|
||||||
publishedYear: libraryItemData.mediaMetadata.publishedYear,
|
publishedYear: libraryItemData.mediaMetadata.publishedYear || undefined,
|
||||||
publisher: null,
|
publisher: undefined,
|
||||||
description: null,
|
description: undefined,
|
||||||
isbn: null,
|
isbn: undefined,
|
||||||
asin: null,
|
asin: undefined,
|
||||||
language: null,
|
language: undefined,
|
||||||
narrators: parseNameString.parse(libraryItemData.mediaMetadata.narrators)?.names || [],
|
narrators: parseNameString.parse(libraryItemData.mediaMetadata.narrators)?.names || [],
|
||||||
genres: [],
|
genres: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
authors: parseNameString.parse(libraryItemData.mediaMetadata.author)?.names || [],
|
authors: parseNameString.parse(libraryItemData.mediaMetadata.author)?.names || [],
|
||||||
series: [],
|
series: [],
|
||||||
chapters: [],
|
chapters: [],
|
||||||
explicit: false,
|
explicit: undefined,
|
||||||
abridged: false,
|
abridged: undefined,
|
||||||
coverPath: null
|
coverPath: undefined
|
||||||
}
|
}
|
||||||
if (libraryItemData.mediaMetadata.series) {
|
if (libraryItemData.mediaMetadata.series) {
|
||||||
bookMetadata.series.push({
|
bookMetadata.series.push({
|
||||||
@ -206,7 +549,7 @@ class BookScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fill in or override book metadata from audio file meta tags
|
// Fill in or override book metadata from audio file meta tags
|
||||||
if (scannedAudioFiles.length) {
|
if (audioFiles.length) {
|
||||||
const MetadataMapArray = [
|
const MetadataMapArray = [
|
||||||
{
|
{
|
||||||
tag: 'tagComposer',
|
tag: 'tagComposer',
|
||||||
@ -261,7 +604,7 @@ class BookScanner {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
const overrideExistingDetails = Database.serverSettings.scannerPreferAudioMetadata
|
const overrideExistingDetails = Database.serverSettings.scannerPreferAudioMetadata
|
||||||
const firstScannedFile = scannedAudioFiles[0]
|
const firstScannedFile = audioFiles[0]
|
||||||
const audioFileMetaTags = firstScannedFile.metaTags
|
const audioFileMetaTags = firstScannedFile.metaTags
|
||||||
MetadataMapArray.forEach((mapping) => {
|
MetadataMapArray.forEach((mapping) => {
|
||||||
let value = audioFileMetaTags[mapping.tag]
|
let value = audioFileMetaTags[mapping.tag]
|
||||||
@ -372,7 +715,7 @@ class BookScanner {
|
|||||||
|
|
||||||
// Set chapters from audio files if not already set
|
// Set chapters from audio files if not already set
|
||||||
if (!bookMetadata.chapters.length) {
|
if (!bookMetadata.chapters.length) {
|
||||||
bookMetadata.chapters = this.getChaptersFromAudioFiles(bookMetadata.title, scannedAudioFiles, libraryScan)
|
bookMetadata.chapters = this.getChaptersFromAudioFiles(bookMetadata.title, audioFiles, libraryScan)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set cover from library file if one is found otherwise check audiofile
|
// Set cover from library file if one is found otherwise check audiofile
|
||||||
|
@ -56,7 +56,7 @@ class LibraryItemScanData {
|
|||||||
mediaType: this.mediaType,
|
mediaType: this.mediaType,
|
||||||
isFile: this.isFile,
|
isFile: this.isFile,
|
||||||
mtime: this.mtimeMs,
|
mtime: this.mtimeMs,
|
||||||
ctime: this.ctime,
|
ctime: this.ctimeMs,
|
||||||
birthtime: this.birthtimeMs,
|
birthtime: this.birthtimeMs,
|
||||||
lastScan: Date.now(),
|
lastScan: Date.now(),
|
||||||
lastScanVersion: packageJson.version,
|
lastScanVersion: packageJson.version,
|
||||||
@ -74,7 +74,7 @@ class LibraryItemScanData {
|
|||||||
|
|
||||||
/** @type {boolean} */
|
/** @type {boolean} */
|
||||||
get hasAudioFileChanges() {
|
get hasAudioFileChanges() {
|
||||||
return this.audioLibraryFilesRemoved.length + this.audioLibraryFilesAdded.length + this.audioLibraryFilesModified
|
return (this.audioLibraryFilesRemoved.length + this.audioLibraryFilesAdded.length + this.audioLibraryFilesModified.length) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {LibraryItem.LibraryFileObject[]} */
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
||||||
@ -136,6 +136,7 @@ class LibraryItemScanData {
|
|||||||
*
|
*
|
||||||
* @param {LibraryItem} existingLibraryItem
|
* @param {LibraryItem} existingLibraryItem
|
||||||
* @param {import('./LibraryScan')} libraryScan
|
* @param {import('./LibraryScan')} libraryScan
|
||||||
|
* @returns {boolean} true if changes found
|
||||||
*/
|
*/
|
||||||
async checkLibraryItemData(existingLibraryItem, libraryScan) {
|
async checkLibraryItemData(existingLibraryItem, libraryScan) {
|
||||||
const keysToCompare = ['libraryFolderId', 'ino', 'path', 'relPath', 'isFile']
|
const keysToCompare = ['libraryFolderId', 'ino', 'path', 'relPath', 'isFile']
|
||||||
@ -154,18 +155,18 @@ class LibraryItemScanData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check mtime, ctime and birthtime
|
// Check mtime, ctime and birthtime
|
||||||
if (existingLibraryItem.mtime.valueOf() !== this.mtimeMs) {
|
if (existingLibraryItem.mtime?.valueOf() !== this.mtimeMs) {
|
||||||
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "mtime" changed from "${existingLibraryItem.mtime.valueOf()}" to "${this.mtimeMs}"`)
|
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "mtime" changed from "${existingLibraryItem.mtime?.valueOf()}" to "${this.mtimeMs}"`)
|
||||||
existingLibraryItem.mtime = this.mtimeMs
|
existingLibraryItem.mtime = this.mtimeMs
|
||||||
this.hasChanges = true
|
this.hasChanges = true
|
||||||
}
|
}
|
||||||
if (existingLibraryItem.birthtime.valueOf() !== this.birthtimeMs) {
|
if (existingLibraryItem.birthtime?.valueOf() !== this.birthtimeMs) {
|
||||||
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "birthtime" changed from "${existingLibraryItem.birthtime.valueOf()}" to "${this.birthtimeMs}"`)
|
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "birthtime" changed from "${existingLibraryItem.birthtime?.valueOf()}" to "${this.birthtimeMs}"`)
|
||||||
existingLibraryItem.birthtime = this.birthtimeMs
|
existingLibraryItem.birthtime = this.birthtimeMs
|
||||||
this.hasChanges = true
|
this.hasChanges = true
|
||||||
}
|
}
|
||||||
if (existingLibraryItem.ctime.valueOf() !== this.ctimeMs) {
|
if (existingLibraryItem.ctime?.valueOf() !== this.ctimeMs) {
|
||||||
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "ctime" changed from "${existingLibraryItem.ctime.valueOf()}" to "${this.ctimeMs}"`)
|
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "ctime" changed from "${existingLibraryItem.ctime?.valueOf()}" to "${this.ctimeMs}"`)
|
||||||
existingLibraryItem.ctime = this.ctimeMs
|
existingLibraryItem.ctime = this.ctimeMs
|
||||||
this.hasChanges = true
|
this.hasChanges = true
|
||||||
}
|
}
|
||||||
@ -221,14 +222,15 @@ class LibraryItemScanData {
|
|||||||
existingLibraryItem.lastScanVersion = packageJson.version
|
existingLibraryItem.lastScanVersion = packageJson.version
|
||||||
|
|
||||||
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" changed: [${existingLibraryItem.changed()?.join(',') || ''}]`)
|
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" changed: [${existingLibraryItem.changed()?.join(',') || ''}]`)
|
||||||
libraryScan.resultsUpdated++
|
|
||||||
|
|
||||||
if (this.hasLibraryFileChanges) {
|
if (this.hasLibraryFileChanges) {
|
||||||
existingLibraryItem.changed('libraryFiles', true)
|
existingLibraryItem.changed('libraryFiles', true)
|
||||||
}
|
}
|
||||||
await existingLibraryItem.save()
|
await existingLibraryItem.save()
|
||||||
|
return true
|
||||||
} else {
|
} else {
|
||||||
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" is up-to-date`)
|
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" is up-to-date`)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,10 +251,6 @@ class LibraryItemScanData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const key in existingLibraryFile.metadata) {
|
for (const key in existingLibraryFile.metadata) {
|
||||||
if (existingLibraryFile.metadata.relPath === 'metadata.json' || existingLibraryFile.metadata.relPath === 'metadata.abs') {
|
|
||||||
if (key === 'mtimeMs' || key === 'size') continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingLibraryFile.metadata[key] !== scannedLibraryFile.metadata[key]) {
|
if (existingLibraryFile.metadata[key] !== scannedLibraryFile.metadata[key]) {
|
||||||
if (key !== 'path' && key !== 'relPath') {
|
if (key !== 'path' && key !== 'relPath') {
|
||||||
libraryScan.addLog(LogLevel.DEBUG, `Library file "${existingLibraryFile.metadata.path}" for library item "${libraryItemPath}" key "${key}" changed from "${existingLibraryFile.metadata[key]}" to "${scannedLibraryFile.metadata[key]}"`)
|
libraryScan.addLog(LogLevel.DEBUG, `Library file "${existingLibraryFile.metadata.path}" for library item "${libraryItemPath}" key "${key}" changed from "${existingLibraryFile.metadata[key]}" to "${scannedLibraryFile.metadata[key]}"`)
|
||||||
|
@ -27,6 +27,11 @@ class LibraryScan {
|
|||||||
this.resultsAdded = 0
|
this.resultsAdded = 0
|
||||||
this.resultsUpdated = 0
|
this.resultsUpdated = 0
|
||||||
|
|
||||||
|
/** @type {string[]} */
|
||||||
|
this.authorsRemovedFromBooks = []
|
||||||
|
/** @type {string[]} */
|
||||||
|
this.seriesRemovedFromBooks = []
|
||||||
|
|
||||||
this.logs = []
|
this.logs = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
const sequelize = require('sequelize')
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const packageJson = require('../../package.json')
|
const packageJson = require('../../package.json')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
@ -7,14 +8,10 @@ const fs = require('../libs/fsExtra')
|
|||||||
const fileUtils = require('../utils/fileUtils')
|
const fileUtils = require('../utils/fileUtils')
|
||||||
const scanUtils = require('../utils/scandir')
|
const scanUtils = require('../utils/scandir')
|
||||||
const { ScanResult, LogLevel } = require('../utils/constants')
|
const { ScanResult, LogLevel } = require('../utils/constants')
|
||||||
const globals = require('../utils/globals')
|
|
||||||
const libraryFilters = require('../utils/queries/libraryFilters')
|
const libraryFilters = require('../utils/queries/libraryFilters')
|
||||||
const AudioFileScanner = require('./AudioFileScanner')
|
|
||||||
const ScanOptions = require('./ScanOptions')
|
const ScanOptions = require('./ScanOptions')
|
||||||
const LibraryScan = require('./LibraryScan')
|
const LibraryScan = require('./LibraryScan')
|
||||||
const LibraryItemScanData = require('./LibraryItemScanData')
|
const LibraryItemScanData = require('./LibraryItemScanData')
|
||||||
const AudioFile = require('../objects/files/AudioFile')
|
|
||||||
const Book = require('../models/Book')
|
|
||||||
const BookScanner = require('./BookScanner')
|
const BookScanner = require('./BookScanner')
|
||||||
|
|
||||||
class LibraryScanner {
|
class LibraryScanner {
|
||||||
@ -91,6 +88,7 @@ class LibraryScanner {
|
|||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {import('./LibraryScan')} libraryScan
|
* @param {import('./LibraryScan')} libraryScan
|
||||||
|
* @returns {boolean} true if scan canceled
|
||||||
*/
|
*/
|
||||||
async scanLibrary(libraryScan) {
|
async scanLibrary(libraryScan) {
|
||||||
// Make sure library filter data is set
|
// Make sure library filter data is set
|
||||||
@ -113,11 +111,13 @@ class LibraryScanner {
|
|||||||
const existingLibraryItems = await Database.libraryItemModel.findAll({
|
const existingLibraryItems = await Database.libraryItemModel.findAll({
|
||||||
where: {
|
where: {
|
||||||
libraryId: libraryScan.libraryId
|
libraryId: libraryScan.libraryId
|
||||||
},
|
}
|
||||||
attributes: ['id', 'mediaId', 'mediaType', 'path', 'relPath', 'ino', 'isMissing', 'isFile', 'mtime', 'ctime', 'birthtime', 'libraryFiles', 'libraryFolderId', 'size']
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||||
|
|
||||||
const libraryItemIdsMissing = []
|
const libraryItemIdsMissing = []
|
||||||
|
let oldLibraryItemsUpdated = []
|
||||||
for (const existingLibraryItem of existingLibraryItems) {
|
for (const existingLibraryItem of existingLibraryItems) {
|
||||||
// First try to find matching library item with exact file path
|
// First try to find matching library item with exact file path
|
||||||
let libraryItemData = libraryItemDataFound.find(lid => lid.path === existingLibraryItem.path)
|
let libraryItemData = libraryItemDataFound.find(lid => lid.path === existingLibraryItem.path)
|
||||||
@ -138,15 +138,81 @@ class LibraryScanner {
|
|||||||
libraryScan.resultsMissing++
|
libraryScan.resultsMissing++
|
||||||
if (!existingLibraryItem.isMissing) {
|
if (!existingLibraryItem.isMissing) {
|
||||||
libraryItemIdsMissing.push(existingLibraryItem.id)
|
libraryItemIdsMissing.push(existingLibraryItem.id)
|
||||||
|
|
||||||
|
// TODO: Temporary while using old model to socket emit
|
||||||
|
const oldLibraryItem = await Database.libraryItemModel.getOldById(existingLibraryItem.id)
|
||||||
|
oldLibraryItem.isMissing = true
|
||||||
|
oldLibraryItem.updatedAt = Date.now()
|
||||||
|
oldLibraryItemsUpdated.push(oldLibraryItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
libraryItemDataFound = libraryItemDataFound.filter(lidf => lidf !== libraryItemData)
|
libraryItemDataFound = libraryItemDataFound.filter(lidf => lidf !== libraryItemData)
|
||||||
await libraryItemData.checkLibraryItemData(existingLibraryItem, libraryScan)
|
if (await libraryItemData.checkLibraryItemData(existingLibraryItem, libraryScan)) {
|
||||||
if (libraryItemData.hasLibraryFileChanges || libraryItemData.hasPathChange) {
|
libraryScan.resultsUpdated++
|
||||||
await this.rescanLibraryItem(existingLibraryItem, libraryItemData, libraryScan)
|
if (libraryItemData.hasLibraryFileChanges || libraryItemData.hasPathChange) {
|
||||||
|
const libraryItem = await this.rescanLibraryItem(existingLibraryItem, libraryItemData, libraryScan)
|
||||||
|
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem)
|
||||||
|
await oldLibraryItem.saveMetadata() // Save metadata.json
|
||||||
|
oldLibraryItemsUpdated.push(oldLibraryItem)
|
||||||
|
} else {
|
||||||
|
// TODO: Temporary while using old model to socket emit
|
||||||
|
const oldLibraryItem = await Database.libraryItemModel.getOldById(existingLibraryItem.id)
|
||||||
|
oldLibraryItemsUpdated.push(oldLibraryItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit item updates in chunks of 10 to client
|
||||||
|
if (oldLibraryItemsUpdated.length === 10) {
|
||||||
|
// TODO: Should only emit to clients where library item is accessible
|
||||||
|
SocketAuthority.emitter('items_updated', oldLibraryItemsUpdated.map(li => li.toJSONExpanded()))
|
||||||
|
oldLibraryItemsUpdated = []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||||
|
}
|
||||||
|
// Emit item updates to client
|
||||||
|
if (oldLibraryItemsUpdated.length) {
|
||||||
|
// TODO: Should only emit to clients where library item is accessible
|
||||||
|
SocketAuthority.emitter('items_updated', oldLibraryItemsUpdated.map(li => li.toJSONExpanded()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check authors that were removed from a book and remove them if they no longer have any books
|
||||||
|
// keep authors without books that have a asin, description or imagePath
|
||||||
|
if (libraryScan.authorsRemovedFromBooks.length) {
|
||||||
|
const bookAuthorsToRemove = (await Database.authorModel.findAll({
|
||||||
|
where: [
|
||||||
|
{
|
||||||
|
id: libraryScan.authorsRemovedFromBooks,
|
||||||
|
asin: {
|
||||||
|
[sequelize.Op.or]: [null, ""]
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
[sequelize.Op.or]: [null, ""]
|
||||||
|
},
|
||||||
|
imagePath: {
|
||||||
|
[sequelize.Op.or]: [null, ""]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0)
|
||||||
|
],
|
||||||
|
attributes: ['id'],
|
||||||
|
raw: true
|
||||||
|
})).map(au => au.id)
|
||||||
|
if (bookAuthorsToRemove.length) {
|
||||||
|
await Database.authorModel.destroy({
|
||||||
|
where: {
|
||||||
|
id: bookAuthorsToRemove
|
||||||
|
}
|
||||||
|
})
|
||||||
|
bookAuthorsToRemove.forEach((authorId) => {
|
||||||
|
Database.removeAuthorFromFilterData(libraryScan.libraryId, authorId)
|
||||||
|
// TODO: Clients were expecting full author in payload but its unnecessary
|
||||||
|
SocketAuthority.emitter('author_removed', { id: authorId })
|
||||||
|
})
|
||||||
|
libraryScan.addLog(LogLevel.INFO, `Removed ${bookAuthorsToRemove.length} authors`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update missing library items
|
// Update missing library items
|
||||||
@ -163,17 +229,36 @@ class LibraryScanner {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||||
|
|
||||||
// Add new library items
|
// Add new library items
|
||||||
if (libraryItemDataFound.length) {
|
if (libraryItemDataFound.length) {
|
||||||
|
let newOldLibraryItems = []
|
||||||
for (const libraryItemData of libraryItemDataFound) {
|
for (const libraryItemData of libraryItemDataFound) {
|
||||||
const newLibraryItem = await this.scanNewLibraryItem(libraryItemData, libraryScan)
|
const newLibraryItem = await this.scanNewLibraryItem(libraryItemData, libraryScan)
|
||||||
if (newLibraryItem) {
|
if (newLibraryItem) {
|
||||||
|
const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(newLibraryItem)
|
||||||
|
await oldLibraryItem.saveMetadata() // Save metadata.json
|
||||||
|
newOldLibraryItems.push(oldLibraryItem)
|
||||||
|
|
||||||
libraryScan.resultsAdded++
|
libraryScan.resultsAdded++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit new items in chunks of 10 to client
|
||||||
|
if (newOldLibraryItems.length === 10) {
|
||||||
|
// TODO: Should only emit to clients where library item is accessible
|
||||||
|
SocketAuthority.emitter('items_added', newOldLibraryItems.map(li => li.toJSONExpanded()))
|
||||||
|
newOldLibraryItems = []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
||||||
|
}
|
||||||
|
// Emit new items to client
|
||||||
|
if (newOldLibraryItems.length) {
|
||||||
|
// TODO: Should only emit to clients where library item is accessible
|
||||||
|
SocketAuthority.emitter('items_added', newOldLibraryItems.map(li => li.toJSONExpanded()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Socket emitter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -253,140 +338,8 @@ class LibraryScanner {
|
|||||||
*/
|
*/
|
||||||
async rescanLibraryItem(existingLibraryItem, libraryItemData, libraryScan) {
|
async rescanLibraryItem(existingLibraryItem, libraryItemData, libraryScan) {
|
||||||
if (existingLibraryItem.mediaType === 'book') {
|
if (existingLibraryItem.mediaType === 'book') {
|
||||||
/** @type {Book} */
|
const libraryItem = await BookScanner.rescanExistingBookLibraryItem(existingLibraryItem, libraryItemData, libraryScan)
|
||||||
const media = await existingLibraryItem.getMedia({
|
return libraryItem
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: Database.authorModel,
|
|
||||||
through: {
|
|
||||||
attributes: ['createdAt']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
model: Database.seriesModel,
|
|
||||||
through: {
|
|
||||||
attributes: ['sequence', 'createdAt']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
let hasMediaChanges = libraryItemData.hasAudioFileChanges
|
|
||||||
if (libraryItemData.hasAudioFileChanges || libraryItemData.audioLibraryFiles.length !== media.audioFiles.length) {
|
|
||||||
// Filter out audio files that were removed
|
|
||||||
media.audioFiles = media.audioFiles.filter(af => libraryItemData.checkAudioFileRemoved(af))
|
|
||||||
|
|
||||||
// Update audio files that were modified
|
|
||||||
if (libraryItemData.audioLibraryFilesModified.length) {
|
|
||||||
let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, libraryItemData.audioLibraryFilesModified)
|
|
||||||
media.audioFiles = media.audioFiles.map((audioFileObj) => {
|
|
||||||
let matchedScannedAudioFile = scannedAudioFiles.find(saf => saf.metadata.path === audioFileObj.metadata.path)
|
|
||||||
if (!matchedScannedAudioFile) {
|
|
||||||
matchedScannedAudioFile = scannedAudioFiles.find(saf => saf.ino === audioFileObj.ino)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchedScannedAudioFile) {
|
|
||||||
scannedAudioFiles = scannedAudioFiles.filter(saf => saf !== matchedScannedAudioFile)
|
|
||||||
const audioFile = new AudioFile(audioFileObj)
|
|
||||||
audioFile.updateFromScan(matchedScannedAudioFile)
|
|
||||||
return audioFile.toJSON()
|
|
||||||
}
|
|
||||||
return audioFileObj
|
|
||||||
})
|
|
||||||
// Modified audio files that were not found on the book
|
|
||||||
if (scannedAudioFiles.length) {
|
|
||||||
media.audioFiles.push(...scannedAudioFiles)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new audio files scanned in
|
|
||||||
if (libraryItemData.audioLibraryFilesAdded.length) {
|
|
||||||
const scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, libraryItemData.audioLibraryFilesAdded)
|
|
||||||
media.audioFiles.push(...scannedAudioFiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add audio library files that are not already set on the book (safety check)
|
|
||||||
let audioLibraryFilesToAdd = []
|
|
||||||
for (const audioLibraryFile of libraryItemData.audioLibraryFiles) {
|
|
||||||
if (!media.audioFiles.some(af => af.ino === audioLibraryFile.ino)) {
|
|
||||||
libraryScan.addLog(LogLevel.DEBUG, `Existing audio library file "${audioLibraryFile.metadata.relPath}" was not set on book "${media.title}" so setting it now`)
|
|
||||||
audioLibraryFilesToAdd.push(audioLibraryFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (audioLibraryFilesToAdd.length) {
|
|
||||||
const scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, audioLibraryFilesToAdd)
|
|
||||||
media.audioFiles.push(...scannedAudioFiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
media.audioFiles = AudioFileScanner.runSmartTrackOrder(existingLibraryItem.relPath, media.audioFiles)
|
|
||||||
|
|
||||||
media.duration = 0
|
|
||||||
media.audioFiles.forEach((af) => {
|
|
||||||
if (!isNaN(af.duration)) {
|
|
||||||
media.duration += af.duration
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
media.changed('audioFiles', true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if cover was removed
|
|
||||||
if (media.coverPath && !libraryItemData.imageLibraryFiles.some(lf => lf.metadata.path === media.coverPath)) {
|
|
||||||
media.coverPath = null
|
|
||||||
hasMediaChanges = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if cover is not set and image files were found
|
|
||||||
if (!media.coverPath && libraryItemData.imageLibraryFiles.length) {
|
|
||||||
// Prefer using a cover image with the name "cover" otherwise use the first image
|
|
||||||
const coverMatch = libraryItemData.imageLibraryFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
|
||||||
media.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path
|
|
||||||
hasMediaChanges = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if ebook was removed
|
|
||||||
if (media.ebookFile && (libraryScan.library.settings.audiobooksOnly || libraryItemData.checkEbookFileRemoved(media.ebookFile))) {
|
|
||||||
media.ebookFile = null
|
|
||||||
hasMediaChanges = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if ebook is not set and ebooks were found
|
|
||||||
if (!media.ebookFile && !libraryScan.library.settings.audiobooksOnly && libraryItemData.ebookLibraryFiles.length) {
|
|
||||||
// Prefer to use an epub ebook then fallback to the first ebook found
|
|
||||||
let ebookLibraryFile = libraryItemData.ebookLibraryFiles.find(lf => lf.metadata.ext.slice(1).toLowerCase() === 'epub')
|
|
||||||
if (!ebookLibraryFile) ebookLibraryFile = libraryItemData.ebookLibraryFiles[0]
|
|
||||||
// Ebook file is the same as library file except for additional `ebookFormat`
|
|
||||||
ebookLibraryFile.ebookFormat = ebookLibraryFile.metadata.ext.slice(1).toLowerCase()
|
|
||||||
media.ebookFile = ebookLibraryFile
|
|
||||||
media.changed('ebookFile', true)
|
|
||||||
hasMediaChanges = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check/update the isSupplementary flag on libraryFiles for the LibraryItem
|
|
||||||
let libraryItemUpdated = false
|
|
||||||
for (const libraryFile of existingLibraryItem.libraryFiles) {
|
|
||||||
if (globals.SupportedEbookTypes.includes(libraryFile.metadata.ext.slice(1).toLowerCase())) {
|
|
||||||
if (media.ebookFile && libraryFile.ino === media.ebookFile.ino) {
|
|
||||||
if (libraryFile.isSupplementary !== false) {
|
|
||||||
libraryFile.isSupplementary = false
|
|
||||||
libraryItemUpdated = true
|
|
||||||
}
|
|
||||||
} else if (libraryFile.isSupplementary !== true) {
|
|
||||||
libraryFile.isSupplementary = true
|
|
||||||
libraryItemUpdated = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (libraryItemUpdated) {
|
|
||||||
existingLibraryItem.changed('libraryFiles', true)
|
|
||||||
await existingLibraryItem.save()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Update chapters & metadata
|
|
||||||
|
|
||||||
if (hasMediaChanges) {
|
|
||||||
await media.save()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// TODO: Scan updated podcast
|
// TODO: Scan updated podcast
|
||||||
}
|
}
|
||||||
|
@ -42,13 +42,8 @@ class MediaProbeData {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getEmbeddedCoverArt(videoStream) {
|
|
||||||
const ImageCodecs = ['mjpeg', 'jpeg', 'png']
|
|
||||||
return ImageCodecs.includes(videoStream.codec) ? videoStream.codec : null
|
|
||||||
}
|
|
||||||
|
|
||||||
setData(data) {
|
setData(data) {
|
||||||
this.embeddedCoverArt = data.video_stream ? this.getEmbeddedCoverArt(data.video_stream) : null
|
this.embeddedCoverArt = data.video_stream?.codec || null
|
||||||
this.format = data.format
|
this.format = data.format
|
||||||
this.duration = data.duration
|
this.duration = data.duration
|
||||||
this.size = data.size
|
this.size = data.size
|
||||||
|
@ -324,6 +324,10 @@ function parseAbMetadataText(text, mediaType) {
|
|||||||
|
|
||||||
mediaDetails.chapters.sort((a, b) => a.start - b.start)
|
mediaDetails.chapters.sort((a, b) => a.start - b.start)
|
||||||
|
|
||||||
|
if (mediaDetails.chapters.length) {
|
||||||
|
mediaDetails.chapters = cleanChaptersArray(mediaDetails.chapters, mediaDetails.metadata.title) || []
|
||||||
|
}
|
||||||
|
|
||||||
return mediaDetails
|
return mediaDetails
|
||||||
}
|
}
|
||||||
module.exports.parse = parseAbMetadataText
|
module.exports.parse = parseAbMetadataText
|
||||||
@ -425,9 +429,8 @@ function parseJsonMetadataText(text) {
|
|||||||
if (abmetadataData.tags?.length) {
|
if (abmetadataData.tags?.length) {
|
||||||
abmetadataData.tags = [...new Set(abmetadataData.tags.map(t => t?.trim()).filter(t => t))]
|
abmetadataData.tags = [...new Set(abmetadataData.tags.map(t => t?.trim()).filter(t => t))]
|
||||||
}
|
}
|
||||||
// TODO: Clean chapters
|
|
||||||
if (abmetadataData.chapters?.length) {
|
if (abmetadataData.chapters?.length) {
|
||||||
|
abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.metadata.title)
|
||||||
}
|
}
|
||||||
// clean remove dupes
|
// clean remove dupes
|
||||||
if (abmetadataData.metadata.authors?.length) {
|
if (abmetadataData.metadata.authors?.length) {
|
||||||
|
Loading…
Reference in New Issue
Block a user