mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01:00
1089 lines
36 KiB
JavaScript
1089 lines
36 KiB
JavaScript
const Path = require('path')
|
|
const fs = require('fs-extra')
|
|
const { bytesPretty, readTextFile } = require('../utils/fileUtils')
|
|
const { comparePaths, getIno, getId, elapsedPretty } = require('../utils/index')
|
|
const { parseOpfMetadataXML } = require('../utils/parseOpfMetadata')
|
|
const { extractCoverArt } = require('../utils/ffmpegHelpers')
|
|
const nfoGenerator = require('../utils/nfoGenerator')
|
|
const abmetadataGenerator = require('../utils/abmetadataGenerator')
|
|
const Logger = require('../Logger')
|
|
const Book = require('./Book')
|
|
const AudioTrack = require('./AudioTrack')
|
|
const AudioFile = require('./AudioFile')
|
|
const AudiobookFile = require('./AudiobookFile')
|
|
|
|
class Audiobook {
|
|
constructor(audiobook = null) {
|
|
this.id = null
|
|
this.ino = null // Inode
|
|
|
|
this.libraryId = null
|
|
this.folderId = null
|
|
|
|
this.path = null
|
|
this.fullPath = null
|
|
this.addedAt = null
|
|
this.lastUpdate = null
|
|
this.lastScan = null
|
|
this.scanVersion = null
|
|
|
|
this.tracks = []
|
|
this.missingParts = []
|
|
|
|
this.audioFiles = []
|
|
this.otherFiles = []
|
|
|
|
this.tags = []
|
|
this.book = null
|
|
this.chapters = []
|
|
|
|
// Audiobook was scanned and not found
|
|
this.isMissing = false
|
|
// Audiobook no longer has "book" files
|
|
this.isInvalid = false
|
|
|
|
if (audiobook) {
|
|
this.construct(audiobook)
|
|
}
|
|
|
|
// Temp flags
|
|
this.isSavingMetadata = false
|
|
}
|
|
|
|
construct(audiobook) {
|
|
this.id = audiobook.id
|
|
this.ino = audiobook.ino || null
|
|
this.libraryId = audiobook.libraryId || 'main'
|
|
this.folderId = audiobook.folderId || 'audiobooks'
|
|
this.path = audiobook.path
|
|
this.fullPath = audiobook.fullPath
|
|
this.addedAt = audiobook.addedAt
|
|
this.lastUpdate = audiobook.lastUpdate || this.addedAt
|
|
this.lastScan = audiobook.lastScan || null
|
|
this.scanVersion = audiobook.scanVersion || null
|
|
|
|
this.tracks = audiobook.tracks.map(track => new AudioTrack(track))
|
|
this.missingParts = audiobook.missingParts
|
|
|
|
this.audioFiles = audiobook.audioFiles.map(file => new AudioFile(file))
|
|
this.otherFiles = audiobook.otherFiles.map(file => new AudiobookFile(file))
|
|
|
|
this.tags = audiobook.tags
|
|
if (audiobook.book) {
|
|
this.book = new Book(audiobook.book)
|
|
}
|
|
if (audiobook.chapters) {
|
|
this.chapters = audiobook.chapters.map(c => ({ ...c }))
|
|
}
|
|
|
|
this.isMissing = !!audiobook.isMissing
|
|
this.isInvalid = !!audiobook.isInvalid
|
|
}
|
|
|
|
get title() {
|
|
return this.book ? this.book.title : 'No Title'
|
|
}
|
|
|
|
get author() {
|
|
return this.book ? this.book.author : 'Unknown'
|
|
}
|
|
|
|
get cover() {
|
|
return this.book ? this.book.cover : ''
|
|
}
|
|
|
|
get authorLF() {
|
|
return this.book ? this.book.authorLF : null
|
|
}
|
|
|
|
get authorFL() {
|
|
return this.book ? this.book.authorFL : null
|
|
}
|
|
|
|
get genres() {
|
|
return this.book ? this.book.genres || [] : []
|
|
}
|
|
|
|
get duration() {
|
|
var total = 0
|
|
this.tracks.forEach((track) => total += track.duration)
|
|
return total
|
|
}
|
|
|
|
get size() {
|
|
var total = 0
|
|
this.tracks.forEach((track) => total += track.size)
|
|
return total
|
|
}
|
|
|
|
get sizePretty() {
|
|
return bytesPretty(this.size)
|
|
}
|
|
|
|
get durationPretty() {
|
|
return elapsedPretty(this.duration)
|
|
}
|
|
|
|
get invalidParts() {
|
|
return this._audioFiles.filter(af => af.invalid).map(af => ({ filename: af.filename, error: af.error || 'Unknown Error' }))
|
|
}
|
|
|
|
get numMissingParts() {
|
|
return this.missingParts ? this.missingParts.length : 0
|
|
}
|
|
|
|
get numInvalidParts() {
|
|
return this.invalidParts ? this.invalidParts.length : 0
|
|
}
|
|
|
|
get _audioFiles() { return this.audioFiles || [] }
|
|
get _otherFiles() { return this.otherFiles || [] }
|
|
get _tracks() { return this.tracks || [] }
|
|
|
|
get audioFilesToInclude() { return this._audioFiles.filter(af => !af.exclude) }
|
|
|
|
get ebooks() {
|
|
return this.otherFiles.filter(file => file.filetype === 'ebook')
|
|
}
|
|
|
|
get hasMissingIno() {
|
|
return !this.ino || this._audioFiles.find(abf => !abf.ino) || this._otherFiles.find(f => !f.ino) || this._tracks.find(t => !t.ino)
|
|
}
|
|
|
|
get hasEmbeddedCoverArt() {
|
|
return !!this._audioFiles.find(af => af.embeddedCoverArt)
|
|
}
|
|
|
|
// TEMP: Issue with inodes not always being set for files
|
|
getFilesWithMissingIno() {
|
|
var afs = this._audioFiles.filter(af => !af.ino)
|
|
var ofs = this._otherFiles.filter(f => !f.ino)
|
|
var ts = this._tracks.filter(t => !t.ino)
|
|
return afs.concat(ofs).concat(ts)
|
|
}
|
|
|
|
bookToJSON() {
|
|
return this.book ? this.book.toJSON() : null
|
|
}
|
|
|
|
tracksToJSON() {
|
|
if (!this.tracks || !this.tracks.length) return []
|
|
return this.tracks.map(t => t.toJSON())
|
|
}
|
|
|
|
toJSON() {
|
|
return {
|
|
id: this.id,
|
|
ino: this.ino,
|
|
libraryId: this.libraryId,
|
|
folderId: this.folderId,
|
|
path: this.path,
|
|
fullPath: this.fullPath,
|
|
addedAt: this.addedAt,
|
|
lastUpdate: this.lastUpdate,
|
|
lastScan: this.lastScan,
|
|
scanVersion: this.scanVersion,
|
|
missingParts: this.missingParts,
|
|
tags: this.tags,
|
|
book: this.bookToJSON(),
|
|
tracks: this.tracksToJSON(),
|
|
audioFiles: this._audioFiles.map(audioFile => audioFile.toJSON()),
|
|
otherFiles: this._otherFiles.map(otherFile => otherFile.toJSON()),
|
|
chapters: this.chapters || [],
|
|
isMissing: !!this.isMissing,
|
|
isInvalid: !!this.isInvalid
|
|
}
|
|
}
|
|
|
|
toJSONMinified() {
|
|
return {
|
|
id: this.id,
|
|
ino: this.ino,
|
|
libraryId: this.libraryId,
|
|
folderId: this.folderId,
|
|
book: this.bookToJSON(),
|
|
tags: this.tags,
|
|
path: this.path,
|
|
fullPath: this.fullPath,
|
|
addedAt: this.addedAt,
|
|
lastUpdate: this.lastUpdate,
|
|
duration: this.duration,
|
|
size: this.size,
|
|
ebooks: this.ebooks.map(ebook => ebook.toJSON()),
|
|
numEbooks: this.ebooks.length,
|
|
numTracks: this.tracks.length,
|
|
numChapters: (this.chapters || []).length,
|
|
isMissing: !!this.isMissing,
|
|
isInvalid: !!this.isInvalid,
|
|
hasMissingParts: this.numMissingParts,
|
|
hasInvalidParts: this.numInvalidParts
|
|
}
|
|
}
|
|
|
|
toJSONExpanded() {
|
|
return {
|
|
id: this.id,
|
|
ino: this.ino,
|
|
libraryId: this.libraryId,
|
|
folderId: this.folderId,
|
|
path: this.path,
|
|
fullPath: this.fullPath,
|
|
addedAt: this.addedAt,
|
|
lastUpdate: this.lastUpdate,
|
|
duration: this.duration,
|
|
durationPretty: this.durationPretty,
|
|
size: this.size,
|
|
sizePretty: this.sizePretty,
|
|
missingParts: this.missingParts,
|
|
invalidParts: this.invalidParts,
|
|
audioFiles: this._audioFiles.map(audioFile => audioFile.toJSON()),
|
|
otherFiles: this._otherFiles.map(otherFile => otherFile.toJSON()),
|
|
ebooks: this.ebooks.map(ebook => ebook.toJSON()),
|
|
numEbooks: this.ebooks.length,
|
|
numTracks: this.tracks.length,
|
|
tags: this.tags,
|
|
book: this.bookToJSON(),
|
|
tracks: this.tracksToJSON(),
|
|
chapters: this.chapters || [],
|
|
isMissing: !!this.isMissing,
|
|
isInvalid: !!this.isInvalid,
|
|
hasMissingParts: this.numMissingParts,
|
|
hasInvalidParts: this.numInvalidParts
|
|
}
|
|
}
|
|
|
|
// Originally files did not store the inode value
|
|
// this function checks all files and sets the inode
|
|
async checkUpdateInos() {
|
|
var hasUpdates = false
|
|
|
|
// Audiobook folder needs inode
|
|
if (!this.ino) {
|
|
this.ino = await getIno(this.fullPath)
|
|
hasUpdates = true
|
|
}
|
|
|
|
// Check audio files have an inode
|
|
for (let i = 0; i < this.audioFiles.length; i++) {
|
|
var af = this.audioFiles[i]
|
|
var at = this.tracks.find(t => t.ino === af.ino)
|
|
if (!at) {
|
|
at = this.tracks.find(t => comparePaths(t.path, af.path))
|
|
if (!at && !af.exclude) {
|
|
Logger.warn(`[Audiobook] No matching track for audio file "${af.filename}"`)
|
|
}
|
|
}
|
|
if (!af.ino || af.ino === this.ino) {
|
|
af.ino = await getIno(af.fullPath)
|
|
if (!af.ino) {
|
|
Logger.error('[Audiobook] checkUpdateInos: Failed to set ino for audio file', af.fullPath)
|
|
} else {
|
|
Logger.debug(`[Audiobook] Set INO For audio file ${af.path}`)
|
|
if (at) at.ino = af.ino
|
|
}
|
|
hasUpdates = true
|
|
} else if (at && at.ino !== af.ino) {
|
|
at.ino = af.ino
|
|
hasUpdates = true
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < this.tracks.length; i++) {
|
|
var at = this.tracks[i]
|
|
if (!at.ino) {
|
|
Logger.debug(`[Audiobook] Track ${at.filename} still does not have ino`)
|
|
var atino = await getIno(at.fullPath)
|
|
var af = this.audioFiles.find(_af => _af.ino === atino)
|
|
if (!af) {
|
|
Logger.debug(`[Audiobook] Track ${at.filename} no matching audio file with ino ${atino}`)
|
|
af = this.audioFiles.find(_af => _af.filename === at.filename)
|
|
if (!af) {
|
|
Logger.debug(`[Audiobook] Track ${at.filename} no matching audio file with filename`)
|
|
} else {
|
|
Logger.debug(`[Audiobook] Track ${at.filename} found matching filename but mismatch ino ${atino}/${af.ino}`)
|
|
// at.ino = af.ino
|
|
// at.path = af.path
|
|
// at.fullPath = af.fullPath
|
|
// hasUpdates = true
|
|
}
|
|
} else {
|
|
Logger.debug(`[Audiobook] Track ${at.filename} found audio file with matching ino ${at.path}/${af.path}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < this.otherFiles.length; i++) {
|
|
var file = this.otherFiles[i]
|
|
if (!file.ino || file.ino === this.ino) {
|
|
file.ino = await getIno(file.fullPath)
|
|
if (!file.ino) {
|
|
Logger.error('[Audiobook] checkUpdateInos: Failed to set ino for other file', file.fullPath)
|
|
} else {
|
|
Logger.debug(`[Audiobook] Set INO For other file ${file.path}`)
|
|
}
|
|
hasUpdates = true
|
|
}
|
|
}
|
|
return hasUpdates
|
|
}
|
|
|
|
setData(data) {
|
|
this.id = getId('ab')
|
|
this.libraryId = data.libraryId || 'main'
|
|
this.folderId = data.folderId || 'audiobooks'
|
|
this.ino = data.ino || null
|
|
|
|
this.path = data.path
|
|
this.fullPath = data.fullPath
|
|
this.addedAt = Date.now()
|
|
this.lastUpdate = this.addedAt
|
|
|
|
if (data.otherFiles) {
|
|
data.otherFiles.forEach((file) => {
|
|
this.addOtherFile(file)
|
|
})
|
|
}
|
|
|
|
this.setBook(data)
|
|
}
|
|
|
|
checkHasOldCoverPath() {
|
|
return this.book.cover && !this.book.coverFullPath
|
|
}
|
|
|
|
setLastScan(version) {
|
|
this.lastScan = Date.now()
|
|
this.lastUpdate = Date.now()
|
|
this.scanVersion = version
|
|
}
|
|
|
|
setMissing() {
|
|
this.isMissing = true
|
|
this.lastUpdate = Date.now()
|
|
}
|
|
|
|
setInvalid() {
|
|
this.isInvalid = true
|
|
this.lastUpdate = Date.now()
|
|
}
|
|
|
|
setBook(data) {
|
|
// Use first image file as cover
|
|
if (this.otherFiles && this.otherFiles.length) {
|
|
var imageFile = this.otherFiles.find(f => f.filetype === 'image')
|
|
if (imageFile) {
|
|
data.coverFullPath = imageFile.fullPath
|
|
var relImagePath = imageFile.path.replace(this.path, '')
|
|
data.cover = Path.posix.join(`/s/book/${this.id}`, relImagePath)
|
|
}
|
|
}
|
|
|
|
this.book = new Book()
|
|
this.book.setData(data)
|
|
}
|
|
|
|
setCoverFromFile(file) {
|
|
if (!file || !file.fullPath || !file.path) {
|
|
Logger.error(`[Audiobook] "${this.title}" Invalid file for setCoverFromFile`, file)
|
|
return false
|
|
}
|
|
var updateBookPayload = {}
|
|
updateBookPayload.coverFullPath = file.fullPath
|
|
// Set ab local static path from file relative path
|
|
var relImagePath = file.path.replace(this.path, '')
|
|
updateBookPayload.cover = Path.posix.join(`/s/book/${this.id}`, relImagePath)
|
|
return this.book.update(updateBookPayload)
|
|
}
|
|
|
|
addTrack(trackData) {
|
|
var track = new AudioTrack()
|
|
track.setData(trackData)
|
|
this.tracks.push(track)
|
|
return track
|
|
}
|
|
|
|
addAudioFile(audioFileData) {
|
|
this.audioFiles.push(audioFileData)
|
|
return audioFileData
|
|
}
|
|
|
|
updateAudioFile(updatedAudioFile) {
|
|
var audioFile = this.audioFiles.find(af => af.ino === updatedAudioFile.ino)
|
|
return audioFile.updateFromScan(updatedAudioFile)
|
|
}
|
|
|
|
addOtherFile(fileData) {
|
|
var file = new AudiobookFile()
|
|
file.setData(fileData)
|
|
this.otherFiles.push(file)
|
|
return file
|
|
}
|
|
|
|
update(payload) {
|
|
var hasUpdates = false
|
|
if (payload.tags && payload.tags.join(',') !== this.tags.join(',')) {
|
|
this.tags = payload.tags
|
|
hasUpdates = true
|
|
}
|
|
|
|
if (payload.book && this.book.update(payload.book)) {
|
|
hasUpdates = true
|
|
}
|
|
|
|
if (hasUpdates) {
|
|
this.lastUpdate = Date.now()
|
|
}
|
|
|
|
return hasUpdates
|
|
}
|
|
|
|
// Cover Url may be the same, this ensures the lastUpdate is updated
|
|
updateBookCover(cover, coverFullPath) {
|
|
if (!this.book) return false
|
|
return this.book.updateCover(cover, coverFullPath)
|
|
}
|
|
|
|
checkHasTrackNum(trackNum, excludeIno) {
|
|
return this._audioFiles.find(t => t.index === trackNum && t.ino !== excludeIno)
|
|
}
|
|
|
|
updateAudioTracks(orderedFileData) {
|
|
var index = 1
|
|
this.audioFiles = orderedFileData.map((fileData) => {
|
|
var audioFile = this.audioFiles.find(af => af.ino === fileData.ino)
|
|
audioFile.manuallyVerified = true
|
|
audioFile.invalid = false
|
|
audioFile.error = null
|
|
if (fileData.exclude !== undefined) {
|
|
audioFile.exclude = !!fileData.exclude
|
|
}
|
|
if (audioFile.exclude) {
|
|
audioFile.index = -1
|
|
} else {
|
|
audioFile.index = index++
|
|
}
|
|
return audioFile
|
|
})
|
|
|
|
this.rebuildTracks()
|
|
}
|
|
|
|
// After audio files have been added/removed/updated this method sets tracks
|
|
rebuildTracks() {
|
|
this.audioFiles.sort((a, b) => a.index - b.index)
|
|
this.tracks = []
|
|
this.missingParts = []
|
|
this.audioFiles.forEach((file) => {
|
|
if (!file.exclude) {
|
|
this.addTrack(file)
|
|
}
|
|
})
|
|
this.setChapters()
|
|
this.checkUpdateMissingTracks()
|
|
this.lastUpdate = Date.now()
|
|
}
|
|
|
|
removeAudioFile(audioFile) {
|
|
this.tracks = this.tracks.filter(t => t.ino !== audioFile.ino)
|
|
this.audioFiles = this.audioFiles.filter(f => f.ino !== audioFile.ino)
|
|
}
|
|
|
|
removeAudioTrack(track) {
|
|
this.tracks = this.tracks.filter(t => t.ino !== track.ino)
|
|
this.audioFiles = this.audioFiles.filter(f => f.ino !== track.ino)
|
|
}
|
|
|
|
checkUpdateMissingTracks() {
|
|
var currMissingParts = (this.missingParts || []).join(',') || ''
|
|
|
|
var current_index = 1
|
|
var missingParts = []
|
|
|
|
for (let i = 0; i < this.tracks.length; i++) {
|
|
var _track = this.tracks[i]
|
|
if (_track.index > current_index) {
|
|
var num_parts_missing = _track.index - current_index
|
|
for (let x = 0; x < num_parts_missing && x < 9999; x++) {
|
|
missingParts.push(current_index + x)
|
|
}
|
|
}
|
|
current_index = _track.index + 1
|
|
}
|
|
|
|
this.missingParts = missingParts
|
|
|
|
var newMissingParts = (this.missingParts || []).join(',') || ''
|
|
var wasUpdated = newMissingParts !== currMissingParts
|
|
if (wasUpdated && this.missingParts.length) {
|
|
Logger.info(`[Audiobook] "${this.title}" has ${missingParts.length} missing parts`)
|
|
}
|
|
|
|
return wasUpdated
|
|
}
|
|
|
|
// On scan check other files found with other files saved
|
|
async syncOtherFiles(newOtherFiles, opfMetadataOverrideDetails) {
|
|
var hasUpdates = false
|
|
|
|
var currOtherFileNum = this.otherFiles.length
|
|
|
|
var otherFilenamesAlreadyInBook = this.otherFiles.map(ofile => ofile.filename)
|
|
var alreadyHasAbsMetadata = otherFilenamesAlreadyInBook.includes('metadata.abs')
|
|
var alreadyHasDescTxt = otherFilenamesAlreadyInBook.includes('desc.txt')
|
|
var alreadyHasReaderTxt = otherFilenamesAlreadyInBook.includes('reader.txt')
|
|
|
|
// Filter out other files no longer in directory
|
|
var newOtherFilePaths = newOtherFiles.map(f => f.path)
|
|
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
|
|
if (currOtherFileNum !== this.otherFiles.length) {
|
|
Logger.debug(`[Audiobook] ${currOtherFileNum - this.otherFiles.length} other files were removed for "${this.title}"`)
|
|
hasUpdates = true
|
|
}
|
|
|
|
// If desc.txt is new then read it and update description (will overwrite)
|
|
var descriptionTxt = newOtherFiles.find(file => file.filename === 'desc.txt')
|
|
if (descriptionTxt && !alreadyHasDescTxt) {
|
|
var newDescription = await readTextFile(descriptionTxt.fullPath)
|
|
if (newDescription) {
|
|
Logger.debug(`[Audiobook] Sync Other File desc.txt: ${newDescription}`)
|
|
this.update({ book: { description: newDescription } })
|
|
hasUpdates = true
|
|
}
|
|
}
|
|
// If reader.txt is new then read it and update narrator (will overwrite)
|
|
var readerTxt = newOtherFiles.find(file => file.filename === 'reader.txt')
|
|
if (readerTxt && !alreadyHasReaderTxt) {
|
|
var newReader = await readTextFile(readerTxt.fullPath)
|
|
if (newReader) {
|
|
Logger.debug(`[Audiobook] Sync Other File reader.txt: ${newReader}`)
|
|
this.update({ book: { narrator: newReader } })
|
|
hasUpdates = true
|
|
}
|
|
}
|
|
|
|
|
|
// If metadata.abs is new then read it and set all defined keys (will overwrite)
|
|
var metadataAbs = newOtherFiles.find(file => file.filename === 'metadata.abs')
|
|
if (metadataAbs && !alreadyHasAbsMetadata) {
|
|
var abmetadataText = await readTextFile(metadataAbs.fullPath)
|
|
if (abmetadataText) {
|
|
var metadataUpdateObject = abmetadataGenerator.parse(abmetadataText)
|
|
if (metadataUpdateObject && metadataUpdateObject.book) {
|
|
Logger.debug(`[Audiobook] Updating book "${this.title}" details from metadata.abs file`, metadataUpdateObject)
|
|
if (this.update(metadataUpdateObject)) {
|
|
Logger.debug(`[Audiobook] Some details were updated from metadata.abs for "${this.title}"`)
|
|
hasUpdates = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If OPF file and was not already there OR prefer opf metadata
|
|
var metadataOpf = newOtherFiles.find(file => file.ext === '.opf' || file.filename === 'metadata.xml')
|
|
if (metadataOpf && (!otherFilenamesAlreadyInBook.includes(metadataOpf.filename) || opfMetadataOverrideDetails)) {
|
|
var xmlText = await readTextFile(metadataOpf.fullPath)
|
|
if (xmlText) {
|
|
var opfMetadata = await parseOpfMetadataXML(xmlText)
|
|
// Logger.debug(`[Audiobook] Sync Other File "${metadataOpf.filename}" parsed:`, opfMetadata)
|
|
if (opfMetadata) {
|
|
const bookUpdatePayload = {}
|
|
for (const key in opfMetadata) {
|
|
// Add genres only if genres are empty
|
|
if (key === 'genres') {
|
|
if (opfMetadata.genres.length && (!this.book._genres.length || opfMetadataOverrideDetails)) {
|
|
bookUpdatePayload[key] = opfMetadata.genres
|
|
}
|
|
} else if (opfMetadata[key] && (!this.book[key] || opfMetadataOverrideDetails)) {
|
|
bookUpdatePayload[key] = opfMetadata[key]
|
|
}
|
|
}
|
|
if (Object.keys(bookUpdatePayload).length) {
|
|
Logger.debug(`[Audiobook] Using data found in OPF "${metadataOpf.filename}"`, bookUpdatePayload)
|
|
this.update({ book: bookUpdatePayload })
|
|
hasUpdates = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
newOtherFiles.forEach((file) => {
|
|
var existingOtherFile = this.otherFiles.find(f => f.ino === file.ino)
|
|
if (!existingOtherFile) {
|
|
Logger.debug(`[Audiobook] New other file found on sync ${file.filename} | "${this.title}"`)
|
|
this.addOtherFile(file)
|
|
hasUpdates = true
|
|
}
|
|
})
|
|
|
|
var imageFiles = this.otherFiles.filter(f => f.filetype === 'image')
|
|
|
|
// OLD Path Check if cover was a local image and that it still exists
|
|
if (this.book.cover && this.book.cover.substr(1).startsWith('local')) {
|
|
var coverStripped = this.book.cover.substr('/local/'.length)
|
|
// Check if was removed first
|
|
var coverStillExists = imageFiles.find(f => comparePaths(f.path, coverStripped))
|
|
if (!coverStillExists) {
|
|
Logger.info(`[Audiobook] Local cover was removed | "${this.title}"`)
|
|
this.book.removeCover()
|
|
} else {
|
|
var oldFormat = this.book.cover
|
|
|
|
// Update book cover path to new format
|
|
this.book.coverFullPath = Path.join(this.fullPath, this.book.cover.substr(7)).replace(/\\/g, '/')
|
|
this.book.cover = coverStripped.replace(this.path, `/s/book/${this.id}`)
|
|
Logger.debug(`[Audiobook] updated book cover to new format "${oldFormat}" => "${this.book.cover}"`)
|
|
}
|
|
hasUpdates = true
|
|
}
|
|
|
|
// Check if book was removed from book dir
|
|
var bookCoverPath = this.book.cover ? this.book.cover.replace(/\\/g, '/') : null
|
|
if (bookCoverPath && bookCoverPath.startsWith('/s/book/')) {
|
|
// Fixing old cover paths
|
|
if (!this.book.coverFullPath) {
|
|
this.book.coverFullPath = Path.join(this.fullPath, this.book.cover.substr(`/s/book/${this.id}`.length)).replace(/\\/g, '/').replace(/\/\//g, '/')
|
|
Logger.debug(`[Audiobook] Metadata cover full path set "${this.book.coverFullPath}" for "${this.title}"`)
|
|
hasUpdates = true
|
|
}
|
|
|
|
var coverStillExists = imageFiles.find(f => comparePaths(f.fullPath, this.book.coverFullPath))
|
|
if (!coverStillExists) {
|
|
Logger.info(`[Audiobook] Local cover "${this.book.cover}" was removed | "${this.title}"`)
|
|
this.book.removeCover()
|
|
hasUpdates = true
|
|
}
|
|
}
|
|
|
|
if (bookCoverPath && bookCoverPath.startsWith('/metadata')) {
|
|
// Fixing old cover paths
|
|
if (!this.book.coverFullPath) {
|
|
this.book.coverFullPath = Path.join(global.MetadataPath, this.book.cover.substr('/metadata/'.length)).replace(/\\/g, '/').replace(/\/\//g, '/')
|
|
Logger.debug(`[Audiobook] Metadata cover full path set "${this.book.coverFullPath}" for "${this.title}"`)
|
|
hasUpdates = true
|
|
}
|
|
// metadata covers are stored in /<MetadataPath>/books/:id/
|
|
if (!await fs.pathExists(this.book.coverFullPath)) {
|
|
Logger.info(`[Audiobook] Cover in /metadata for "${this.title}" no longer exists - removing cover paths`)
|
|
this.book.removeCover()
|
|
hasUpdates = true
|
|
}
|
|
}
|
|
|
|
if (this.book.cover && !this.book.coverFullPath) {
|
|
if (this.book.cover.startsWith('http')) {
|
|
Logger.debug(`[Audiobook] Still using http path for cover "${this.book.cover}" - should update to local`)
|
|
this.book.coverFullPath = this.book.cover
|
|
hasUpdates = true
|
|
} else {
|
|
Logger.warn(`[Audiobook] Full cover path still not set "${this.book.cover}"`)
|
|
}
|
|
}
|
|
|
|
// If no cover set and image file exists then use it
|
|
if (!this.book.cover && imageFiles.length) {
|
|
var imagePathRelativeToBook = imageFiles[0].path.replace(this.path, '')
|
|
this.book.cover = Path.posix.join(`/s/book/${this.id}`, imagePathRelativeToBook)
|
|
this.book.coverFullPath = imageFiles[0].fullPath
|
|
Logger.info(`[Audiobook] Local cover was set to "${this.book.cover}" | "${this.title}"`)
|
|
hasUpdates = true
|
|
}
|
|
|
|
return hasUpdates
|
|
}
|
|
|
|
syncAudioFile(audioFile, fileScanData) {
|
|
var hasUpdates = audioFile.syncFile(fileScanData)
|
|
var track = this.tracks.find(t => t.ino === audioFile.ino)
|
|
if (track && track.syncFile(fileScanData)) {
|
|
hasUpdates = true
|
|
}
|
|
return hasUpdates
|
|
}
|
|
|
|
syncPaths(audiobookData) {
|
|
var hasUpdates = false
|
|
var keysToSync = ['path', 'fullPath']
|
|
keysToSync.forEach((key) => {
|
|
if (audiobookData[key] !== undefined && audiobookData[key] !== this[key]) {
|
|
hasUpdates = true
|
|
this[key] = audiobookData[key]
|
|
}
|
|
})
|
|
if (hasUpdates) {
|
|
this.book.syncPathsUpdated(audiobookData)
|
|
}
|
|
return hasUpdates
|
|
}
|
|
|
|
isSearchMatch(search) {
|
|
var tagMatch = this.tags.filter(tag => {
|
|
return tag.toLowerCase().includes(search.toLowerCase().trim())
|
|
})
|
|
return this.book.isSearchMatch(search.toLowerCase().trim()) || tagMatch.length
|
|
}
|
|
|
|
searchQuery(search) {
|
|
var matches = this.book.getQueryMatches(search.toLowerCase().trim())
|
|
matches.tags = this.tags.filter(tag => {
|
|
return tag.toLowerCase().includes(search.toLowerCase().trim())
|
|
})
|
|
if (!matches.book && matches.tags.length) {
|
|
matches.book = 'tags'
|
|
matches.bookMatchText = matches.tags.join(', ')
|
|
}
|
|
return matches
|
|
}
|
|
|
|
getAudioFileByIno(ino) {
|
|
return this.audioFiles.find(af => af.ino === ino)
|
|
}
|
|
|
|
getAudioFileByPath(fullPath) {
|
|
return this.audioFiles.find(af => af.fullPath === fullPath)
|
|
}
|
|
|
|
setChapters() {
|
|
// If 1 audio file without chapters, then no chapters will be set
|
|
var includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
|
|
if (includedAudioFiles.length === 1) {
|
|
// 1 audio file with chapters
|
|
if (includedAudioFiles[0].chapters) {
|
|
this.chapters = includedAudioFiles[0].chapters.map(c => ({ ...c }))
|
|
}
|
|
} else {
|
|
this.chapters = []
|
|
var currChapterId = 0
|
|
var currStartTime = 0
|
|
includedAudioFiles.forEach((file) => {
|
|
// If audio file has chapters use chapters
|
|
if (file.chapters && file.chapters.length) {
|
|
file.chapters.forEach((chapter) => {
|
|
var chapterDuration = chapter.end - chapter.start
|
|
if (chapterDuration > 0) {
|
|
var title = `Chapter ${currChapterId}`
|
|
if (chapter.title) {
|
|
title += ` (${chapter.title})`
|
|
}
|
|
this.chapters.push({
|
|
id: currChapterId++,
|
|
start: currStartTime,
|
|
end: currStartTime + chapterDuration,
|
|
title
|
|
})
|
|
currStartTime += chapterDuration
|
|
}
|
|
})
|
|
} else if (file.duration) {
|
|
// Otherwise just use track has chapter
|
|
this.chapters.push({
|
|
id: currChapterId++,
|
|
start: currStartTime,
|
|
end: currStartTime + file.duration,
|
|
title: file.filename ? Path.basename(file.filename, Path.extname(file.filename)) : `Chapter ${currChapterId}`
|
|
})
|
|
currStartTime += file.duration
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
writeNfoFile(nfoFilename = 'metadata.nfo') {
|
|
return nfoGenerator(this, nfoFilename)
|
|
}
|
|
|
|
// Return cover filename
|
|
async saveEmbeddedCoverArt(coverDirFullPath, coverDirRelPath) {
|
|
var audioFileWithCover = this.audioFiles.find(af => af.embeddedCoverArt)
|
|
if (!audioFileWithCover) return false
|
|
|
|
var coverFilename = audioFileWithCover.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg'
|
|
var coverFilePath = Path.join(coverDirFullPath, coverFilename)
|
|
|
|
var coverAlreadyExists = await fs.pathExists(coverFilePath)
|
|
if (coverAlreadyExists) {
|
|
Logger.warn(`[Audiobook] Extract embedded cover art but cover already exists for "${this.title}" - bail`)
|
|
return false
|
|
}
|
|
|
|
var success = await extractCoverArt(audioFileWithCover.fullPath, coverFilePath)
|
|
if (success) {
|
|
var coverRelPath = Path.join(coverDirRelPath, coverFilename).replace(/\\/g, '/').replace(/\/\//g, '/')
|
|
this.update({ book: { cover: coverRelPath, coverFullPath: audioFileWithCover.fullPath } })
|
|
return coverRelPath
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Look for desc.txt, reader.txt, metadata.abs and opf file then update details if found
|
|
async saveDataFromTextFiles(opfMetadataOverrideDetails) {
|
|
var bookUpdatePayload = {}
|
|
|
|
var descriptionText = await this.fetchTextFromTextFile('desc.txt')
|
|
if (descriptionText) {
|
|
Logger.debug(`[Audiobook] "${this.title}" found desc.txt updating description with "${descriptionText.slice(0, 20)}..."`)
|
|
bookUpdatePayload.description = descriptionText
|
|
}
|
|
var readerText = await this.fetchTextFromTextFile('reader.txt')
|
|
if (readerText) {
|
|
Logger.debug(`[Audiobook] "${this.title}" found reader.txt updating narrator with "${readerText}"`)
|
|
bookUpdatePayload.narrator = readerText
|
|
}
|
|
|
|
// abmetadata will always overwrite
|
|
var abmetadataText = await this.fetchTextFromTextFile('metadata.abs')
|
|
if (abmetadataText) {
|
|
var metadataUpdateObject = abmetadataGenerator.parse(abmetadataText)
|
|
if (metadataUpdateObject && metadataUpdateObject.book) {
|
|
Logger.debug(`[Audiobook] "${this.title}" found book details from metadata.abs file`, metadataUpdateObject)
|
|
for (const key in metadataUpdateObject.book) {
|
|
var value = metadataUpdateObject.book[key]
|
|
if (key && value !== undefined) {
|
|
bookUpdatePayload[key] = value
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Opf only overwrites if detail is empty
|
|
var metadataOpf = this.otherFiles.find(file => file.isOPFFile || file.filename === 'metadata.xml')
|
|
if (metadataOpf) {
|
|
var xmlText = await readTextFile(metadataOpf.fullPath)
|
|
if (xmlText) {
|
|
var opfMetadata = await parseOpfMetadataXML(xmlText)
|
|
// Logger.debug(`[Audiobook] "${this.title}" found "${metadataOpf.filename}" parsed:`, opfMetadata)
|
|
if (opfMetadata) {
|
|
for (const key in opfMetadata) {
|
|
// Add genres only if genres are empty
|
|
if (key === 'genres') {
|
|
if (opfMetadata.genres.length && (!this.book._genres.length || opfMetadataOverrideDetails)) {
|
|
bookUpdatePayload[key] = opfMetadata.genres
|
|
}
|
|
} else if (opfMetadata[key] && ((!this.book[key] && !bookUpdatePayload[key]) || opfMetadataOverrideDetails)) {
|
|
bookUpdatePayload[key] = opfMetadata[key]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Object.keys(bookUpdatePayload).length) {
|
|
return this.update({ book: bookUpdatePayload })
|
|
}
|
|
return false
|
|
}
|
|
|
|
fetchTextFromTextFile(textfileName) {
|
|
var textFile = this.otherFiles.find(file => file.filename === textfileName)
|
|
if (!textFile) return false
|
|
return readTextFile(textFile.fullPath)
|
|
}
|
|
|
|
// Audio file metadata tags map to book details (will not overwrite)
|
|
setDetailsFromFileMetadata(overrideExistingDetails = false) {
|
|
if (!this.audioFiles.length) return false
|
|
var audioFile = this.audioFiles[0]
|
|
return this.book.setDetailsFromFileMetadata(audioFile.metadata, overrideExistingDetails)
|
|
}
|
|
|
|
// Returns null if file not found, true if file was updated, false if up to date
|
|
checkFileFound(fileFound, isAudioFile) {
|
|
var hasUpdated = false
|
|
|
|
const arrayToCheck = isAudioFile ? this.audioFiles : this.otherFiles
|
|
|
|
var existingFile = arrayToCheck.find(_af => _af.ino === fileFound.ino)
|
|
if (!existingFile) {
|
|
existingFile = arrayToCheck.find(_af => _af.path === fileFound.path)
|
|
if (existingFile) {
|
|
// file inode was updated
|
|
existingFile.ino = fileFound.ino
|
|
hasUpdated = true
|
|
} else {
|
|
// file not found
|
|
return null
|
|
}
|
|
}
|
|
|
|
if (existingFile.filename !== fileFound.filename) {
|
|
existingFile.filename = fileFound.filename
|
|
existingFile.ext = fileFound.ext
|
|
hasUpdated = true
|
|
}
|
|
|
|
if (existingFile.path !== fileFound.path) {
|
|
existingFile.path = fileFound.path
|
|
existingFile.fullPath = fileFound.fullPath
|
|
hasUpdated = true
|
|
} else if (existingFile.fullPath !== fileFound.fullPath) {
|
|
existingFile.fullPath = fileFound.fullPath
|
|
hasUpdated = true
|
|
}
|
|
|
|
if (!isAudioFile && existingFile.filetype !== fileFound.filetype) {
|
|
existingFile.filetype = fileFound.filetype
|
|
hasUpdated = true
|
|
}
|
|
|
|
return hasUpdated
|
|
}
|
|
|
|
checkScanData(dataFound, version) {
|
|
var hasUpdated = false
|
|
|
|
if (this.isMissing) {
|
|
// Audiobook no longer missing
|
|
this.isMissing = false
|
|
hasUpdated = true
|
|
}
|
|
|
|
if (dataFound.ino !== this.ino) {
|
|
this.ino = dataFound.ino
|
|
hasUpdated = true
|
|
}
|
|
|
|
if (dataFound.folderId !== this.folderId) {
|
|
Logger.warn(`[Audiobook] Check scan audiobook changed folder ${this.folderId} -> ${dataFound.folderId}`)
|
|
this.folderId = dataFound.folderId
|
|
hasUpdated = true
|
|
}
|
|
|
|
if (dataFound.path !== this.path) {
|
|
Logger.warn(`[Audiobook] Check scan audiobook changed path "${this.path}" -> "${dataFound.path}"`)
|
|
this.path = dataFound.path
|
|
this.fullPath = dataFound.fullPath
|
|
hasUpdated = true
|
|
} else if (dataFound.fullPath !== this.fullPath) {
|
|
Logger.warn(`[Audiobook] Check scan audiobook changed fullpath "${this.fullPath}" -> "${dataFound.fullPath}"`)
|
|
this.fullPath = dataFound.fullPath
|
|
hasUpdated = true
|
|
}
|
|
|
|
var newAudioFileData = []
|
|
var newOtherFileData = []
|
|
var existingAudioFileData = []
|
|
var existingOtherFileData = []
|
|
|
|
dataFound.audioFiles.forEach((af) => {
|
|
var audioFileFoundCheck = this.checkFileFound(af, true)
|
|
if (audioFileFoundCheck === null) {
|
|
newAudioFileData.push(af)
|
|
} else if (audioFileFoundCheck) {
|
|
hasUpdated = true
|
|
existingAudioFileData.push(af)
|
|
} else {
|
|
existingAudioFileData.push(af)
|
|
}
|
|
})
|
|
|
|
dataFound.otherFiles.forEach((otherFileData) => {
|
|
var fileFoundCheck = this.checkFileFound(otherFileData, false)
|
|
if (fileFoundCheck === null) {
|
|
newOtherFileData.push(otherFileData)
|
|
} else if (fileFoundCheck) {
|
|
hasUpdated = true
|
|
existingOtherFileData.push(otherFileData)
|
|
} else {
|
|
existingOtherFileData.push(otherFileData)
|
|
}
|
|
})
|
|
|
|
const audioFilesRemoved = []
|
|
const otherFilesRemoved = []
|
|
|
|
// Remove audio files not found (inodes will all be up to date at this point)
|
|
this.audioFiles = this.audioFiles.filter(af => {
|
|
if (!dataFound.audioFiles.find(_af => _af.ino === af.ino)) {
|
|
audioFilesRemoved.push(af.toJSON())
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
|
|
// Remove all tracks that were associated with removed audio files
|
|
if (audioFilesRemoved.length) {
|
|
const audioFilesRemovedInodes = audioFilesRemoved.map(afr => afr.ino)
|
|
this.tracks = this.tracks.filter(t => !audioFilesRemovedInodes.includes(t.ino))
|
|
this.checkUpdateMissingTracks()
|
|
hasUpdated = true
|
|
}
|
|
|
|
// Remove other files not found
|
|
this.otherFiles = this.otherFiles.filter(otherFile => {
|
|
if (!dataFound.otherFiles.find(_otherFile => _otherFile.ino === otherFile.ino)) {
|
|
otherFilesRemoved.push(otherFile.toJSON())
|
|
|
|
// Check remove cover
|
|
if (otherFile.fullPath === this.book.coverFullPath) {
|
|
Logger.debug(`[Audiobook] "${this.title}" Check scan book cover removed`)
|
|
this.book.removeCover()
|
|
}
|
|
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
|
|
if (otherFilesRemoved.length) {
|
|
hasUpdated = true
|
|
}
|
|
|
|
// Check if invalid (has no audio files or ebooks)
|
|
if (!this.audioFilesToInclude.length && !this.ebooks.length && !newAudioFileData.length && !newOtherFileData.length) {
|
|
this.isInvalid = true
|
|
}
|
|
|
|
if (hasUpdated) {
|
|
this.setLastScan(version)
|
|
}
|
|
|
|
return {
|
|
updated: hasUpdated,
|
|
newAudioFileData,
|
|
newOtherFileData,
|
|
audioFilesRemoved,
|
|
otherFilesRemoved,
|
|
existingAudioFileData, // Existing file data may get re-scanned if forceRescan is set
|
|
existingOtherFileData
|
|
}
|
|
}
|
|
|
|
// Temp fix for cover is set but coverFullPath is not set
|
|
fixFullCoverPath() {
|
|
if (!this.book.cover) return
|
|
var bookCoverPath = this.book.cover.replace(/\\/g, '/')
|
|
var newFullCoverPath = null
|
|
if (bookCoverPath.startsWith('/s/book/')) {
|
|
newFullCoverPath = Path.join(this.fullPath, bookCoverPath.substr(`/s/book/${this.id}`.length)).replace(/\/\//g, '/')
|
|
} else if (bookCoverPath.startsWith('/metadata/')) {
|
|
newFullCoverPath = Path.join(global.MetadataPath, bookCoverPath.substr('/metadata/'.length)).replace(/\/\//g, '/')
|
|
}
|
|
if (newFullCoverPath) {
|
|
Logger.debug(`[Audiobook] "${this.title}" fixing full cover path "${this.book.cover}" => "${newFullCoverPath}"`)
|
|
this.update({ book: { fullCoverPath: newFullCoverPath } })
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
async saveAbMetadata() {
|
|
if (this.isSavingMetadata) return
|
|
this.isSavingMetadata = true
|
|
|
|
var metadataPath = Path.join(global.MetadataPath, 'books', this.id)
|
|
if (global.ServerSettings.storeMetadataWithBook) {
|
|
metadataPath = this.fullPath
|
|
} else {
|
|
// Make sure metadata book dir exists
|
|
await fs.ensureDir(metadataPath)
|
|
}
|
|
metadataPath = Path.join(metadataPath, 'metadata.abs')
|
|
|
|
return abmetadataGenerator.generate(this, metadataPath).then((success) => {
|
|
this.isSavingMetadata = false
|
|
if (!success) Logger.error(`[Audiobook] Failed saving abmetadata to "${metadataPath}"`)
|
|
else Logger.debug(`[Audiobook] Success saving abmetadata to "${metadataPath}"`)
|
|
return success
|
|
})
|
|
}
|
|
}
|
|
module.exports = Audiobook |