mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-11-07 16:44:16 +01:00
482 lines
16 KiB
JavaScript
482 lines
16 KiB
JavaScript
const uuidv4 = require("uuid").v4
|
|
const fs = require('../libs/fsExtra')
|
|
const Path = require('path')
|
|
const { version } = require('../../package.json')
|
|
const Logger = require('../Logger')
|
|
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
|
|
const LibraryFile = require('./files/LibraryFile')
|
|
const Book = require('./mediaTypes/Book')
|
|
const Podcast = require('./mediaTypes/Podcast')
|
|
const Video = require('./mediaTypes/Video')
|
|
const Music = require('./mediaTypes/Music')
|
|
const { areEquivalent, copyValue, cleanStringForSearch } = require('../utils/index')
|
|
const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
|
|
|
|
class LibraryItem {
|
|
constructor(libraryItem = null) {
|
|
this.id = null
|
|
this.ino = null // Inode
|
|
this.oldLibraryItemId = null
|
|
|
|
this.libraryId = null
|
|
this.folderId = null
|
|
|
|
this.path = null
|
|
this.relPath = null
|
|
this.isFile = false
|
|
this.mtimeMs = null
|
|
this.ctimeMs = null
|
|
this.birthtimeMs = null
|
|
this.addedAt = null
|
|
this.updatedAt = null
|
|
this.lastScan = null
|
|
this.scanVersion = null
|
|
|
|
// 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
|
|
|
|
/** @type {LibraryFile[]} */
|
|
this.libraryFiles = []
|
|
|
|
if (libraryItem) {
|
|
this.construct(libraryItem)
|
|
}
|
|
|
|
// Temporary attributes
|
|
this.isSavingMetadata = false
|
|
}
|
|
|
|
construct(libraryItem) {
|
|
this.id = libraryItem.id
|
|
this.ino = libraryItem.ino || null
|
|
this.oldLibraryItemId = libraryItem.oldLibraryItemId
|
|
this.libraryId = libraryItem.libraryId
|
|
this.folderId = libraryItem.folderId
|
|
this.path = libraryItem.path
|
|
this.relPath = libraryItem.relPath
|
|
this.isFile = !!libraryItem.isFile
|
|
this.mtimeMs = libraryItem.mtimeMs || 0
|
|
this.ctimeMs = libraryItem.ctimeMs || 0
|
|
this.birthtimeMs = libraryItem.birthtimeMs || 0
|
|
this.addedAt = libraryItem.addedAt
|
|
this.updatedAt = libraryItem.updatedAt || this.addedAt
|
|
this.lastScan = libraryItem.lastScan || null
|
|
this.scanVersion = libraryItem.scanVersion || null
|
|
|
|
this.isMissing = !!libraryItem.isMissing
|
|
this.isInvalid = !!libraryItem.isInvalid
|
|
|
|
this.mediaType = libraryItem.mediaType
|
|
if (this.mediaType === 'book') {
|
|
this.media = new Book(libraryItem.media)
|
|
} else if (this.mediaType === 'podcast') {
|
|
this.media = new Podcast(libraryItem.media)
|
|
} else if (this.mediaType === 'video') {
|
|
this.media = new Video(libraryItem.media)
|
|
} else if (this.mediaType === 'music') {
|
|
this.media = new Music(libraryItem.media)
|
|
}
|
|
this.media.libraryItemId = this.id
|
|
|
|
this.libraryFiles = libraryItem.libraryFiles.map(f => new LibraryFile(f))
|
|
|
|
// Migration for v2.2.23 to set ebook library files as supplementary
|
|
if (this.isBook && this.media.ebookFile) {
|
|
for (const libraryFile of this.libraryFiles) {
|
|
if (libraryFile.isEBookFile && libraryFile.isSupplementary === null) {
|
|
libraryFile.isSupplementary = this.media.ebookFile.ino !== libraryFile.ino
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
toJSON() {
|
|
return {
|
|
id: this.id,
|
|
ino: this.ino,
|
|
oldLibraryItemId: this.oldLibraryItemId,
|
|
libraryId: this.libraryId,
|
|
folderId: this.folderId,
|
|
path: this.path,
|
|
relPath: this.relPath,
|
|
isFile: this.isFile,
|
|
mtimeMs: this.mtimeMs,
|
|
ctimeMs: this.ctimeMs,
|
|
birthtimeMs: this.birthtimeMs,
|
|
addedAt: this.addedAt,
|
|
updatedAt: this.updatedAt,
|
|
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())
|
|
}
|
|
}
|
|
|
|
toJSONMinified() {
|
|
return {
|
|
id: this.id,
|
|
ino: this.ino,
|
|
oldLibraryItemId: this.oldLibraryItemId,
|
|
libraryId: this.libraryId,
|
|
folderId: this.folderId,
|
|
path: this.path,
|
|
relPath: this.relPath,
|
|
isFile: this.isFile,
|
|
mtimeMs: this.mtimeMs,
|
|
ctimeMs: this.ctimeMs,
|
|
birthtimeMs: this.birthtimeMs,
|
|
addedAt: this.addedAt,
|
|
updatedAt: this.updatedAt,
|
|
isMissing: !!this.isMissing,
|
|
isInvalid: !!this.isInvalid,
|
|
mediaType: this.mediaType,
|
|
media: this.media.toJSONMinified(),
|
|
numFiles: this.libraryFiles.length,
|
|
size: this.size
|
|
}
|
|
}
|
|
|
|
// Adds additional helpful fields like media duration, tracks, etc.
|
|
toJSONExpanded() {
|
|
return {
|
|
id: this.id,
|
|
ino: this.ino,
|
|
oldLibraryItemId: this.oldLibraryItemId,
|
|
libraryId: this.libraryId,
|
|
folderId: this.folderId,
|
|
path: this.path,
|
|
relPath: this.relPath,
|
|
isFile: this.isFile,
|
|
mtimeMs: this.mtimeMs,
|
|
ctimeMs: this.ctimeMs,
|
|
birthtimeMs: this.birthtimeMs,
|
|
addedAt: this.addedAt,
|
|
updatedAt: this.updatedAt,
|
|
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()),
|
|
size: this.size
|
|
}
|
|
}
|
|
|
|
get isPodcast() { return this.mediaType === 'podcast' }
|
|
get isBook() { return this.mediaType === 'book' }
|
|
get isMusic() { return this.mediaType === 'music' }
|
|
get size() {
|
|
let total = 0
|
|
this.libraryFiles.forEach((lf) => total += lf.metadata.size)
|
|
return total
|
|
}
|
|
get audioFileTotalSize() {
|
|
let total = 0
|
|
this.libraryFiles.filter(lf => lf.fileType == 'audio').forEach((lf) => total += lf.metadata.size)
|
|
return total
|
|
}
|
|
get hasAudioFiles() {
|
|
return this.libraryFiles.some(lf => lf.fileType === 'audio')
|
|
}
|
|
get hasMediaEntities() {
|
|
return this.media.hasMediaEntities
|
|
}
|
|
get hasIssues() {
|
|
if (this.isMissing || this.isInvalid) return true
|
|
return this.media.hasIssues
|
|
}
|
|
|
|
// Data comes from scandir library item data
|
|
setData(libraryMediaType, payload) {
|
|
this.id = uuidv4()
|
|
this.mediaType = libraryMediaType
|
|
if (libraryMediaType === 'video') {
|
|
this.media = new Video()
|
|
} else if (libraryMediaType === 'podcast') {
|
|
this.media = new Podcast()
|
|
} else if (libraryMediaType === 'book') {
|
|
this.media = new Book()
|
|
} else if (libraryMediaType === 'music') {
|
|
this.media = new Music()
|
|
}
|
|
this.media.id = uuidv4()
|
|
this.media.libraryItemId = this.id
|
|
|
|
for (const key in payload) {
|
|
if (key === 'libraryFiles') {
|
|
this.libraryFiles = payload.libraryFiles.map(lf => lf.clone())
|
|
|
|
// Set cover image
|
|
const imageFiles = this.libraryFiles.filter(lf => lf.fileType === 'image')
|
|
const coverMatch = imageFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
|
if (coverMatch) {
|
|
this.media.coverPath = coverMatch.metadata.path
|
|
} else if (imageFiles.length) {
|
|
this.media.coverPath = imageFiles[0].metadata.path
|
|
}
|
|
|
|
} else if (this[key] !== undefined && key !== 'media') {
|
|
this[key] = payload[key]
|
|
}
|
|
}
|
|
|
|
if (payload.media) {
|
|
this.media.setData(payload.media)
|
|
}
|
|
|
|
this.addedAt = Date.now()
|
|
this.updatedAt = Date.now()
|
|
}
|
|
|
|
update(payload) {
|
|
const json = this.toJSON()
|
|
let hasUpdates = false
|
|
for (const key in json) {
|
|
if (payload[key] !== undefined) {
|
|
if (key === 'media') {
|
|
if (this.media.update(payload[key])) {
|
|
hasUpdates = true
|
|
}
|
|
} else if (!areEquivalent(payload[key], json[key])) {
|
|
this[key] = copyValue(payload[key])
|
|
hasUpdates = true
|
|
}
|
|
}
|
|
}
|
|
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.updatedAt = Date.now()
|
|
this.scanVersion = version
|
|
}
|
|
|
|
// Returns null if file not found, true if file was updated, false if up to date
|
|
// updates existing LibraryFile, AudioFile, EBookFile's
|
|
checkFileFound(fileFound) {
|
|
let hasUpdated = false
|
|
|
|
let existingFile = this.libraryFiles.find(lf => lf.ino === fileFound.ino)
|
|
let 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
|
|
}
|
|
|
|
// FileMetadata keys
|
|
['filename', 'ext', 'mtimeMs', 'ctimeMs', 'birthtimeMs', 'size'].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
|
|
}
|
|
|
|
searchQuery(query) {
|
|
query = cleanStringForSearch(query)
|
|
return this.media.searchQuery(query)
|
|
}
|
|
|
|
getDirectPlayTracklist(episodeId) {
|
|
return this.media.getDirectPlayTracklist(episodeId)
|
|
}
|
|
|
|
/**
|
|
* Save metadata.json/metadata.abs file
|
|
* @returns {Promise<LibraryFile>} null if not saved
|
|
*/
|
|
async saveMetadata() {
|
|
if (this.isSavingMetadata) return null
|
|
|
|
this.isSavingMetadata = true
|
|
|
|
let metadataPath = Path.join(global.MetadataPath, 'items', this.id)
|
|
let storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem
|
|
if (storeMetadataWithItem && !this.isFile) {
|
|
metadataPath = this.path
|
|
} else {
|
|
// Make sure metadata book dir exists
|
|
storeMetadataWithItem = false
|
|
await fs.ensureDir(metadataPath)
|
|
}
|
|
|
|
const metadataFileFormat = global.ServerSettings.metadataFileFormat
|
|
const metadataFilePath = Path.join(metadataPath, `metadata.${metadataFileFormat}`)
|
|
if (metadataFileFormat === 'json') {
|
|
// Remove metadata.abs if it exists
|
|
if (await fs.pathExists(Path.join(metadataPath, `metadata.abs`))) {
|
|
Logger.debug(`[LibraryItem] Removing metadata.abs for item "${this.media.metadata.title}"`)
|
|
await fs.remove(Path.join(metadataPath, `metadata.abs`))
|
|
this.libraryFiles = this.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.abs`)))
|
|
}
|
|
|
|
return fs.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)).then(async () => {
|
|
// Add metadata.json to libraryFiles array if it is new
|
|
let metadataLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))
|
|
if (storeMetadataWithItem) {
|
|
if (!metadataLibraryFile) {
|
|
metadataLibraryFile = new LibraryFile()
|
|
await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
|
|
this.libraryFiles.push(metadataLibraryFile)
|
|
} else {
|
|
const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
|
|
if (fileTimestamps) {
|
|
metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
|
|
metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
|
|
metadataLibraryFile.metadata.size = fileTimestamps.size
|
|
metadataLibraryFile.ino = fileTimestamps.ino
|
|
}
|
|
}
|
|
const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path)
|
|
if (libraryItemDirTimestamps) {
|
|
this.mtimeMs = libraryItemDirTimestamps.mtimeMs
|
|
this.ctimeMs = libraryItemDirTimestamps.ctimeMs
|
|
}
|
|
}
|
|
|
|
Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`)
|
|
|
|
return metadataLibraryFile
|
|
}).catch((error) => {
|
|
Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error)
|
|
return null
|
|
}).finally(() => {
|
|
this.isSavingMetadata = false
|
|
})
|
|
} else {
|
|
// Remove metadata.json if it exists
|
|
if (await fs.pathExists(Path.join(metadataPath, `metadata.json`))) {
|
|
Logger.debug(`[LibraryItem] Removing metadata.json for item "${this.media.metadata.title}"`)
|
|
await fs.remove(Path.join(metadataPath, `metadata.json`))
|
|
this.libraryFiles = this.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.json`)))
|
|
}
|
|
|
|
return abmetadataGenerator.generate(this, metadataFilePath).then(async (success) => {
|
|
if (!success) {
|
|
Logger.error(`[LibraryItem] Failed saving abmetadata to "${metadataFilePath}"`)
|
|
return null
|
|
}
|
|
// Add metadata.abs to libraryFiles array if it is new
|
|
let metadataLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath))
|
|
if (storeMetadataWithItem) {
|
|
if (!metadataLibraryFile) {
|
|
metadataLibraryFile = new LibraryFile()
|
|
await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.abs`)
|
|
this.libraryFiles.push(metadataLibraryFile)
|
|
} else {
|
|
const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
|
|
if (fileTimestamps) {
|
|
metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
|
|
metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
|
|
metadataLibraryFile.metadata.size = fileTimestamps.size
|
|
metadataLibraryFile.ino = fileTimestamps.ino
|
|
}
|
|
}
|
|
const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path)
|
|
if (libraryItemDirTimestamps) {
|
|
this.mtimeMs = libraryItemDirTimestamps.mtimeMs
|
|
this.ctimeMs = libraryItemDirTimestamps.ctimeMs
|
|
}
|
|
}
|
|
|
|
Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`)
|
|
return metadataLibraryFile
|
|
}).finally(() => {
|
|
this.isSavingMetadata = false
|
|
})
|
|
}
|
|
}
|
|
|
|
removeLibraryFile(ino) {
|
|
if (!ino) return false
|
|
const libraryFile = this.libraryFiles.find(lf => lf.ino === ino)
|
|
if (libraryFile) {
|
|
this.libraryFiles = this.libraryFiles.filter(lf => lf.ino !== ino)
|
|
this.updatedAt = Date.now()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Set the EBookFile from a LibraryFile
|
|
* If null then ebookFile will be removed from the book
|
|
* all ebook library files that are not primary are marked as supplementary
|
|
*
|
|
* @param {LibraryFile} [libraryFile]
|
|
*/
|
|
setPrimaryEbook(ebookLibraryFile = null) {
|
|
const ebookLibraryFiles = this.libraryFiles.filter(lf => lf.isEBookFile)
|
|
for (const libraryFile of ebookLibraryFiles) {
|
|
libraryFile.isSupplementary = ebookLibraryFile?.ino !== libraryFile.ino
|
|
}
|
|
this.media.setEbookFile(ebookLibraryFile)
|
|
}
|
|
}
|
|
module.exports = LibraryItem |