New data model save covers, scanner, new api routes

This commit is contained in:
advplyr 2022-03-12 17:45:32 -06:00
parent 5f4e5cd3d8
commit 73257188f6
37 changed files with 1649 additions and 672 deletions

View file

@ -1,3 +1,4 @@
const { version } = require('../../package.json')
const Logger = require('../Logger')
const LibraryFile = require('./files/LibraryFile')
const Book = require('./entities/Book')
@ -22,8 +23,10 @@ class LibraryItem {
this.lastScan = null
this.scanVersion = null
// Entity was scanned and not found
// 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
@ -51,6 +54,7 @@ class LibraryItem {
this.scanVersion = libraryItem.scanVersion || null
this.isMissing = !!libraryItem.isMissing
this.isInvalid = !!libraryItem.isInvalid
this.mediaType = libraryItem.mediaType
if (this.mediaType === 'book') {
@ -78,6 +82,7 @@ class LibraryItem {
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())
@ -98,6 +103,7 @@ class LibraryItem {
addedAt: this.addedAt,
updatedAt: this.updatedAt,
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toJSONMinified(),
numFiles: this.libraryFiles.length
@ -121,6 +127,7 @@ class LibraryItem {
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()),
@ -133,6 +140,42 @@ class LibraryItem {
this.libraryFiles.forEach((lf) => total += lf.metadata.size)
return total
}
get hasAudioFiles() {
return this.libraryFiles.some(lf => lf.fileType === 'audio')
}
get hasMediaFiles() {
return this.media.hasMediaFiles
}
// Data comes from scandir library item data
setData(libraryMediaType, payload) {
if (libraryMediaType === 'podcast') {
this.mediaType = 'podcast'
this.media = new Podcast()
} else {
this.mediaType = 'book'
this.media = new Book()
}
for (const key in payload) {
if (key === 'libraryFiles') {
this.libraryFiles = payload.libraryFiles.map(lf => lf.clone())
// Use first image library file as cover
var firstImageFile = this.libraryFiles.find(lf => lf.fileType === 'image')
if (firstImageFile) this.media.coverPath = firstImageFile.metadata.path
} else if (this[key] !== undefined) {
this[key] = payload[key]
}
}
if (payload.mediaMetadata) {
this.media.setData(payload.mediaMetadata)
}
this.addedAt = Date.now()
this.updatedAt = Date.now()
}
update(payload) {
var json = this.toJSON()
@ -149,7 +192,214 @@ class LibraryItem {
}
}
}
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.scanVersion = version
}
saveMetadata() { }
// Returns null if file not found, true if file was updated, false if up to date
checkFileFound(fileFound) {
var hasUpdated = false
var existingFile = this.libraryFiles.find(lf => lf.ino === fileFound.ino)
var 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
}
var keysToCheck = ['filename', 'ext', 'mtimeMs', 'ctimeMs', 'birthtimeMs', 'size']
keysToCheck.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
}
// Data pulled from scandir during a scan, check it with current data
checkScanData(dataFound) {
var hasUpdated = false
if (this.isMissing) {
// Item 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(`[LibraryItem] Check scan item changed folder ${this.folderId} -> ${dataFound.folderId}`)
this.folderId = dataFound.folderId
hasUpdated = true
}
if (dataFound.path !== this.path) {
Logger.warn(`[LibraryItem] Check scan item changed path "${this.path}" -> "${dataFound.path}"`)
this.path = dataFound.path
this.relPath = dataFound.relPath
hasUpdated = true
}
var keysToCheck = ['mtimeMs', 'ctimeMs', 'birthtimeMs']
keysToCheck.forEach((key) => {
if (dataFound[key] != this[key]) {
this[key] = dataFound[key] || 0
hasUpdated = true
}
})
var newLibraryFiles = []
var existingLibraryFiles = []
dataFound.libraryFiles.forEach((lf) => {
var fileFoundCheck = this.checkFileFound(lf, true)
console.log('Check library file', fileFoundCheck, lf.metadata.filename)
if (fileFoundCheck === null) {
newLibraryFiles.push(lf)
} else if (fileFoundCheck) {
hasUpdated = true
existingLibraryFiles.push(lf)
} else {
existingLibraryFiles.push(lf)
}
})
const filesRemoved = []
// Remove files not found (inodes will all be up to date at this point)
this.libraryFiles = this.libraryFiles.filter(lf => {
if (!dataFound.libraryFiles.find(_lf => _lf.ino === lf.ino)) {
if (lf.metadata.path === this.media.coverPath) {
Logger.debug(`[LibraryItem] "${this.media.metadata.title}" check scan cover removed`)
this.media.updateCover('')
}
filesRemoved.push(lf.toJSON())
this.media.removeFileWithInode(lf.ino)
return false
}
return true
})
if (filesRemoved.length) {
this.media.checkUpdateMissingTracks()
hasUpdated = true
}
// Add library files to library item
if (newLibraryFiles.length) {
newLibraryFiles.forEach((lf) => this.libraryFiles.push(lf.clone()))
hasUpdated = true
}
// Check if invalid
this.isInvalid = !this.media.hasMediaFiles
// If cover path is in item folder, make sure libraryFile exists for it
if (this.media.coverPath && this.media.coverPath.startsWith(this.path)) {
var lf = this.libraryFiles.find(lf => lf.metadata.path === this.media.coverPath)
if (!lf) {
Logger.warn(`[LibraryItem] Invalid cover path - library file dne "${this.media.coverPath}"`)
this.media.updateCover('')
hasUpdated = true
}
}
if (hasUpdated) {
this.setLastScan()
}
return {
updated: hasUpdated,
newLibraryFiles,
filesRemoved,
existingLibraryFiles // Existing file data may get re-scanned if forceRescan is set
}
}
// Set metadata from files
async syncFiles(preferOpfMetadata) {
var imageFiles = this.libraryFiles.filter(lf => lf.fileType === 'image')
console.log('image files', imageFiles.length, 'has cover', this.media.coverPath)
if (imageFiles.length && !this.media.coverPath) {
this.media.coverPath = imageFiles[0].metadata.path
Logger.debug('[LibraryItem] Set media cover path', this.media.coverPath)
}
var textMetadataFiles = this.libraryFiles.filter(lf => lf.fileType === 'metadata' || lf.fileType === 'text')
if (!textMetadataFiles.length) {
return false
}
var hasUpdated = await this.media.syncMetadataFiles(textMetadataFiles, preferOpfMetadata)
if (hasUpdated) {
this.updatedAt = Date.now()
}
return hasUpdated
}
}
module.exports = LibraryItem

View file

@ -53,5 +53,10 @@ class Author {
this.addedAt = Date.now()
this.updatedAt = Date.now()
}
checkNameEquals(name) {
if (!name) return false
return this.name.toLowerCase() == name.toLowerCase().trim()
}
}
module.exports = Author

View file

@ -2,7 +2,10 @@ 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) {
@ -13,6 +16,10 @@ class Book {
this.audioFiles = []
this.ebookFiles = []
this.chapters = []
this.missingParts = []
this.lastCoverSearch = null
this.lastCoverSearchQuery = null
if (book) {
this.construct(book)
@ -26,6 +33,9 @@ class Book {
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() {
@ -35,7 +45,8 @@ class Book {
tags: [...this.tags],
audioFiles: this.audioFiles.map(f => f.toJSON()),
ebookFiles: this.ebookFiles.map(f => f.toJSON()),
chapters: this.chapters.map(c => ({ ...c }))
chapters: this.chapters.map(c => ({ ...c })),
missingParts: [...this.missingParts]
}
}
@ -48,6 +59,7 @@ class Book {
numAudioFiles: this.audioFiles.length,
numEbooks: this.ebookFiles.length,
numChapters: this.chapters.length,
numMissingParts: this.missingParts.length,
duration: this.duration,
size: this.size
}
@ -63,7 +75,8 @@ class Book {
chapters: this.chapters.map(c => ({ ...c })),
duration: this.duration,
size: this.size,
tracks: this.tracks.map(t => t.toJSON())
tracks: this.tracks.map(t => t.toJSON()),
missingParts: [...this.missingParts]
}
}
@ -80,6 +93,17 @@ class Book {
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()
@ -99,5 +123,195 @@ class Book {
}
return hasUpdates
}
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
}
}
module.exports = Book

View file

@ -1,5 +1,6 @@
const PodcastEpisode = require('./PodcastEpisode')
const PodcastMetadata = require('../metadata/PodcastMetadata')
const { areEquivalent, copyValue } = require('../../utils/index')
class Podcast {
constructor(podcast) {
@ -10,8 +11,8 @@ class Podcast {
this.tags = []
this.episodes = []
this.createdAt = null
this.lastUpdate = null
this.lastCoverSearch = null
this.lastCoverSearchQuery = null
if (podcast) {
this.construct(podcast)
@ -24,8 +25,6 @@ class Podcast {
this.coverPath = podcast.coverPath
this.tags = [...podcast.tags]
this.episodes = podcast.episodes.map((e) => new PodcastEpisode(e))
this.createdAt = podcast.createdAt
this.lastUpdate = podcast.lastUpdate
}
toJSON() {
@ -35,8 +34,6 @@ class Podcast {
coverPath: this.coverPath,
tags: [...this.tags],
episodes: this.episodes.map(e => e.toJSON()),
createdAt: this.createdAt,
lastUpdate: this.lastUpdate
}
}
@ -47,8 +44,7 @@ class Podcast {
coverPath: this.coverPath,
tags: [...this.tags],
episodes: this.episodes.map(e => e.toJSON()),
createdAt: this.createdAt,
lastUpdate: this.lastUpdate
}
}
@ -59,9 +55,74 @@ class Podcast {
coverPath: this.coverPath,
tags: [...this.tags],
episodes: this.episodes.map(e => e.toJSON()),
createdAt: this.createdAt,
lastUpdate: this.lastUpdate
}
}
get tracks() {
return []
}
get duration() {
return 0
}
get size() {
return 0
}
get hasMediaFiles() {
return !!this.episodes.length
}
get shouldSearchForCover() {
return false
}
get hasEmbeddedCoverArt() {
return false
}
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('[Podcast] Key updated', key, this[key])
hasUpdates = true
}
}
}
return hasUpdates
}
updateCover(coverPath) {
coverPath = coverPath.replace(/\\/g, '/')
if (this.coverPath === coverPath) return false
this.coverPath = coverPath
return true
}
checkUpdateMissingTracks() {
return false
}
removeFileWithInode(inode) {
return false
}
findFileWithInode(inode) {
return null
}
setData(scanMediaMetadata) {
this.metadata = new PodcastMetadata()
this.metadata.setData(scanMediaMetadata)
}
async syncMetadataFiles(textMetadataFiles, opfMetadataOverrideDetails) {
return false
}
}
module.exports = Podcast

View file

@ -42,5 +42,10 @@ class Series {
this.addedAt = Date.now()
this.updatedAt = Date.now()
}
checkNameEquals(name) {
if (!name) return false
return this.name.toLowerCase() == name.toLowerCase().trim()
}
}
module.exports = Series

View file

@ -72,6 +72,9 @@ class AudioFile {
this.index = data.index
this.ino = data.ino
this.metadata = new FileMetadata(data.metadata || {})
if (!this.metadata.toJSON) {
console.error('No metadata tojosnm\n\n\n\n\n\n', this)
}
this.addedAt = data.addedAt
this.updatedAt = data.updatedAt
this.manuallyVerified = !!data.manuallyVerified
@ -101,19 +104,13 @@ class AudioFile {
}
// New scanner creates AudioFile from AudioFileScanner
setDataFromProbe(fileData, probeData) {
this.index = fileData.index || null
this.ino = fileData.ino || null
setDataFromProbe(libraryFile, probeData) {
this.ino = libraryFile.ino || null
// TODO: Update file metadata for set data from probe
this.metadata = libraryFile.metadata.clone()
this.addedAt = Date.now()
this.updatedAt = Date.now()
this.trackNumFromMeta = fileData.trackNumFromMeta
this.discNumFromMeta = fileData.discNumFromMeta
this.trackNumFromFilename = fileData.trackNumFromFilename
this.discNumFromFilename = fileData.discNumFromFilename
this.format = probeData.format
this.duration = probeData.duration
this.bitRate = probeData.bitRate || null
@ -196,9 +193,13 @@ class AudioFile {
newjson.addedAt = this.addedAt
for (const key in newjson) {
if (key === 'metaTags') {
if (!this.metaTags || !this.metaTags.isEqual(scannedAudioFile.metadata)) {
this.metaTags = scannedAudioFile.metadata
if (key === 'metadata') {
if (this.metadata.update(newjson[key])) {
hasUpdated = true
}
} else if (key === 'metaTags') {
if (!this.metaTags || !this.metaTags.isEqual(scannedAudioFile.metaTags)) {
this.metaTags = scannedAudioFile.metaTags.clone()
hasUpdated = true
}
} else if (key === 'chapters') {
@ -206,7 +207,6 @@ class AudioFile {
hasUpdated = true
}
} else if (this[key] !== newjson[key]) {
// console.log(this.filename, 'key', key, 'updated', this[key], newjson[key])
this[key] = newjson[key]
hasUpdated = true
}

View file

@ -1,3 +1,5 @@
const Path = require('path')
const { getFileTimestampsWithIno } = require('../../utils/fileUtils')
const globals = require('../../utils/globals')
const FileMetadata = require('../metadata/FileMetadata')
@ -30,6 +32,10 @@ class LibraryFile {
}
}
clone() {
return new LibraryFile(this.toJSON())
}
get fileType() {
if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image'
if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio'
@ -38,5 +44,27 @@ class LibraryFile {
if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata'
return 'unknown'
}
get isMediaFile() {
return this.fileType === 'audio' || this.fileType === 'ebook'
}
get isOPFFile() {
return this.metadata.ext === '.opf'
}
async setDataFromPath(path, relPath) {
var fileTsData = await getFileTimestampsWithIno(path)
var fileMetadata = new FileMetadata()
fileMetadata.setData(fileTsData)
fileMetadata.filename = Path.basename(relPath)
fileMetadata.path = path
fileMetadata.relPath = relPath
fileMetadata.ext = Path.extname(relPath)
this.ino = fileTsData.ino
this.metadata = fileMetadata
this.addedAt = Date.now()
this.updatedAt = Date.now()
}
}
module.exports = LibraryFile

View file

@ -1,6 +1,6 @@
const Path = require('path')
const Logger = require('../../Logger')
const parseAuthors = require('../../utils/parseAuthors')
const parseAuthors = require('../../utils/parseNameString')
class Book {
constructor(book = null) {

View file

@ -118,6 +118,10 @@ class AudioMetaTags {
return hasUpdates
}
clone() {
return new AudioMetaTags(this.toJSON())
}
isEqual(audioFileMetadata) {
if (!audioFileMetadata || !audioFileMetadata.toJSON) return false
for (const key in audioFileMetadata.toJSON()) {

View file

@ -1,6 +1,6 @@
const Logger = require('../../Logger')
const { areEquivalent, copyValue } = require('../../utils/index')
const parseNameString = require('../../utils/parseNameString')
class BookMetadata {
constructor(metadata) {
this.title = null
@ -88,11 +88,16 @@ class BookMetadata {
return this.title
}
get authorName() {
if (!this.authors.length) return ''
return this.authors.map(au => au.name).join(', ')
}
get narratorName() {
return this.narrators.join(', ')
}
get coverSearchQuery() {
if (!this.authorName) return this.title
return this.title + '&' + this.authorName
}
hasAuthor(authorName) {
return !!this.authors.find(au => au.name == authorName)
@ -118,5 +123,150 @@ class BookMetadata {
}
return hasUpdates
}
setData(scanMediaData = {}) {
this.title = scanMediaData.title || null
this.subtitle = scanMediaData.subtitle || null
this.narrators = []
this.publishYear = scanMediaData.publishYear || null
this.description = scanMediaData.description || null
this.isbn = scanMediaData.isbn || null
this.asin = scanMediaData.asin || null
this.language = scanMediaData.language || null
this.genres = []
if (scanMediaData.author) {
this.authors = this.parseAuthorsTag(scanMediaData.author)
}
if (scanMediaData.series) {
this.series = this.parseSeriesTag(scanMediaData.series, scanMediaData.sequence)
}
}
setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) {
const MetadataMapArray = [
{
tag: 'tagComposer',
key: 'narrators'
},
{
tag: 'tagDescription',
key: 'description'
},
{
tag: 'tagPublisher',
key: 'publisher'
},
{
tag: 'tagDate',
key: 'publishYear'
},
{
tag: 'tagSubtitle',
key: 'subtitle'
},
{
tag: 'tagAlbum',
altTag: 'tagTitle',
key: 'title',
},
{
tag: 'tagArtist',
altTag: 'tagAlbumArtist',
key: 'authors'
},
{
tag: 'tagGenre',
key: 'genres'
},
{
tag: 'tagSeries',
key: 'series'
},
{
tag: 'tagIsbn',
key: 'isbn'
},
{
tag: 'tagLanguage',
key: 'language'
},
{
tag: 'tagASIN',
key: 'asin'
}
]
var updatePayload = {}
// Metadata is only mapped to the book if it is empty
MetadataMapArray.forEach((mapping) => {
var value = audioFileMetaTags[mapping.tag]
var tagToUse = mapping.tag
if (!value && mapping.altTag) {
value = audioFileMetaTags[mapping.altTag]
tagToUse = mapping.altTag
}
if (value) {
if (mapping.key === 'narrators' && (!this.narrators.length || overrideExistingDetails)) {
updatePayload.narrators = this.parseNarratorsTag(value)
} else if (mapping.key === 'authors' && (!this.authors.length || overrideExistingDetails)) {
updatePayload.authors = this.parseAuthorsTag(value)
} else if (mapping.key === 'genres' && (!this.genres.length || overrideExistingDetails)) {
updatePayload.genres = this.parseGenresTag(value)
} else if (mapping.key === 'series' && (!this.series.length || overrideExistingDetails)) {
var sequenceTag = audioFileMetaTags.tagSeriesPart || null
updatePayload.series = this.parseSeriesTag(value, sequenceTag)
} else if (!this[mapping.key] || overrideExistingDetails) {
updatePayload[mapping.key] = value
// Logger.debug(`[Book] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload[mapping.key]}`)
}
}
})
if (Object.keys(updatePayload).length) {
return this.update(updatePayload)
}
return false
}
// Returns array of names in First Last format
parseNarratorsTag(narratorsTag) {
var parsed = parseNameString(narratorsTag)
return parsed ? parsed.names : []
}
// Return array of authors minified with placeholder id
parseAuthorsTag(authorsTag) {
var parsed = parseNameString(authorsTag)
if (!parsed) return []
return parsed.map((au) => {
return {
id: `new-${Math.floor(Math.random() * 1000000)}`,
name: au
}
})
}
parseGenresTag(genreTag) {
if (!genreTag || !genreTag.length) return []
var separators = ['/', '//', ';']
for (let i = 0; i < separators.length; i++) {
if (genreTag.includes(separators[i])) {
return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g)
}
}
return [genreTag]
}
// Return array with series with placeholder id
parseSeriesTag(seriesTag, sequenceTag) {
if (!seriesTag) return []
return [{
id: `new-${Math.floor(Math.random() * 1000000)}`,
name: seriesTag,
sequence: sequenceTag || ''
}]
}
}
module.exports = BookMetadata

View file

@ -12,6 +12,9 @@ class FileMetadata {
if (metadata) {
this.construct(metadata)
}
// Temp flag used in scans
this.wasModified = false
}
construct(metadata) {
@ -46,5 +49,24 @@ class FileMetadata {
if (!this.ext) return ''
return this.ext.slice(1)
}
update(payload) {
var hasUpdates = false
for (const key in payload) {
if (this[key] !== undefined && this[key] !== payload[key]) {
this[key] = payload[key]
hasUpdates = true
}
}
return hasUpdates
}
setData(payload) {
for (const key in payload) {
if (this[key] !== undefined) {
this[key] = payload[key]
}
}
}
}
module.exports = FileMetadata