2024-09-04 00:04:58 +02:00
|
|
|
const uuidv4 = require('uuid').v4
|
2022-07-06 02:53:01 +02:00
|
|
|
const fs = require('../libs/fsExtra')
|
2022-04-12 23:05:16 +02:00
|
|
|
const Path = require('path')
|
2022-03-09 02:31:44 +01:00
|
|
|
const Logger = require('../Logger')
|
|
|
|
const LibraryFile = require('./files/LibraryFile')
|
2022-03-17 01:15:25 +01:00
|
|
|
const Book = require('./mediaTypes/Book')
|
|
|
|
const Podcast = require('./mediaTypes/Podcast')
|
2023-10-20 00:20:12 +02:00
|
|
|
const { areEquivalent, copyValue } = require('../utils/index')
|
2023-09-03 22:14:58 +02:00
|
|
|
const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
|
2022-03-09 02:31:44 +01:00
|
|
|
|
|
|
|
class LibraryItem {
|
|
|
|
constructor(libraryItem = null) {
|
|
|
|
this.id = null
|
|
|
|
this.ino = null // Inode
|
2023-07-16 22:05:51 +02:00
|
|
|
this.oldLibraryItemId = null
|
2022-03-09 02:31:44 +01:00
|
|
|
|
|
|
|
this.libraryId = null
|
|
|
|
this.folderId = null
|
|
|
|
|
|
|
|
this.path = null
|
|
|
|
this.relPath = null
|
2022-04-28 02:42:34 +02:00
|
|
|
this.isFile = false
|
2022-03-09 02:31:44 +01:00
|
|
|
this.mtimeMs = null
|
|
|
|
this.ctimeMs = null
|
|
|
|
this.birthtimeMs = null
|
|
|
|
this.addedAt = null
|
2022-03-11 01:45:02 +01:00
|
|
|
this.updatedAt = null
|
2022-03-09 02:31:44 +01:00
|
|
|
this.lastScan = null
|
|
|
|
this.scanVersion = null
|
|
|
|
|
2022-03-13 00:45:32 +01:00
|
|
|
// Was scanned and no longer exists
|
2022-03-09 02:31:44 +01:00
|
|
|
this.isMissing = false
|
2022-03-13 00:45:32 +01:00
|
|
|
// Was scanned and no longer has media files
|
|
|
|
this.isInvalid = false
|
2022-03-09 02:31:44 +01:00
|
|
|
|
2022-03-11 01:45:02 +01:00
|
|
|
this.mediaType = null
|
|
|
|
this.media = null
|
2022-03-09 02:31:44 +01:00
|
|
|
|
2023-09-03 22:14:58 +02:00
|
|
|
/** @type {LibraryFile[]} */
|
2022-03-09 02:31:44 +01:00
|
|
|
this.libraryFiles = []
|
|
|
|
|
|
|
|
if (libraryItem) {
|
|
|
|
this.construct(libraryItem)
|
|
|
|
}
|
2022-04-12 23:05:16 +02:00
|
|
|
|
|
|
|
// Temporary attributes
|
|
|
|
this.isSavingMetadata = false
|
2022-03-09 02:31:44 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
construct(libraryItem) {
|
|
|
|
this.id = libraryItem.id
|
|
|
|
this.ino = libraryItem.ino || null
|
2023-07-16 22:05:51 +02:00
|
|
|
this.oldLibraryItemId = libraryItem.oldLibraryItemId
|
2022-03-09 02:31:44 +01:00
|
|
|
this.libraryId = libraryItem.libraryId
|
|
|
|
this.folderId = libraryItem.folderId
|
|
|
|
this.path = libraryItem.path
|
|
|
|
this.relPath = libraryItem.relPath
|
2022-04-28 02:42:34 +02:00
|
|
|
this.isFile = !!libraryItem.isFile
|
2022-03-09 02:31:44 +01:00
|
|
|
this.mtimeMs = libraryItem.mtimeMs || 0
|
|
|
|
this.ctimeMs = libraryItem.ctimeMs || 0
|
|
|
|
this.birthtimeMs = libraryItem.birthtimeMs || 0
|
|
|
|
this.addedAt = libraryItem.addedAt
|
2022-03-11 01:45:02 +01:00
|
|
|
this.updatedAt = libraryItem.updatedAt || this.addedAt
|
2022-03-09 02:31:44 +01:00
|
|
|
this.lastScan = libraryItem.lastScan || null
|
|
|
|
this.scanVersion = libraryItem.scanVersion || null
|
|
|
|
|
|
|
|
this.isMissing = !!libraryItem.isMissing
|
2022-03-13 00:45:32 +01:00
|
|
|
this.isInvalid = !!libraryItem.isInvalid
|
2022-03-09 02:31:44 +01:00
|
|
|
|
2022-03-11 01:45:02 +01:00
|
|
|
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)
|
2022-03-09 02:31:44 +01:00
|
|
|
}
|
2022-05-31 02:26:53 +02:00
|
|
|
this.media.libraryItemId = this.id
|
2022-03-09 02:31:44 +01:00
|
|
|
|
2024-09-04 00:04:58 +02:00
|
|
|
this.libraryFiles = libraryItem.libraryFiles.map((f) => new LibraryFile(f))
|
2023-06-10 19:46:57 +02:00
|
|
|
|
|
|
|
// 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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2022-03-09 02:31:44 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
toJSON() {
|
|
|
|
return {
|
|
|
|
id: this.id,
|
|
|
|
ino: this.ino,
|
2023-07-16 22:05:51 +02:00
|
|
|
oldLibraryItemId: this.oldLibraryItemId,
|
2022-03-09 02:31:44 +01:00
|
|
|
libraryId: this.libraryId,
|
|
|
|
folderId: this.folderId,
|
|
|
|
path: this.path,
|
|
|
|
relPath: this.relPath,
|
2022-04-28 02:42:34 +02:00
|
|
|
isFile: this.isFile,
|
2022-03-09 02:31:44 +01:00
|
|
|
mtimeMs: this.mtimeMs,
|
|
|
|
ctimeMs: this.ctimeMs,
|
|
|
|
birthtimeMs: this.birthtimeMs,
|
|
|
|
addedAt: this.addedAt,
|
2022-03-11 01:45:02 +01:00
|
|
|
updatedAt: this.updatedAt,
|
2022-03-09 02:31:44 +01:00
|
|
|
lastScan: this.lastScan,
|
|
|
|
scanVersion: this.scanVersion,
|
|
|
|
isMissing: !!this.isMissing,
|
2022-03-13 00:45:32 +01:00
|
|
|
isInvalid: !!this.isInvalid,
|
2022-03-11 01:45:02 +01:00
|
|
|
mediaType: this.mediaType,
|
|
|
|
media: this.media.toJSON(),
|
2024-09-04 00:04:58 +02:00
|
|
|
libraryFiles: this.libraryFiles.map((f) => f.toJSON())
|
2022-03-09 02:31:44 +01:00
|
|
|
}
|
|
|
|
}
|
2022-03-11 01:45:02 +01:00
|
|
|
|
|
|
|
toJSONMinified() {
|
|
|
|
return {
|
|
|
|
id: this.id,
|
|
|
|
ino: this.ino,
|
2023-07-16 22:05:51 +02:00
|
|
|
oldLibraryItemId: this.oldLibraryItemId,
|
2022-03-11 01:45:02 +01:00
|
|
|
libraryId: this.libraryId,
|
|
|
|
folderId: this.folderId,
|
|
|
|
path: this.path,
|
|
|
|
relPath: this.relPath,
|
2022-04-28 02:42:34 +02:00
|
|
|
isFile: this.isFile,
|
2022-03-11 01:45:02 +01:00
|
|
|
mtimeMs: this.mtimeMs,
|
|
|
|
ctimeMs: this.ctimeMs,
|
|
|
|
birthtimeMs: this.birthtimeMs,
|
|
|
|
addedAt: this.addedAt,
|
|
|
|
updatedAt: this.updatedAt,
|
|
|
|
isMissing: !!this.isMissing,
|
2022-03-13 00:45:32 +01:00
|
|
|
isInvalid: !!this.isInvalid,
|
2022-03-11 01:45:02 +01:00
|
|
|
mediaType: this.mediaType,
|
|
|
|
media: this.media.toJSONMinified(),
|
2022-03-17 18:25:12 +01:00
|
|
|
numFiles: this.libraryFiles.length,
|
|
|
|
size: this.size
|
2022-03-11 01:45:02 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Adds additional helpful fields like media duration, tracks, etc.
|
|
|
|
toJSONExpanded() {
|
|
|
|
return {
|
|
|
|
id: this.id,
|
|
|
|
ino: this.ino,
|
2023-07-16 22:05:51 +02:00
|
|
|
oldLibraryItemId: this.oldLibraryItemId,
|
2022-03-11 01:45:02 +01:00
|
|
|
libraryId: this.libraryId,
|
|
|
|
folderId: this.folderId,
|
|
|
|
path: this.path,
|
|
|
|
relPath: this.relPath,
|
2022-04-28 02:42:34 +02:00
|
|
|
isFile: this.isFile,
|
2022-03-11 01:45:02 +01:00
|
|
|
mtimeMs: this.mtimeMs,
|
|
|
|
ctimeMs: this.ctimeMs,
|
|
|
|
birthtimeMs: this.birthtimeMs,
|
|
|
|
addedAt: this.addedAt,
|
|
|
|
updatedAt: this.updatedAt,
|
|
|
|
lastScan: this.lastScan,
|
|
|
|
scanVersion: this.scanVersion,
|
|
|
|
isMissing: !!this.isMissing,
|
2022-03-13 00:45:32 +01:00
|
|
|
isInvalid: !!this.isInvalid,
|
2022-03-11 01:45:02 +01:00
|
|
|
mediaType: this.mediaType,
|
|
|
|
media: this.media.toJSONExpanded(),
|
2024-09-04 00:04:58 +02:00
|
|
|
libraryFiles: this.libraryFiles.map((f) => f.toJSON()),
|
2022-03-11 01:45:02 +01:00
|
|
|
size: this.size
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-09-04 00:04:58 +02:00
|
|
|
get isPodcast() {
|
|
|
|
return this.mediaType === 'podcast'
|
|
|
|
}
|
|
|
|
get isBook() {
|
|
|
|
return this.mediaType === 'book'
|
|
|
|
}
|
2022-03-11 01:45:02 +01:00
|
|
|
get size() {
|
2022-12-22 23:38:55 +01:00
|
|
|
let total = 0
|
2024-09-04 00:04:58 +02:00
|
|
|
this.libraryFiles.forEach((lf) => (total += lf.metadata.size))
|
2022-03-11 01:45:02 +01:00
|
|
|
return total
|
|
|
|
}
|
2022-03-13 00:45:32 +01:00
|
|
|
get hasAudioFiles() {
|
2024-09-04 00:04:58 +02:00
|
|
|
return this.libraryFiles.some((lf) => lf.fileType === 'audio')
|
2022-03-13 00:45:32 +01:00
|
|
|
}
|
2022-03-17 01:15:25 +01:00
|
|
|
get hasMediaEntities() {
|
|
|
|
return this.media.hasMediaEntities
|
2022-03-13 00:45:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// Data comes from scandir library item data
|
2023-10-20 00:20:12 +02:00
|
|
|
// TODO: Remove this function. Only used when creating a new podcast now
|
2022-03-13 00:45:32 +01:00
|
|
|
setData(libraryMediaType, payload) {
|
2023-07-05 01:14:44 +02:00
|
|
|
this.id = uuidv4()
|
2022-05-31 02:26:53 +02:00
|
|
|
this.mediaType = libraryMediaType
|
2023-10-20 00:20:12 +02:00
|
|
|
if (libraryMediaType === 'podcast') {
|
2022-03-13 00:45:32 +01:00
|
|
|
this.media = new Podcast()
|
2023-10-20 00:20:12 +02:00
|
|
|
} else {
|
|
|
|
Logger.error(`[LibraryItem] setData called with unsupported media type "${libraryMediaType}"`)
|
|
|
|
return
|
2022-03-13 00:45:32 +01:00
|
|
|
}
|
2023-07-05 01:14:44 +02:00
|
|
|
this.media.id = uuidv4()
|
2022-05-31 02:26:53 +02:00
|
|
|
this.media.libraryItemId = this.id
|
2022-03-22 01:24:38 +01:00
|
|
|
|
2022-03-13 00:45:32 +01:00
|
|
|
for (const key in payload) {
|
|
|
|
if (key === 'libraryFiles') {
|
2024-09-04 00:04:58 +02:00
|
|
|
this.libraryFiles = payload.libraryFiles.map((lf) => lf.clone())
|
2022-03-13 00:45:32 +01:00
|
|
|
|
2023-02-24 00:55:11 +01:00
|
|
|
// Set cover image
|
2024-09-04 00:04:58 +02:00
|
|
|
const imageFiles = this.libraryFiles.filter((lf) => lf.fileType === 'image')
|
|
|
|
const coverMatch = imageFiles.find((iFile) => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
2023-02-24 00:55:11 +01:00
|
|
|
if (coverMatch) {
|
|
|
|
this.media.coverPath = coverMatch.metadata.path
|
|
|
|
} else if (imageFiles.length) {
|
|
|
|
this.media.coverPath = imageFiles[0].metadata.path
|
|
|
|
}
|
2022-03-22 01:24:38 +01:00
|
|
|
} else if (this[key] !== undefined && key !== 'media') {
|
2022-03-13 00:45:32 +01:00
|
|
|
this[key] = payload[key]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-03-22 01:24:38 +01:00
|
|
|
if (payload.media) {
|
|
|
|
this.media.setData(payload.media)
|
2022-03-13 00:45:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
this.addedAt = Date.now()
|
|
|
|
this.updatedAt = Date.now()
|
|
|
|
}
|
2022-03-12 02:46:32 +01:00
|
|
|
|
|
|
|
update(payload) {
|
2022-12-26 23:08:53 +01:00
|
|
|
const json = this.toJSON()
|
|
|
|
let hasUpdates = false
|
2022-03-12 02:46:32 +01:00
|
|
|
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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2022-03-13 00:45:32 +01:00
|
|
|
if (hasUpdates) {
|
|
|
|
this.updatedAt = Date.now()
|
|
|
|
}
|
2022-03-12 02:46:32 +01:00
|
|
|
return hasUpdates
|
|
|
|
}
|
2022-03-13 00:45:32 +01:00
|
|
|
|
|
|
|
updateMediaCover(coverPath) {
|
|
|
|
this.media.updateCover(coverPath)
|
|
|
|
this.updatedAt = Date.now()
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
setMissing() {
|
|
|
|
this.isMissing = true
|
|
|
|
this.updatedAt = Date.now()
|
|
|
|
}
|
|
|
|
|
2022-04-03 23:01:59 +02:00
|
|
|
getDirectPlayTracklist(episodeId) {
|
|
|
|
return this.media.getDirectPlayTracklist(episodeId)
|
2022-03-17 01:15:25 +01:00
|
|
|
}
|
2022-04-12 23:05:16 +02:00
|
|
|
|
2023-07-22 14:50:47 +02:00
|
|
|
/**
|
2023-10-22 22:53:05 +02:00
|
|
|
* Save metadata.json file
|
2023-10-20 00:20:12 +02:00
|
|
|
* TODO: Move to new LibraryItem model
|
2023-09-03 22:14:58 +02:00
|
|
|
* @returns {Promise<LibraryFile>} null if not saved
|
2023-07-22 14:50:47 +02:00
|
|
|
*/
|
2022-04-12 23:05:16 +02:00
|
|
|
async saveMetadata() {
|
2024-12-01 19:44:21 +01:00
|
|
|
if (this.isSavingMetadata || !global.MetadataPath) return null
|
2022-05-31 02:26:53 +02:00
|
|
|
|
2022-04-12 23:05:16 +02:00
|
|
|
this.isSavingMetadata = true
|
|
|
|
|
2022-12-22 23:38:55 +01:00
|
|
|
let metadataPath = Path.join(global.MetadataPath, 'items', this.id)
|
2023-09-03 22:14:58 +02:00
|
|
|
let storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem
|
|
|
|
if (storeMetadataWithItem && !this.isFile) {
|
2022-04-12 23:05:16 +02:00
|
|
|
metadataPath = this.path
|
|
|
|
} else {
|
|
|
|
// Make sure metadata book dir exists
|
2023-09-03 22:14:58 +02:00
|
|
|
storeMetadataWithItem = false
|
2022-04-12 23:05:16 +02:00
|
|
|
await fs.ensureDir(metadataPath)
|
|
|
|
}
|
|
|
|
|
2023-10-22 22:53:05 +02:00
|
|
|
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
|
|
|
|
|
2024-09-04 00:04:58 +02:00
|
|
|
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
|
2023-09-03 22:14:58 +02:00
|
|
|
}
|
2023-05-17 01:58:01 +02:00
|
|
|
}
|
2023-09-03 22:14:58 +02:00
|
|
|
|
2024-09-04 00:04:58 +02:00
|
|
|
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
|
|
|
|
})
|
2022-04-12 23:05:16 +02:00
|
|
|
}
|
2022-06-02 00:45:52 +02:00
|
|
|
|
|
|
|
removeLibraryFile(ino) {
|
|
|
|
if (!ino) return false
|
2024-09-04 00:04:58 +02:00
|
|
|
const libraryFile = this.libraryFiles.find((lf) => lf.ino === ino)
|
2022-06-02 00:45:52 +02:00
|
|
|
if (libraryFile) {
|
2024-09-04 00:04:58 +02:00
|
|
|
this.libraryFiles = this.libraryFiles.filter((lf) => lf.ino !== ino)
|
2022-06-02 00:45:52 +02:00
|
|
|
this.updatedAt = Date.now()
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
return false
|
|
|
|
}
|
2023-06-10 19:46:57 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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
|
2024-09-04 00:04:58 +02:00
|
|
|
*
|
|
|
|
* @param {LibraryFile} [libraryFile]
|
2023-06-10 19:46:57 +02:00
|
|
|
*/
|
|
|
|
setPrimaryEbook(ebookLibraryFile = null) {
|
2024-09-04 00:04:58 +02:00
|
|
|
const ebookLibraryFiles = this.libraryFiles.filter((lf) => lf.isEBookFile)
|
2023-06-10 19:46:57 +02:00
|
|
|
for (const libraryFile of ebookLibraryFiles) {
|
|
|
|
libraryFile.isSupplementary = ebookLibraryFile?.ino !== libraryFile.ino
|
|
|
|
}
|
|
|
|
this.media.setEbookFile(ebookLibraryFile)
|
|
|
|
}
|
2022-03-09 02:31:44 +01:00
|
|
|
}
|
2024-09-04 00:04:58 +02:00
|
|
|
module.exports = LibraryItem
|