mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-24 19:11:44 +01:00
377 lines
14 KiB
JavaScript
377 lines
14 KiB
JavaScript
const packageJson = require('../../package.json')
|
|
const { LogLevel } = require('../utils/constants')
|
|
const LibraryItem = require('../models/LibraryItem')
|
|
const globals = require('../utils/globals')
|
|
|
|
class LibraryItemScanData {
|
|
/**
|
|
* @typedef LibraryFileModifiedObject
|
|
* @property {LibraryItem.LibraryFileObject} old
|
|
* @property {LibraryItem.LibraryFileObject} new
|
|
*/
|
|
|
|
constructor(data) {
|
|
/** @type {string} */
|
|
this.libraryFolderId = data.libraryFolderId
|
|
/** @type {string} */
|
|
this.libraryId = data.libraryId
|
|
/** @type {string} */
|
|
this.mediaType = data.mediaType
|
|
/** @type {string} */
|
|
this.ino = data.ino
|
|
/** @type {number} */
|
|
this.mtimeMs = data.mtimeMs
|
|
/** @type {number} */
|
|
this.ctimeMs = data.ctimeMs
|
|
/** @type {number} */
|
|
this.birthtimeMs = data.birthtimeMs
|
|
/** @type {string} */
|
|
this.path = data.path
|
|
/** @type {string} */
|
|
this.relPath = data.relPath
|
|
/** @type {boolean} */
|
|
this.isFile = data.isFile
|
|
/** @type {import('../utils/scandir').LibraryItemFilenameMetadata} */
|
|
this.mediaMetadata = data.mediaMetadata
|
|
/** @type {import('../objects/files/LibraryFile')[]} */
|
|
this.libraryFiles = data.libraryFiles
|
|
|
|
// Set after check
|
|
/** @type {boolean} */
|
|
this.hasChanges
|
|
/** @type {boolean} */
|
|
this.hasPathChange
|
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
|
this.libraryFilesRemoved = []
|
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
|
this.libraryFilesAdded = []
|
|
/** @type {LibraryFileModifiedObject[]} */
|
|
this.libraryFilesModified = []
|
|
}
|
|
|
|
/**
|
|
* Used to create a library item
|
|
*/
|
|
get libraryItemObject() {
|
|
let size = 0
|
|
this.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
|
|
return {
|
|
ino: this.ino,
|
|
path: this.path,
|
|
relPath: this.relPath,
|
|
mediaType: this.mediaType,
|
|
isFile: this.isFile,
|
|
mtime: this.mtimeMs,
|
|
ctime: this.ctimeMs,
|
|
birthtime: this.birthtimeMs,
|
|
lastScan: Date.now(),
|
|
lastScanVersion: packageJson.version,
|
|
libraryFiles: this.libraryFiles,
|
|
libraryId: this.libraryId,
|
|
libraryFolderId: this.libraryFolderId,
|
|
size
|
|
}
|
|
}
|
|
|
|
/** @type {boolean} */
|
|
get hasLibraryFileChanges() {
|
|
return this.libraryFilesRemoved.length + this.libraryFilesModified.length + this.libraryFilesAdded.length
|
|
}
|
|
|
|
/** @type {boolean} */
|
|
get hasAudioFileChanges() {
|
|
return (this.audioLibraryFilesRemoved.length + this.audioLibraryFilesAdded.length + this.audioLibraryFilesModified.length) > 0
|
|
}
|
|
|
|
/** @type {LibraryFileModifiedObject[]} */
|
|
get audioLibraryFilesModified() {
|
|
return this.libraryFilesModified.filter(lf => globals.SupportedAudioTypes.includes(lf.old.metadata.ext?.slice(1).toLowerCase() || ''))
|
|
}
|
|
|
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
|
get audioLibraryFilesRemoved() {
|
|
return this.libraryFilesRemoved.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
|
}
|
|
|
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
|
get audioLibraryFilesAdded() {
|
|
return this.libraryFilesAdded.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
|
}
|
|
|
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
|
get audioLibraryFiles() {
|
|
return this.libraryFiles.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
|
}
|
|
|
|
/** @type {LibraryFileModifiedObject[]} */
|
|
get imageLibraryFilesModified() {
|
|
return this.libraryFilesModified.filter(lf => globals.SupportedImageTypes.includes(lf.old.metadata.ext?.slice(1).toLowerCase() || ''))
|
|
}
|
|
|
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
|
get imageLibraryFilesRemoved() {
|
|
return this.libraryFilesRemoved.filter(lf => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
|
}
|
|
|
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
|
get imageLibraryFilesAdded() {
|
|
return this.libraryFilesAdded.filter(lf => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
|
}
|
|
|
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
|
get imageLibraryFiles() {
|
|
return this.libraryFiles.filter(lf => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
|
}
|
|
|
|
/** @type {LibraryFileModifiedObject[]} */
|
|
get ebookLibraryFilesModified() {
|
|
return this.libraryFilesModified.filter(lf => globals.SupportedEbookTypes.includes(lf.old.metadata.ext?.slice(1).toLowerCase() || ''))
|
|
}
|
|
|
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
|
get ebookLibraryFilesRemoved() {
|
|
return this.libraryFilesRemoved.filter(lf => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
|
}
|
|
|
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
|
get ebookLibraryFilesAdded() {
|
|
return this.libraryFilesAdded.filter(lf => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
|
}
|
|
|
|
/** @type {LibraryItem.LibraryFileObject[]} */
|
|
get ebookLibraryFiles() {
|
|
return this.libraryFiles.filter(lf => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
|
}
|
|
|
|
/** @type {LibraryItem.LibraryFileObject} */
|
|
get descTxtLibraryFile() {
|
|
return this.libraryFiles.find(lf => lf.metadata.filename === 'desc.txt')
|
|
}
|
|
|
|
/** @type {LibraryItem.LibraryFileObject} */
|
|
get readerTxtLibraryFile() {
|
|
return this.libraryFiles.find(lf => lf.metadata.filename === 'reader.txt')
|
|
}
|
|
|
|
/** @type {LibraryItem.LibraryFileObject} */
|
|
get metadataAbsLibraryFile() {
|
|
return this.libraryFiles.find(lf => lf.metadata.filename === 'metadata.abs')
|
|
}
|
|
|
|
/** @type {LibraryItem.LibraryFileObject} */
|
|
get metadataJsonLibraryFile() {
|
|
return this.libraryFiles.find(lf => lf.metadata.filename === 'metadata.json')
|
|
}
|
|
|
|
/** @type {LibraryItem.LibraryFileObject} */
|
|
get metadataOpfLibraryFile() {
|
|
return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.opf')
|
|
}
|
|
|
|
/** @type {LibraryItem.LibraryFileObject} */
|
|
get metadataNfoLibraryFile() {
|
|
return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.nfo')
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {LibraryItem} existingLibraryItem
|
|
* @param {import('./LibraryScan')} libraryScan
|
|
* @returns {boolean} true if changes found
|
|
*/
|
|
async checkLibraryItemData(existingLibraryItem, libraryScan) {
|
|
const keysToCompare = ['libraryFolderId', 'ino', 'path', 'relPath', 'isFile']
|
|
this.hasChanges = false
|
|
this.hasPathChange = false
|
|
for (const key of keysToCompare) {
|
|
if (existingLibraryItem[key] !== this[key]) {
|
|
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "${key}" changed from "${existingLibraryItem[key]}" to "${this[key]}"`)
|
|
existingLibraryItem[key] = this[key]
|
|
this.hasChanges = true
|
|
|
|
if (key === 'relPath' || key === 'path') {
|
|
this.hasPathChange = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check mtime, ctime and birthtime
|
|
if (existingLibraryItem.mtime?.valueOf() !== this.mtimeMs) {
|
|
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "mtime" changed from "${existingLibraryItem.mtime?.valueOf()}" to "${this.mtimeMs}"`)
|
|
existingLibraryItem.mtime = this.mtimeMs
|
|
this.hasChanges = true
|
|
}
|
|
if (existingLibraryItem.birthtime?.valueOf() !== this.birthtimeMs) {
|
|
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "birthtime" changed from "${existingLibraryItem.birthtime?.valueOf()}" to "${this.birthtimeMs}"`)
|
|
existingLibraryItem.birthtime = this.birthtimeMs
|
|
this.hasChanges = true
|
|
}
|
|
if (existingLibraryItem.ctime?.valueOf() !== this.ctimeMs) {
|
|
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "ctime" changed from "${existingLibraryItem.ctime?.valueOf()}" to "${this.ctimeMs}"`)
|
|
existingLibraryItem.ctime = this.ctimeMs
|
|
this.hasChanges = true
|
|
}
|
|
if (existingLibraryItem.isMissing) {
|
|
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" was missing but now found`)
|
|
existingLibraryItem.isMissing = false
|
|
this.hasChanges = true
|
|
}
|
|
|
|
this.libraryFilesRemoved = []
|
|
this.libraryFilesModified = []
|
|
let libraryFilesAdded = this.libraryFiles.map(lf => lf)
|
|
|
|
for (const existingLibraryFile of existingLibraryItem.libraryFiles) {
|
|
// Find matching library file using path first and fallback to using inode value
|
|
let matchingLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === existingLibraryFile.metadata.path)
|
|
if (!matchingLibraryFile) {
|
|
matchingLibraryFile = this.libraryFiles.find(lf => lf.ino === existingLibraryFile.ino)
|
|
if (matchingLibraryFile) {
|
|
libraryScan.addLog(LogLevel.INFO, `Library file with path "${existingLibraryFile.metadata.path}" not found, but found file with matching inode value "${existingLibraryFile.ino}" at path "${matchingLibraryFile.metadata.path}"`)
|
|
}
|
|
}
|
|
|
|
if (!matchingLibraryFile) { // Library file removed
|
|
libraryScan.addLog(LogLevel.INFO, `Library file "${existingLibraryFile.metadata.path}" was removed from library item "${existingLibraryItem.relPath}"`)
|
|
this.libraryFilesRemoved.push(existingLibraryFile)
|
|
existingLibraryItem.libraryFiles = existingLibraryItem.libraryFiles.filter(lf => lf !== existingLibraryFile)
|
|
this.hasChanges = true
|
|
} else {
|
|
libraryFilesAdded = libraryFilesAdded.filter(lf => lf !== matchingLibraryFile)
|
|
let existingLibraryFileBefore = structuredClone(existingLibraryFile)
|
|
if (this.compareUpdateLibraryFile(existingLibraryItem.path, existingLibraryFile, matchingLibraryFile, libraryScan)) {
|
|
this.libraryFilesModified.push({old: existingLibraryFileBefore, new: existingLibraryFile})
|
|
this.hasChanges = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Log new library files found
|
|
if (libraryFilesAdded.length) {
|
|
this.hasChanges = true
|
|
for (const libraryFile of libraryFilesAdded) {
|
|
libraryScan.addLog(LogLevel.INFO, `New library file found with path "${libraryFile.metadata.path}" for library item "${existingLibraryItem.relPath}"`)
|
|
if (libraryFile.isEBookFile) {
|
|
// Set all new ebook files as supplementary
|
|
libraryFile.isSupplementary = true
|
|
}
|
|
existingLibraryItem.libraryFiles.push(libraryFile.toJSON())
|
|
}
|
|
}
|
|
|
|
this.libraryFilesAdded = libraryFilesAdded
|
|
|
|
if (this.hasChanges) {
|
|
existingLibraryItem.size = 0
|
|
existingLibraryItem.libraryFiles.forEach((lf) => existingLibraryItem.size += lf.metadata.size)
|
|
|
|
existingLibraryItem.lastScan = Date.now()
|
|
existingLibraryItem.lastScanVersion = packageJson.version
|
|
|
|
libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" changed: [${existingLibraryItem.changed()?.join(',') || ''}]`)
|
|
|
|
if (this.hasLibraryFileChanges) {
|
|
existingLibraryItem.changed('libraryFiles', true)
|
|
}
|
|
await existingLibraryItem.save()
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Update existing library file with scanned in library file data
|
|
* @param {string} libraryItemPath
|
|
* @param {LibraryItem.LibraryFileObject} existingLibraryFile
|
|
* @param {import('../objects/files/LibraryFile')} scannedLibraryFile
|
|
* @param {import('./LibraryScan')} libraryScan
|
|
* @returns {boolean} false if no changes
|
|
*/
|
|
compareUpdateLibraryFile(libraryItemPath, existingLibraryFile, scannedLibraryFile, libraryScan) {
|
|
let hasChanges = false
|
|
|
|
if (existingLibraryFile.ino !== scannedLibraryFile.ino) {
|
|
existingLibraryFile.ino = scannedLibraryFile.ino
|
|
hasChanges = true
|
|
}
|
|
|
|
for (const key in existingLibraryFile.metadata) {
|
|
if (existingLibraryFile.metadata[key] !== scannedLibraryFile.metadata[key]) {
|
|
if (key !== 'path' && key !== 'relPath') {
|
|
libraryScan.addLog(LogLevel.DEBUG, `Library file "${existingLibraryFile.metadata.relPath}" for library item "${libraryItemPath}" key "${key}" changed from "${existingLibraryFile.metadata[key]}" to "${scannedLibraryFile.metadata[key]}"`)
|
|
} else {
|
|
libraryScan.addLog(LogLevel.DEBUG, `Library file for library item "${libraryItemPath}" key "${key}" changed from "${existingLibraryFile.metadata[key]}" to "${scannedLibraryFile.metadata[key]}"`)
|
|
}
|
|
existingLibraryFile.metadata[key] = scannedLibraryFile.metadata[key]
|
|
hasChanges = true
|
|
}
|
|
}
|
|
|
|
if (hasChanges) {
|
|
existingLibraryFile.updatedAt = Date.now()
|
|
}
|
|
|
|
return hasChanges
|
|
}
|
|
|
|
/**
|
|
* Check if existing audio file on Book was removed
|
|
* @param {import('../models/Book').AudioFileObject} existingAudioFile
|
|
* @returns {boolean} true if audio file was removed
|
|
*/
|
|
checkAudioFileRemoved(existingAudioFile) {
|
|
if (!this.audioLibraryFilesRemoved.length) return false
|
|
// First check exact path
|
|
if (this.audioLibraryFilesRemoved.some(af => af.metadata.path === existingAudioFile.metadata.path)) {
|
|
return true
|
|
}
|
|
// Fallback to check inode value
|
|
return this.audioLibraryFilesRemoved.some(af => af.ino === existingAudioFile.ino)
|
|
}
|
|
|
|
/**
|
|
* Check if existing ebook file on Book was removed
|
|
* @param {import('../models/Book').EBookFileObject} ebookFile
|
|
* @returns {boolean} true if ebook file was removed
|
|
*/
|
|
checkEbookFileRemoved(ebookFile) {
|
|
if (!this.ebookLibraryFiles.length) return true
|
|
|
|
if (this.ebookLibraryFiles.some(lf => lf.metadata.path === ebookFile.metadata.path)) {
|
|
return false
|
|
}
|
|
|
|
return !this.ebookLibraryFiles.some(lf => lf.ino === ebookFile.ino)
|
|
}
|
|
|
|
/**
|
|
* Set data parsed from filenames
|
|
*
|
|
* @param {Object} bookMetadata
|
|
*/
|
|
setBookMetadataFromFilenames(bookMetadata) {
|
|
const keysToMap = ['title', 'subtitle', 'publishedYear', 'asin']
|
|
for (const key in this.mediaMetadata) {
|
|
if (keysToMap.includes(key) && this.mediaMetadata[key]) {
|
|
bookMetadata[key] = this.mediaMetadata[key]
|
|
}
|
|
}
|
|
|
|
if (this.mediaMetadata.authors?.length) {
|
|
bookMetadata.authors = this.mediaMetadata.authors
|
|
}
|
|
if (this.mediaMetadata.narrators?.length) {
|
|
bookMetadata.narrators = this.mediaMetadata.narrators
|
|
}
|
|
if (this.mediaMetadata.seriesName) {
|
|
bookMetadata.series = [
|
|
{
|
|
name: this.mediaMetadata.seriesName,
|
|
sequence: this.mediaMetadata.seriesSequence || null
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
module.exports = LibraryItemScanData |