mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01:00
373 lines
12 KiB
JavaScript
373 lines
12 KiB
JavaScript
const Path = require('path')
|
|
const Logger = require('../../Logger')
|
|
const BookMetadata = require('../metadata/BookMetadata')
|
|
const AudioFile = require('../files/AudioFile')
|
|
const EBookFile = require('../files/EBookFile')
|
|
const abmetadataGenerator = require('../../utils/abmetadataGenerator')
|
|
const { areEquivalent, copyValue } = require('../../utils/index')
|
|
const { parseOpfMetadataXML } = require('../../utils/parseOpfMetadata')
|
|
const { readTextFile } = require('../../utils/fileUtils')
|
|
|
|
class Book {
|
|
constructor(book) {
|
|
this.metadata = null
|
|
|
|
this.coverPath = null
|
|
this.tags = []
|
|
this.audioFiles = []
|
|
this.ebookFiles = []
|
|
this.chapters = []
|
|
this.missingParts = []
|
|
|
|
this.lastCoverSearch = null
|
|
this.lastCoverSearchQuery = null
|
|
|
|
if (book) {
|
|
this.construct(book)
|
|
}
|
|
}
|
|
|
|
construct(book) {
|
|
this.metadata = new BookMetadata(book.metadata)
|
|
this.coverPath = book.coverPath
|
|
this.tags = [...book.tags]
|
|
this.audioFiles = book.audioFiles.map(f => new AudioFile(f))
|
|
this.ebookFiles = book.ebookFiles.map(f => new EBookFile(f))
|
|
this.chapters = book.chapters.map(c => ({ ...c }))
|
|
this.missingParts = book.missingParts ? [...book.missingParts] : []
|
|
this.lastCoverSearch = book.lastCoverSearch || null
|
|
this.lastCoverSearchQuery = book.lastCoverSearchQuery || null
|
|
}
|
|
|
|
toJSON() {
|
|
return {
|
|
metadata: this.metadata.toJSON(),
|
|
coverPath: this.coverPath,
|
|
tags: [...this.tags],
|
|
audioFiles: this.audioFiles.map(f => f.toJSON()),
|
|
ebookFiles: this.ebookFiles.map(f => f.toJSON()),
|
|
chapters: this.chapters.map(c => ({ ...c })),
|
|
missingParts: [...this.missingParts]
|
|
}
|
|
}
|
|
|
|
toJSONMinified() {
|
|
return {
|
|
metadata: this.metadata.toJSON(),
|
|
coverPath: this.coverPath,
|
|
tags: [...this.tags],
|
|
numTracks: this.tracks.length,
|
|
numAudioFiles: this.audioFiles.length,
|
|
numEbooks: this.ebookFiles.length,
|
|
numChapters: this.chapters.length,
|
|
numMissingParts: this.missingParts.length,
|
|
duration: this.duration,
|
|
size: this.size
|
|
}
|
|
}
|
|
|
|
toJSONExpanded() {
|
|
return {
|
|
metadata: this.metadata.toJSONExpanded(),
|
|
coverPath: this.coverPath,
|
|
tags: [...this.tags],
|
|
audioFiles: this.audioFiles.map(f => f.toJSON()),
|
|
ebookFiles: this.ebookFiles.map(f => f.toJSON()),
|
|
chapters: this.chapters.map(c => ({ ...c })),
|
|
duration: this.duration,
|
|
size: this.size,
|
|
tracks: this.tracks.map(t => t.toJSON()),
|
|
missingParts: [...this.missingParts]
|
|
}
|
|
}
|
|
|
|
get tracks() {
|
|
return this.audioFiles.filter(af => !af.exclude && !af.invalid)
|
|
}
|
|
get duration() {
|
|
var total = 0
|
|
this.tracks.forEach((track) => total += track.duration)
|
|
return total
|
|
}
|
|
get size() {
|
|
var total = 0
|
|
this.audioFiles.forEach((af) => total += af.metadata.size)
|
|
return total
|
|
}
|
|
get hasMediaFiles() {
|
|
return !!(this.tracks.length + this.ebookFiles.length)
|
|
}
|
|
get shouldSearchForCover() {
|
|
if (this.coverPath) return false
|
|
if (!this.lastCoverSearch || this.metadata.coverSearchQuery !== this.lastCoverSearchQuery) return true
|
|
return (Date.now() - this.lastCoverSearch) > 1000 * 60 * 60 * 24 * 7 // 7 day
|
|
}
|
|
get hasEmbeddedCoverArt() {
|
|
return this.audioFiles.some(af => af.embeddedCoverArt)
|
|
}
|
|
|
|
update(payload) {
|
|
var json = this.toJSON()
|
|
var hasUpdates = false
|
|
for (const key in json) {
|
|
if (payload[key] !== undefined) {
|
|
if (key === 'metadata') {
|
|
if (this.metadata.update(payload.metadata)) {
|
|
hasUpdates = true
|
|
}
|
|
} else if (!areEquivalent(payload[key], json[key])) {
|
|
this[key] = copyValue(payload[key])
|
|
Logger.debug('[Book] Key updated', key, this[key])
|
|
hasUpdates = true
|
|
}
|
|
}
|
|
}
|
|
return hasUpdates
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
updateCover(coverPath) {
|
|
coverPath = coverPath.replace(/\\/g, '/')
|
|
if (this.coverPath === coverPath) return false
|
|
this.coverPath = coverPath
|
|
return true
|
|
}
|
|
|
|
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(`[Book] "${this.metadata.title}" has ${missingParts.length} missing parts`)
|
|
}
|
|
|
|
return wasUpdated
|
|
}
|
|
|
|
removeFileWithInode(inode) {
|
|
if (this.audioFiles.some(af => af.ino === inode)) {
|
|
this.audioFiles = this.audioFiles.filter(af => af.ino !== inode)
|
|
return true
|
|
}
|
|
if (this.ebookFiles.some(ef => ef.ino === inode)) {
|
|
this.ebookFiles = this.ebookFiles.filter(ef => ef.ino !== inode)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
findFileWithInode(inode) {
|
|
var audioFile = this.audioFiles.find(af => af.ino == inode)
|
|
if (audioFile) return audioFile
|
|
var ebookFile = this.ebookFiles.find(ef => ef.inode == inode)
|
|
if (ebookFile) return ebookFile
|
|
return null
|
|
}
|
|
|
|
updateLastCoverSearch(coverWasFound) {
|
|
this.lastCoverSearch = coverWasFound ? null : Date.now()
|
|
this.lastCoverSearchQuery = coverWasFound ? null : this.metadata.coverSearchQuery
|
|
}
|
|
|
|
// Audio file metadata tags map to book details (will not overwrite)
|
|
setMetadataFromAudioFile(overrideExistingDetails = false) {
|
|
if (!this.audioFiles.length) return false
|
|
var audioFile = this.audioFiles[0]
|
|
if (!audioFile.metaTags) return false
|
|
return this.metadata.setDataFromAudioMetaTags(audioFile.metaTags, overrideExistingDetails)
|
|
}
|
|
|
|
rebuildTracks() {
|
|
this.audioFiles.sort((a, b) => a.index - b.index)
|
|
this.missingParts = []
|
|
this.setChapters()
|
|
this.checkUpdateMissingTracks()
|
|
}
|
|
|
|
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.metadata.filename ? Path.basename(file.metadata.filename, Path.extname(file.metadata.filename)) : `Chapter ${currChapterId}`
|
|
})
|
|
currStartTime += file.duration
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
setData(scanMediaMetadata) {
|
|
this.metadata = new BookMetadata()
|
|
this.metadata.setData(scanMediaMetadata)
|
|
}
|
|
|
|
// Look for desc.txt, reader.txt, metadata.abs and opf file then update details if found
|
|
async syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) {
|
|
var metadataUpdatePayload = {}
|
|
|
|
var descTxt = textMetadataFiles.find(lf => lf.metadata.filename === 'desc.txt')
|
|
if (descTxt) {
|
|
var descriptionText = await readTextFile(descTxt.metadata.path)
|
|
if (descriptionText) {
|
|
Logger.debug(`[Book] "${this.metadata.title}" found desc.txt updating description with "${descriptionText.slice(0, 20)}..."`)
|
|
metadataUpdatePayload.description = descriptionText
|
|
}
|
|
}
|
|
var readerTxt = textMetadataFiles.find(lf => lf.metadata.filename === 'reader.txt')
|
|
if (readerTxt) {
|
|
var narratorText = await readTextFile(readerTxt.metadata.path)
|
|
if (narratorText) {
|
|
Logger.debug(`[Book] "${this.metadata.title}" found reader.txt updating narrator with "${narratorText}"`)
|
|
metadataUpdatePayload.narrators = this.metadata.parseNarratorsTag(narratorText)
|
|
}
|
|
}
|
|
|
|
// TODO: Implement metadata.abs
|
|
var metadataAbs = textMetadataFiles.find(lf => lf.metadata.filename === 'metadata.abs')
|
|
if (metadataAbs) {
|
|
|
|
}
|
|
|
|
var metadataOpf = textMetadataFiles.find(lf => lf.isOPFFile || lf.metadata.filename === 'metadata.xml')
|
|
if (metadataOpf) {
|
|
var xmlText = await readTextFile(metadataOpf.metadata.path)
|
|
if (xmlText) {
|
|
var opfMetadata = await parseOpfMetadataXML(xmlText)
|
|
if (opfMetadata) {
|
|
for (const key in opfMetadata) {
|
|
// Add genres only if genres are empty
|
|
if (key === 'genres') {
|
|
if (opfMetadata.genres.length && (!this.metadata.genres.length || opfMetadataOverrideDetails)) {
|
|
metadataUpdatePayload[key] = opfMetadata.genres
|
|
}
|
|
} else if (key === 'author') {
|
|
if (opfMetadata.author && (!this.metadata.authors.length || opfMetadataOverrideDetails)) {
|
|
metadataUpdatePayload.authors = this.metadata.parseAuthorsTag(opfMetadata.author)
|
|
}
|
|
} else if (key === 'narrator') {
|
|
if (opfMetadata.narrator && (!this.metadata.narrators.length || opfMetadataOverrideDetails)) {
|
|
metadataUpdatePayload.narrators = this.metadata.parseNarratorsTag(opfMetadata.narrator)
|
|
}
|
|
} else if (key === 'series') {
|
|
if (opfMetadata.series && (!this.metadata.series.length || opfMetadataOverrideDetails)) {
|
|
metadataUpdatePayload.series = this.metadata.parseSeriesTag(opfMetadata.series, opfMetadata.sequence)
|
|
}
|
|
} else if (opfMetadata[key] && ((!this.metadata[key] && !metadataUpdatePayload[key]) || opfMetadataOverrideDetails)) {
|
|
metadataUpdatePayload[key] = opfMetadata[key]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Object.keys(metadataUpdatePayload).length) {
|
|
return this.metadata.update(metadataUpdatePayload)
|
|
}
|
|
return false
|
|
}
|
|
|
|
searchQuery(query) {
|
|
var payload = {
|
|
tags: this.tags.filter(t => t.toLowerCase().includes(query)),
|
|
series: this.metadata.searchSeries(query),
|
|
authors: this.metadata.searchAuthors(query),
|
|
matchKey: null,
|
|
matchText: null
|
|
}
|
|
var metadataMatch = this.metadata.searchQuery(query)
|
|
if (metadataMatch) {
|
|
payload.matchKey = metadataMatch.matchKey
|
|
payload.matchText = metadataMatch.matchText
|
|
} else {
|
|
if (payload.authors.length) {
|
|
payload.matchKey = 'authors'
|
|
payload.matchText = this.metadata.authorName
|
|
} else if (payload.series.length) {
|
|
payload.matchKey = 'series'
|
|
payload.matchText = this.metadata.seriesName
|
|
}
|
|
else if (payload.tags.length) {
|
|
payload.matchKey = 'tags'
|
|
payload.matchText = this.tags.join(', ')
|
|
}
|
|
}
|
|
return payload
|
|
}
|
|
|
|
addEbookFile(libraryFile) {
|
|
var newEbook = new EBookFile()
|
|
newEbook.setData(libraryFile)
|
|
this.ebookFiles.push(newEbook)
|
|
}
|
|
}
|
|
module.exports = Book |