mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-30 18:12:25 -04:00 
			
		
		
		
	
		
			
				
	
	
		
			347 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			347 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| const uuidv4 = require("uuid").v4
 | |
| const fs = require('../libs/fsExtra')
 | |
| const Path = require('path')
 | |
| const Logger = require('../Logger')
 | |
| 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 } = 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 hasAudioFiles() {
 | |
|     return this.libraryFiles.some(lf => lf.fileType === 'audio')
 | |
|   }
 | |
|   get hasMediaEntities() {
 | |
|     return this.media.hasMediaEntities
 | |
|   }
 | |
| 
 | |
|   // Data comes from scandir library item data
 | |
|   // TODO: Remove this function. Only used when creating a new podcast now
 | |
|   setData(libraryMediaType, payload) {
 | |
|     this.id = uuidv4()
 | |
|     this.mediaType = libraryMediaType
 | |
|     if (libraryMediaType === 'podcast') {
 | |
|       this.media = new Podcast()
 | |
|     } else {
 | |
|       Logger.error(`[LibraryItem] setData called with unsupported media type "${libraryMediaType}"`)
 | |
|       return
 | |
|     }
 | |
|     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()
 | |
|   }
 | |
| 
 | |
|   getDirectPlayTracklist(episodeId) {
 | |
|     return this.media.getDirectPlayTracklist(episodeId)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Save metadata.json file
 | |
|    * TODO: Move to new LibraryItem model
 | |
|    * @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 metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
 | |
| 
 | |
|     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
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   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 |