2026-02-07 16:45:40 +01:00
|
|
|
const { parseDaisyMetadata } = require('../utils/parsers/parseDaisyMetadata')
|
|
|
|
|
const { readTextFile } = require('../utils/fileUtils')
|
|
|
|
|
const Path = require('path')
|
|
|
|
|
|
|
|
|
|
class DaisyFileScanner {
|
|
|
|
|
constructor() {}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Parse metadata from DAISY ncc.html file found in library scan and update bookMetadata
|
|
|
|
|
*
|
|
|
|
|
* @param {import('../models/LibraryItem').LibraryFileObject} daisyLibraryFileObj
|
|
|
|
|
* @param {Object} bookMetadata
|
|
|
|
|
*/
|
|
|
|
|
async scanBookDaisyFile(daisyLibraryFileObj, bookMetadata, audioFiles = []) {
|
2026-02-08 03:33:56 +01:00
|
|
|
const htmlText = await readTextFile(daisyLibraryFileObj.metadata.path)
|
2026-02-07 16:45:40 +01:00
|
|
|
const daisyMetadata = htmlText ? parseDaisyMetadata(htmlText) : null
|
|
|
|
|
if (daisyMetadata) {
|
|
|
|
|
for (const key in daisyMetadata) {
|
|
|
|
|
if (key === 'tags') {
|
|
|
|
|
if (daisyMetadata.tags.length) {
|
|
|
|
|
bookMetadata.tags = daisyMetadata.tags
|
|
|
|
|
}
|
|
|
|
|
} else if (key === 'genres') {
|
|
|
|
|
if (daisyMetadata.genres.length) {
|
|
|
|
|
bookMetadata.genres = daisyMetadata.genres
|
|
|
|
|
}
|
|
|
|
|
} else if (key === 'authors') {
|
|
|
|
|
if (daisyMetadata.authors?.length) {
|
|
|
|
|
bookMetadata.authors = daisyMetadata.authors
|
|
|
|
|
}
|
|
|
|
|
} else if (key === 'narrators') {
|
|
|
|
|
if (daisyMetadata.narrators?.length) {
|
|
|
|
|
bookMetadata.narrators = daisyMetadata.narrators
|
|
|
|
|
}
|
|
|
|
|
} else if (key === 'chapters') {
|
|
|
|
|
if (!daisyMetadata.chapters?.length) continue
|
|
|
|
|
|
|
|
|
|
// DAISY ncc.html provides chapter names; preserve existing timings if available.
|
|
|
|
|
if (bookMetadata.chapters?.length) {
|
|
|
|
|
const updatedChapters = bookMetadata.chapters.map((chapter, index) => {
|
|
|
|
|
const daisyChapter = daisyMetadata.chapters[index]
|
|
|
|
|
if (!daisyChapter?.title) return chapter
|
|
|
|
|
return {
|
|
|
|
|
...chapter,
|
|
|
|
|
id: chapter.id ?? index,
|
|
|
|
|
title: daisyChapter.title
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
bookMetadata.chapters = updatedChapters
|
|
|
|
|
} else {
|
|
|
|
|
const chaptersFromFiles = this.buildChaptersFromAudioFiles(audioFiles, daisyMetadata.chapters)
|
|
|
|
|
if (chaptersFromFiles.length) {
|
|
|
|
|
bookMetadata.chapters = chaptersFromFiles
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if (daisyMetadata[key]) {
|
|
|
|
|
bookMetadata[key] = daisyMetadata[key]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build chapter timings from ordered audio files while applying DAISY chapter titles.
|
|
|
|
|
* Falls back to file basenames if DAISY has fewer titles than files.
|
|
|
|
|
*
|
|
|
|
|
* @param {import('../models/Book').AudioFileObject[]} audioFiles
|
|
|
|
|
* @param {{title:string}[]} daisyChapters
|
|
|
|
|
* @returns {import('../models/Book').ChapterObject[]}
|
|
|
|
|
*/
|
|
|
|
|
buildChaptersFromAudioFiles(audioFiles, daisyChapters) {
|
|
|
|
|
if (!audioFiles?.length) return []
|
|
|
|
|
|
|
|
|
|
const chapters = []
|
|
|
|
|
let currentStartTime = 0
|
|
|
|
|
let chapterId = 0
|
|
|
|
|
|
|
|
|
|
audioFiles.forEach((audioFile) => {
|
|
|
|
|
if (!audioFile.duration) return
|
|
|
|
|
|
|
|
|
|
const fallbackTitle = audioFile.metadata?.filename
|
|
|
|
|
? Path.basename(audioFile.metadata.filename, Path.extname(audioFile.metadata.filename))
|
|
|
|
|
: `Chapter ${chapterId + 1}`
|
|
|
|
|
const title = daisyChapters[chapterId]?.title || fallbackTitle
|
|
|
|
|
|
|
|
|
|
chapters.push({
|
|
|
|
|
id: chapterId++,
|
|
|
|
|
start: currentStartTime,
|
|
|
|
|
end: currentStartTime + audioFile.duration,
|
|
|
|
|
title
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
currentStartTime += audioFile.duration
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return chapters
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
module.exports = new DaisyFileScanner()
|