mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-03-24 00:16:39 +01:00
Update new library scanner for scanning in new books
This commit is contained in:
parent
75276f5a44
commit
0ecfdab463
@ -551,16 +551,35 @@ class Database {
|
|||||||
return this.models.device.createFromOld(oldDevice)
|
return this.models.device.createFromOld(oldDevice)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replaceTagInFilterData(oldTag, newTag) {
|
||||||
|
for (const libraryId in this.libraryFilterData) {
|
||||||
|
const indexOf = this.libraryFilterData[libraryId].tags.findIndex(n => n === oldTag)
|
||||||
|
if (indexOf >= 0) {
|
||||||
|
this.libraryFilterData[libraryId].tags.splice(indexOf, 1, newTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
removeTagFromFilterData(tag) {
|
removeTagFromFilterData(tag) {
|
||||||
for (const libraryId in this.libraryFilterData) {
|
for (const libraryId in this.libraryFilterData) {
|
||||||
this.libraryFilterData[libraryId].tags = this.libraryFilterData[libraryId].tags.filter(t => t !== tag)
|
this.libraryFilterData[libraryId].tags = this.libraryFilterData[libraryId].tags.filter(t => t !== tag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addTagToFilterData(tag) {
|
addTagsToFilterData(libraryId, tags) {
|
||||||
|
if (!this.libraryFilterData[libraryId] || !tags?.length) return
|
||||||
|
tags.forEach((t) => {
|
||||||
|
if (!this.libraryFilterData[libraryId].tags.includes(t)) {
|
||||||
|
this.libraryFilterData[libraryId].tags.push(t)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceGenreInFilterData(oldGenre, newGenre) {
|
||||||
for (const libraryId in this.libraryFilterData) {
|
for (const libraryId in this.libraryFilterData) {
|
||||||
if (!this.libraryFilterData[libraryId].tags.includes(tag)) {
|
const indexOf = this.libraryFilterData[libraryId].genres.findIndex(n => n === oldGenre)
|
||||||
this.libraryFilterData[libraryId].tags.push(tag)
|
if (indexOf >= 0) {
|
||||||
|
this.libraryFilterData[libraryId].genres.splice(indexOf, 1, newGenre)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -571,10 +590,20 @@ class Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addGenreToFilterData(genre) {
|
addGenresToFilterData(libraryId, genres) {
|
||||||
|
if (!this.libraryFilterData[libraryId] || !genres?.length) return
|
||||||
|
genres.forEach((g) => {
|
||||||
|
if (!this.libraryFilterData[libraryId].genres.includes(g)) {
|
||||||
|
this.libraryFilterData[libraryId].genres.push(g)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceNarratorInFilterData(oldNarrator, newNarrator) {
|
||||||
for (const libraryId in this.libraryFilterData) {
|
for (const libraryId in this.libraryFilterData) {
|
||||||
if (!this.libraryFilterData[libraryId].genres.includes(genre)) {
|
const indexOf = this.libraryFilterData[libraryId].narrators.findIndex(n => n === oldNarrator)
|
||||||
this.libraryFilterData[libraryId].genres.push(genre)
|
if (indexOf >= 0) {
|
||||||
|
this.libraryFilterData[libraryId].narrators.splice(indexOf, 1, newNarrator)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -585,12 +614,13 @@ class Database {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addNarratorToFilterData(narrator) {
|
addNarratorsToFilterData(libraryId, narrators) {
|
||||||
for (const libraryId in this.libraryFilterData) {
|
if (!this.libraryFilterData[libraryId] || !narrators?.length) return
|
||||||
if (!this.libraryFilterData[libraryId].narrators.includes(narrator)) {
|
narrators.forEach((n) => {
|
||||||
this.libraryFilterData[libraryId].narrators.push(narrator)
|
if (!this.libraryFilterData[libraryId].narrators.includes(n)) {
|
||||||
}
|
this.libraryFilterData[libraryId].narrators.push(n)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
removeSeriesFromFilterData(libraryId, seriesId) {
|
removeSeriesFromFilterData(libraryId, seriesId) {
|
||||||
@ -623,6 +653,16 @@ class Database {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addPublisherToFilterData(libraryId, publisher) {
|
||||||
|
if (!this.libraryFilterData[libraryId] || !publisher || this.libraryFilterData[libraryId].publishers.includes(publisher)) return
|
||||||
|
this.libraryFilterData[libraryId].publishers.push(publisher)
|
||||||
|
}
|
||||||
|
|
||||||
|
addLanguageToFilterData(libraryId, language) {
|
||||||
|
if (!this.libraryFilterData[libraryId] || !language || this.libraryFilterData[libraryId].languages.includes(language)) return
|
||||||
|
this.libraryFilterData[libraryId].languages.push(language)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used when updating items to make sure author id exists
|
* Used when updating items to make sure author id exists
|
||||||
* If library filter data is set then use that for check
|
* If library filter data is set then use that for check
|
||||||
|
@ -889,8 +889,7 @@ class LibraryController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update filter data
|
// Update filter data
|
||||||
Database.removeNarratorFromFilterData(narratorName)
|
Database.replaceNarratorInFilterData(narratorName, updatedName)
|
||||||
Database.addNarratorToFilterData(updatedName)
|
|
||||||
|
|
||||||
const itemsUpdated = []
|
const itemsUpdated = []
|
||||||
|
|
||||||
|
@ -230,8 +230,7 @@ class MiscController {
|
|||||||
let numItemsUpdated = 0
|
let numItemsUpdated = 0
|
||||||
|
|
||||||
// Update filter data
|
// Update filter data
|
||||||
Database.removeTagFromFilterData(tag)
|
Database.replaceTagInFilterData(tag, newTag)
|
||||||
Database.addTagToFilterData(newTag)
|
|
||||||
|
|
||||||
const libraryItemsWithTag = await libraryItemFilters.getAllLibraryItemsWithTags([tag, newTag])
|
const libraryItemsWithTag = await libraryItemFilters.getAllLibraryItemsWithTags([tag, newTag])
|
||||||
for (const libraryItem of libraryItemsWithTag) {
|
for (const libraryItem of libraryItemsWithTag) {
|
||||||
@ -364,8 +363,7 @@ class MiscController {
|
|||||||
let numItemsUpdated = 0
|
let numItemsUpdated = 0
|
||||||
|
|
||||||
// Update filter data
|
// Update filter data
|
||||||
Database.removeGenreFromFilterData(genre)
|
Database.replaceGenreInFilterData(genre, newGenre)
|
||||||
Database.addGenreToFilterData(newGenre)
|
|
||||||
|
|
||||||
const libraryItemsWithGenre = await libraryItemFilters.getAllLibraryItemsWithGenres([genre, newGenre])
|
const libraryItemsWithGenre = await libraryItemFilters.getAllLibraryItemsWithGenres([genre, newGenre])
|
||||||
for (const libraryItem of libraryItemsWithGenre) {
|
for (const libraryItem of libraryItemsWithGenre) {
|
||||||
|
@ -270,5 +270,33 @@ class CoverManager {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static async saveEmbeddedCoverArtNew(audioFiles, libraryItemId, libraryItemPath) {
|
||||||
|
let audioFileWithCover = audioFiles.find(af => af.embeddedCoverArt)
|
||||||
|
if (!audioFileWithCover) return null
|
||||||
|
|
||||||
|
let coverDirPath = null
|
||||||
|
if (global.ServerSettings.storeCoverWithItem && libraryItemPath) {
|
||||||
|
coverDirPath = libraryItemPath
|
||||||
|
} else {
|
||||||
|
coverDirPath = Path.posix.join(this.ItemMetadataPath, libraryItemId)
|
||||||
|
}
|
||||||
|
await fs.ensureDir(coverDirPath)
|
||||||
|
|
||||||
|
const coverFilename = audioFileWithCover.embeddedCoverArt === 'png' ? 'cover.png' : 'cover.jpg'
|
||||||
|
const coverFilePath = Path.join(coverDirPath, coverFilename)
|
||||||
|
|
||||||
|
const coverAlreadyExists = await fs.pathExists(coverFilePath)
|
||||||
|
if (coverAlreadyExists) {
|
||||||
|
Logger.warn(`[CoverManager] Extract embedded cover art but cover already exists for "${libraryItemPath}" - bail`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await extractCoverArt(audioFileWithCover.metadata.path, coverFilePath)
|
||||||
|
if (success) {
|
||||||
|
return coverFilePath
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = CoverManager
|
module.exports = CoverManager
|
@ -330,10 +330,6 @@ class BookMetadata {
|
|||||||
{
|
{
|
||||||
tag: 'tagASIN',
|
tag: 'tagASIN',
|
||||||
key: 'asin'
|
key: 'asin'
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: 'tagOverdriveMediaMarker',
|
|
||||||
key: 'overdriveMediaMarker'
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -41,11 +41,13 @@ class AudioFileScanner {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Order audio files by track/disc number
|
* Order audio files by track/disc number
|
||||||
* @param {import('../models/Book')} book
|
* @param {string} libraryItemRelPath
|
||||||
* @param {import('../models/Book').AudioFileObject[]} audioFiles
|
* @param {import('../models/Book').AudioFileObject[]} audioFiles
|
||||||
* @returns {import('../models/Book').AudioFileObject[]}
|
* @returns {import('../models/Book').AudioFileObject[]}
|
||||||
*/
|
*/
|
||||||
runSmartTrackOrder(book, audioFiles) {
|
runSmartTrackOrder(libraryItemRelPath, audioFiles) {
|
||||||
|
if (!audioFiles.length) return []
|
||||||
|
|
||||||
let discsFromFilename = []
|
let discsFromFilename = []
|
||||||
let tracksFromFilename = []
|
let tracksFromFilename = []
|
||||||
let discsFromMeta = []
|
let discsFromMeta = []
|
||||||
@ -79,14 +81,14 @@ class AudioFileScanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (discKey !== null) {
|
if (discKey !== null) {
|
||||||
Logger.debug(`[AudioFileScanner] Smart track order for "${book.title}" using disc key ${discKey} and track key ${trackKey}`)
|
Logger.debug(`[AudioFileScanner] Smart track order for "${libraryItemRelPath}" using disc key ${discKey} and track key ${trackKey}`)
|
||||||
audioFiles.sort((a, b) => {
|
audioFiles.sort((a, b) => {
|
||||||
let Dx = a[discKey] - b[discKey]
|
let Dx = a[discKey] - b[discKey]
|
||||||
if (Dx === 0) Dx = a[trackKey] - b[trackKey]
|
if (Dx === 0) Dx = a[trackKey] - b[trackKey]
|
||||||
return Dx
|
return Dx
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
Logger.debug(`[AudioFileScanner] Smart track order for "${book.title}" using track key ${trackKey}`)
|
Logger.debug(`[AudioFileScanner] Smart track order for "${libraryItemRelPath}" using track key ${trackKey}`)
|
||||||
audioFiles.sort((a, b) => a[trackKey] - b[trackKey])
|
audioFiles.sort((a, b) => a[trackKey] - b[trackKey])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
485
server/scanner/BookScanner.js
Normal file
485
server/scanner/BookScanner.js
Normal file
@ -0,0 +1,485 @@
|
|||||||
|
const uuidv4 = require("uuid").v4
|
||||||
|
const { LogLevel } = require('../utils/constants')
|
||||||
|
const { getTitleIgnorePrefix } = require('../utils/index')
|
||||||
|
const { parseOpfMetadataXML } = require('../utils/parsers/parseOpfMetadata')
|
||||||
|
const { parseOverdriveMediaMarkersAsChapters } = require('../utils/parsers/parseOverdriveMediaMarkers')
|
||||||
|
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
|
||||||
|
const parseNameString = require('../utils/parsers/parseNameString')
|
||||||
|
const AudioFileScanner = require('./AudioFileScanner')
|
||||||
|
const Database = require('../Database')
|
||||||
|
const { readTextFile } = require('../utils/fileUtils')
|
||||||
|
const AudioFile = require('../objects/files/AudioFile')
|
||||||
|
const CoverManager = require('../managers/CoverManager')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for books pulled from files
|
||||||
|
* @typedef BookMetadataObject
|
||||||
|
* @property {string} title
|
||||||
|
* @property {string} titleIgnorePrefix
|
||||||
|
* @property {string} subtitle
|
||||||
|
* @property {string} publishedYear
|
||||||
|
* @property {string} publisher
|
||||||
|
* @property {string} description
|
||||||
|
* @property {string} isbn
|
||||||
|
* @property {string} asin
|
||||||
|
* @property {string} language
|
||||||
|
* @property {string[]} narrators
|
||||||
|
* @property {string[]} genres
|
||||||
|
* @property {string[]} tags
|
||||||
|
* @property {string[]} authors
|
||||||
|
* @property {{name:string, sequence:string}[]} series
|
||||||
|
* @property {{id:number, start:number, end:number, title:string}[]} chapters
|
||||||
|
* @property {boolean} explicit
|
||||||
|
* @property {boolean} abridged
|
||||||
|
* @property {string} coverPath
|
||||||
|
*/
|
||||||
|
|
||||||
|
class BookScanner {
|
||||||
|
constructor() { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||||
|
* @param {import('./LibraryScan')} libraryScan
|
||||||
|
* @returns {import('../models/LibraryItem')}
|
||||||
|
*/
|
||||||
|
async scanNewBookLibraryItem(libraryItemData, libraryScan) {
|
||||||
|
// Scan audio files found
|
||||||
|
let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(libraryScan.libraryMediaType, libraryItemData, libraryItemData.audioLibraryFiles)
|
||||||
|
scannedAudioFiles = AudioFileScanner.runSmartTrackOrder(libraryItemData.relPath, scannedAudioFiles)
|
||||||
|
|
||||||
|
// Find ebook file (prefer epub)
|
||||||
|
let ebookLibraryFile = libraryItemData.ebookLibraryFiles.find(lf => lf.metadata.ext.slice(1).toLowerCase() === 'epub') || libraryItemData.ebookLibraryFiles[0]
|
||||||
|
|
||||||
|
// Do not add library items that have no valid audio files and no ebook file
|
||||||
|
if (!ebookLibraryFile && !scannedAudioFiles.length) {
|
||||||
|
libraryScan.addLog(LogLevel.WARN, `Library item at path "${libraryItemData.relPath}" has no audio files and no ebook file - ignoring`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ebookLibraryFile) {
|
||||||
|
ebookLibraryFile.ebookFormat = ebookLibraryFile.metadata.ext.slice(1).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookMetadata = await this.getBookMetadataFromScanData(scannedAudioFiles, libraryItemData, libraryScan)
|
||||||
|
|
||||||
|
let duration = 0
|
||||||
|
scannedAudioFiles.forEach((af) => duration += (!isNaN(af.duration) ? Number(af.duration) : 0))
|
||||||
|
const bookObject = {
|
||||||
|
...bookMetadata,
|
||||||
|
audioFiles: scannedAudioFiles,
|
||||||
|
ebookFile: ebookLibraryFile || null,
|
||||||
|
duration,
|
||||||
|
bookAuthors: [],
|
||||||
|
bookSeries: []
|
||||||
|
}
|
||||||
|
if (bookMetadata.authors.length) {
|
||||||
|
for (const authorName of bookMetadata.authors) {
|
||||||
|
const matchingAuthor = Database.libraryFilterData[libraryScan.libraryId].authors.find(au => au.name === authorName)
|
||||||
|
if (matchingAuthor) {
|
||||||
|
bookObject.bookAuthors.push({
|
||||||
|
authorId: matchingAuthor.id
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// New author
|
||||||
|
bookObject.bookAuthors.push({
|
||||||
|
author: {
|
||||||
|
libraryId: libraryScan.libraryId,
|
||||||
|
name: authorName,
|
||||||
|
lastFirst: parseNameString.nameToLastFirst(authorName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bookMetadata.series.length) {
|
||||||
|
for (const seriesObj of bookMetadata.series) {
|
||||||
|
if (!seriesObj.name) continue
|
||||||
|
const matchingSeries = Database.libraryFilterData[libraryScan.libraryId].series.find(se => se.name === seriesObj.name)
|
||||||
|
if (matchingSeries) {
|
||||||
|
bookObject.bookSeries.push({
|
||||||
|
seriesId: matchingSeries.id,
|
||||||
|
sequence: seriesObj.sequence
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
bookObject.bookSeries.push({
|
||||||
|
sequence: seriesObj.sequence,
|
||||||
|
series: {
|
||||||
|
name: seriesObj.name,
|
||||||
|
nameIgnorePrefix: getTitleIgnorePrefix(seriesObj.name),
|
||||||
|
libraryId: libraryScan.libraryId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryItemObj = libraryItemData.libraryItemObject
|
||||||
|
libraryItemObj.id = uuidv4() // Generate library item id ahead of time to use for saving extracted cover image
|
||||||
|
|
||||||
|
// If cover was not found in folder then check embedded covers in audio files
|
||||||
|
if (!bookObject.coverPath && scannedAudioFiles.length) {
|
||||||
|
const libraryItemDir = libraryItemObj.isFile ? null : libraryItemObj.path
|
||||||
|
// Extract and save embedded cover art
|
||||||
|
bookObject.coverPath = await CoverManager.saveEmbeddedCoverArtNew(scannedAudioFiles, libraryItemObj.id, libraryItemDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryItemObj.book = bookObject
|
||||||
|
const libraryItem = await Database.libraryItemModel.create(libraryItemObj, {
|
||||||
|
include: {
|
||||||
|
model: Database.bookModel,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Database.bookSeriesModel,
|
||||||
|
include: {
|
||||||
|
model: Database.seriesModel
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Database.bookAuthorModel,
|
||||||
|
include: {
|
||||||
|
model: Database.authorModel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update library filter data
|
||||||
|
if (libraryItem.book.bookSeries?.length) {
|
||||||
|
for (const bs of libraryItem.book.bookSeries) {
|
||||||
|
if (bs.series) {
|
||||||
|
Database.addSeriesToFilterData(libraryScan.libraryId, bs.series.name, bs.series.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (libraryItem.book.bookAuthors?.length) {
|
||||||
|
for (const ba of libraryItem.book.bookAuthors) {
|
||||||
|
if (ba.author) {
|
||||||
|
Database.addAuthorToFilterData(libraryScan.libraryId, ba.author.name, ba.author.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Database.addNarratorsToFilterData(libraryScan.libraryId, libraryItem.book.narrators)
|
||||||
|
Database.addGenresToFilterData(libraryScan.libraryId, libraryItem.book.genres)
|
||||||
|
Database.addTagsToFilterData(libraryScan.libraryId, libraryItem.book.tags)
|
||||||
|
Database.addPublisherToFilterData(libraryScan.libraryId, libraryItem.book.publisher)
|
||||||
|
Database.addLanguageToFilterData(libraryScan.libraryId, libraryItem.book.language)
|
||||||
|
|
||||||
|
return libraryItem
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {import('../objects/files/AudioFile')[]} scannedAudioFiles
|
||||||
|
* @param {import('./LibraryItemScanData')} libraryItemData
|
||||||
|
* @param {import('./LibraryScan')} libraryScan
|
||||||
|
* @returns {Promise<BookMetadataObject>}
|
||||||
|
*/
|
||||||
|
async getBookMetadataFromScanData(scannedAudioFiles, libraryItemData, libraryScan) {
|
||||||
|
// First set book metadata from folder/file names
|
||||||
|
const bookMetadata = {
|
||||||
|
title: libraryItemData.mediaMetadata.title,
|
||||||
|
titleIgnorePrefix: getTitleIgnorePrefix(libraryItemData.mediaMetadata.title),
|
||||||
|
subtitle: libraryItemData.mediaMetadata.subtitle,
|
||||||
|
publishedYear: libraryItemData.mediaMetadata.publishedYear,
|
||||||
|
publisher: null,
|
||||||
|
description: null,
|
||||||
|
isbn: null,
|
||||||
|
asin: null,
|
||||||
|
language: null,
|
||||||
|
narrators: parseNameString.parse(libraryItemData.mediaMetadata.narrators)?.names || [],
|
||||||
|
genres: [],
|
||||||
|
tags: [],
|
||||||
|
authors: parseNameString.parse(libraryItemData.mediaMetadata.author)?.names || [],
|
||||||
|
series: [],
|
||||||
|
chapters: [],
|
||||||
|
explicit: false,
|
||||||
|
abridged: false,
|
||||||
|
coverPath: null
|
||||||
|
}
|
||||||
|
if (libraryItemData.mediaMetadata.series) {
|
||||||
|
bookMetadata.series.push({
|
||||||
|
name: libraryItemData.mediaMetadata.series,
|
||||||
|
sequence: libraryItemData.mediaMetadata.sequence || null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill in or override book metadata from audio file meta tags
|
||||||
|
if (scannedAudioFiles.length) {
|
||||||
|
const MetadataMapArray = [
|
||||||
|
{
|
||||||
|
tag: 'tagComposer',
|
||||||
|
key: 'narrators'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'tagDescription',
|
||||||
|
altTag: 'tagComment',
|
||||||
|
key: 'description'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'tagPublisher',
|
||||||
|
key: 'publisher'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: 'tagDate',
|
||||||
|
key: 'publishedYear'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
const overrideExistingDetails = Database.serverSettings.scannerPreferAudioMetadata
|
||||||
|
const firstScannedFile = scannedAudioFiles[0]
|
||||||
|
const audioFileMetaTags = firstScannedFile.metaTags
|
||||||
|
MetadataMapArray.forEach((mapping) => {
|
||||||
|
let value = audioFileMetaTags[mapping.tag]
|
||||||
|
if (!value && mapping.altTag) {
|
||||||
|
value = audioFileMetaTags[mapping.altTag]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === 'string') {
|
||||||
|
value = value.trim() // Trim whitespace
|
||||||
|
|
||||||
|
if (mapping.key === 'narrators' && (!bookMetadata.narrators.length || overrideExistingDetails)) {
|
||||||
|
bookMetadata.narrators = parseNameString.parse(value)?.names || []
|
||||||
|
} else if (mapping.key === 'authors' && (!bookMetadata.authors.length || overrideExistingDetails)) {
|
||||||
|
bookMetadata.authors = parseNameString.parse(value)?.names || []
|
||||||
|
} else if (mapping.key === 'genres' && (!bookMetadata.genres.length || overrideExistingDetails)) {
|
||||||
|
bookMetadata.genres = this.parseGenresString(value)
|
||||||
|
} else if (mapping.key === 'series' && (!bookMetadata.series.length || overrideExistingDetails)) {
|
||||||
|
bookMetadata.series = [
|
||||||
|
{
|
||||||
|
name: value,
|
||||||
|
sequence: audioFileMetaTags.tagSeriesPart || null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} else if (!bookMetadata[mapping.key] || overrideExistingDetails) {
|
||||||
|
bookMetadata[mapping.key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// If desc.txt in library item folder then use this for description
|
||||||
|
if (libraryItemData.descTxtLibraryFile) {
|
||||||
|
const description = await readTextFile(libraryItemData.descTxtLibraryFile.metadata.path)
|
||||||
|
if (description.trim()) bookMetadata.description = description.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If reader.txt in library item folder then use this for narrator
|
||||||
|
if (libraryItemData.readerTxtLibraryFile) {
|
||||||
|
let narrator = await readTextFile(libraryItemData.readerTxtLibraryFile.metadata.path)
|
||||||
|
narrator = narrator.split(/\r?\n/)[0]?.trim() || '' // Only use first line
|
||||||
|
if (narrator) {
|
||||||
|
bookMetadata.narrators = parseNameString.parse(narrator)?.names || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If opf file is found look for metadata
|
||||||
|
if (libraryItemData.metadataOpfLibraryFile) {
|
||||||
|
const xmlText = await readTextFile(libraryItemData.metadataOpfLibraryFile.metadata.path)
|
||||||
|
const opfMetadata = xmlText ? await parseOpfMetadataXML(xmlText) : null
|
||||||
|
if (opfMetadata) {
|
||||||
|
const opfMetadataOverrideDetails = Database.serverSettings.scannerPreferOpfMetadata
|
||||||
|
for (const key in opfMetadata) {
|
||||||
|
if (key === 'tags') { // Add tags only if tags are empty
|
||||||
|
if (opfMetadata.tags.length && (!bookMetadata.tags.length || opfMetadataOverrideDetails)) {
|
||||||
|
bookMetadata.tags = opfMetadata.tags
|
||||||
|
}
|
||||||
|
} else if (key === 'genres') { // Add genres only if genres are empty
|
||||||
|
if (opfMetadata.genres.length && (!bookMetadata.genres.length || opfMetadataOverrideDetails)) {
|
||||||
|
bookMetadata.genres = opfMetadata.genres
|
||||||
|
}
|
||||||
|
} else if (key === 'authors') {
|
||||||
|
if (opfMetadata.authors?.length && (!bookMetadata.authors.length || opfMetadataOverrideDetails)) {
|
||||||
|
bookMetadata.authors = opfMetadata.authors
|
||||||
|
}
|
||||||
|
} else if (key === 'narrators') {
|
||||||
|
if (opfMetadata.narrators?.length && (!bookMetadata.narrators.length || opfMetadataOverrideDetails)) {
|
||||||
|
bookMetadata.narrators = opfMetadata.narrators
|
||||||
|
}
|
||||||
|
} else if (key === 'series') {
|
||||||
|
if (opfMetadata.series && (!bookMetadata.series.length || opfMetadataOverrideDetails)) {
|
||||||
|
bookMetadata.series = [{
|
||||||
|
name: opfMetadata.series,
|
||||||
|
sequence: opfMetadata.sequence || null
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
} else if (opfMetadata[key] && (!bookMetadata[key] || opfMetadataOverrideDetails)) {
|
||||||
|
bookMetadata[key] = opfMetadata[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If metadata.json or metadata.abs use this for metadata
|
||||||
|
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile || libraryItemData.metadataAbsLibraryFile
|
||||||
|
const metadataText = metadataLibraryFile ? await readTextFile(metadataLibraryFile.metadata.path) : null
|
||||||
|
if (metadataText) {
|
||||||
|
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataLibraryFile.metadata.relPath}" - preferring`)
|
||||||
|
let abMetadata = null
|
||||||
|
if (!!libraryItemData.metadataJsonLibraryFile) {
|
||||||
|
abMetadata = abmetadataGenerator.parseJson(metadataText)
|
||||||
|
} else {
|
||||||
|
abMetadata = abmetadataGenerator.parse(metadataText, 'book')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abMetadata) {
|
||||||
|
if (abMetadata.tags?.length) {
|
||||||
|
bookMetadata.tags = abMetadata.tags
|
||||||
|
}
|
||||||
|
if (abMetadata.chapters?.length) {
|
||||||
|
bookMetadata.chapters = abMetadata.chapters
|
||||||
|
}
|
||||||
|
for (const key in abMetadata.metadata) {
|
||||||
|
if (bookMetadata[key] === undefined || abMetadata.metadata[key] === undefined) continue
|
||||||
|
bookMetadata[key] = abMetadata.metadata[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set chapters from audio files if not already set
|
||||||
|
if (!bookMetadata.chapters.length) {
|
||||||
|
bookMetadata.chapters = this.getChaptersFromAudioFiles(bookMetadata.title, scannedAudioFiles, libraryScan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set cover from library file if one is found otherwise check audiofile
|
||||||
|
if (libraryItemData.imageLibraryFiles.length) {
|
||||||
|
const coverMatch = libraryItemData.imageLibraryFiles.find(iFile => /\/cover\.[^.\/]*$/.test(iFile.metadata.path))
|
||||||
|
bookMetadata.coverPath = coverMatch?.metadata.path || libraryItemData.imageLibraryFiles[0].metadata.path
|
||||||
|
}
|
||||||
|
|
||||||
|
return bookMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a genre string into multiple genres
|
||||||
|
* @example "Fantasy;Sci-Fi;History" => ["Fantasy", "Sci-Fi", "History"]
|
||||||
|
* @param {string} genreTag
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
parseGenresString(genreTag) {
|
||||||
|
if (!genreTag?.length) return []
|
||||||
|
const 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]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} bookTitle
|
||||||
|
* @param {AudioFile[]} audioFiles
|
||||||
|
* @param {import('./LibraryScan')} libraryScan
|
||||||
|
* @returns {import('../models/Book').ChapterObject[]}
|
||||||
|
*/
|
||||||
|
getChaptersFromAudioFiles(bookTitle, audioFiles, libraryScan) {
|
||||||
|
if (!audioFiles.length) return []
|
||||||
|
|
||||||
|
// If overdrive media markers are present and preferred, use those instead
|
||||||
|
if (Database.serverSettings.scannerPreferOverdriveMediaMarker) {
|
||||||
|
const overdriveChapters = parseOverdriveMediaMarkersAsChapters(audioFiles)
|
||||||
|
if (overdriveChapters) {
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, 'Overdrive Media Markers and preference found! Using these for chapter definitions')
|
||||||
|
|
||||||
|
return overdriveChapters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let chapters = []
|
||||||
|
|
||||||
|
// If first audio file has embedded chapters then use embedded chapters
|
||||||
|
if (audioFiles[0].chapters?.length) {
|
||||||
|
// If all files chapters are the same, then only make chapters for the first file
|
||||||
|
if (
|
||||||
|
audioFiles.length === 1 ||
|
||||||
|
audioFiles.length > 1 &&
|
||||||
|
audioFiles[0].chapters.length === audioFiles[1].chapters?.length &&
|
||||||
|
audioFiles[0].chapters.every((c, i) => c.title === audioFiles[1].chapters[i].title)
|
||||||
|
) {
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `setChapters: Using embedded chapters in first audio file ${audioFiles[0].metadata?.path}`)
|
||||||
|
chapters = audioFiles[0].chapters.map((c) => ({ ...c }))
|
||||||
|
} else {
|
||||||
|
libraryScan.addLog(LogLevel.DEBUG, `setChapters: Using embedded chapters from all audio files ${audioFiles[0].metadata?.path}`)
|
||||||
|
let currChapterId = 0
|
||||||
|
let currStartTime = 0
|
||||||
|
|
||||||
|
audioFiles.forEach((file) => {
|
||||||
|
if (file.duration) {
|
||||||
|
const afChapters = file.chapters?.map((c) => ({
|
||||||
|
...c,
|
||||||
|
id: c.id + currChapterId,
|
||||||
|
start: c.start + currStartTime,
|
||||||
|
end: c.end + currStartTime,
|
||||||
|
})) ?? []
|
||||||
|
chapters = chapters.concat(afChapters)
|
||||||
|
|
||||||
|
currChapterId += file.chapters?.length ?? 0
|
||||||
|
currStartTime += file.duration
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return chapters
|
||||||
|
}
|
||||||
|
} else if (audioFiles.length > 1) {
|
||||||
|
const preferAudioMetadata = !!Database.serverSettings.scannerPreferAudioMetadata
|
||||||
|
|
||||||
|
// Build chapters from audio files
|
||||||
|
let currChapterId = 0
|
||||||
|
let currStartTime = 0
|
||||||
|
includedAudioFiles.forEach((file) => {
|
||||||
|
if (file.duration) {
|
||||||
|
let title = file.metadata.filename ? Path.basename(file.metadata.filename, Path.extname(file.metadata.filename)) : `Chapter ${currChapterId}`
|
||||||
|
|
||||||
|
// When prefer audio metadata server setting is set then use ID3 title tag as long as it is not the same as the book title
|
||||||
|
if (preferAudioMetadata && file.metaTags?.tagTitle && file.metaTags?.tagTitle !== bookTitle) {
|
||||||
|
title = file.metaTags.tagTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
chapters.push({
|
||||||
|
id: currChapterId++,
|
||||||
|
start: currStartTime,
|
||||||
|
end: currStartTime + file.duration,
|
||||||
|
title
|
||||||
|
})
|
||||||
|
currStartTime += file.duration
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return chapters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = new BookScanner()
|
@ -10,6 +10,8 @@ class LibraryItemScanData {
|
|||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
this.libraryId = data.libraryId
|
this.libraryId = data.libraryId
|
||||||
/** @type {string} */
|
/** @type {string} */
|
||||||
|
this.mediaType = data.mediaType
|
||||||
|
/** @type {string} */
|
||||||
this.ino = data.ino
|
this.ino = data.ino
|
||||||
/** @type {number} */
|
/** @type {number} */
|
||||||
this.mtimeMs = data.mtimeMs
|
this.mtimeMs = data.mtimeMs
|
||||||
@ -23,7 +25,7 @@ class LibraryItemScanData {
|
|||||||
this.relPath = data.relPath
|
this.relPath = data.relPath
|
||||||
/** @type {boolean} */
|
/** @type {boolean} */
|
||||||
this.isFile = data.isFile
|
this.isFile = data.isFile
|
||||||
/** @type {{title:string, subtitle:string, series:string, sequence:string, publishedYear:string, narrators:string}} */
|
/** @type {{author:string, title:string, subtitle:string, series:string, sequence:string, publishedYear:string, narrators:string}} */
|
||||||
this.mediaMetadata = data.mediaMetadata
|
this.mediaMetadata = data.mediaMetadata
|
||||||
/** @type {import('../objects/files/LibraryFile')[]} */
|
/** @type {import('../objects/files/LibraryFile')[]} */
|
||||||
this.libraryFiles = data.libraryFiles
|
this.libraryFiles = data.libraryFiles
|
||||||
@ -41,6 +43,30 @@ class LibraryItemScanData {
|
|||||||
this.libraryFilesModified = []
|
this.libraryFilesModified = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to create a library item
|
||||||
|
*/
|
||||||
|
get libraryItemObject() {
|
||||||
|
let size = 0
|
||||||
|
this.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
|
||||||
|
return {
|
||||||
|
ino: this.ino,
|
||||||
|
path: this.path,
|
||||||
|
relPath: this.relPath,
|
||||||
|
mediaType: this.mediaType,
|
||||||
|
isFile: this.isFile,
|
||||||
|
mtime: this.mtimeMs,
|
||||||
|
ctime: this.ctime,
|
||||||
|
birthtime: this.birthtimeMs,
|
||||||
|
lastScan: Date.now(),
|
||||||
|
lastScanVersion: packageJson.version,
|
||||||
|
libraryFiles: this.libraryFiles,
|
||||||
|
libraryId: this.libraryId,
|
||||||
|
libraryFolderId: this.libraryFolderId,
|
||||||
|
size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {boolean} */
|
/** @type {boolean} */
|
||||||
get hasLibraryFileChanges() {
|
get hasLibraryFileChanges() {
|
||||||
return this.libraryFilesRemoved.length + this.libraryFilesModified.length + this.libraryFilesAdded.length
|
return this.libraryFilesRemoved.length + this.libraryFilesModified.length + this.libraryFilesAdded.length
|
||||||
@ -81,6 +107,31 @@ class LibraryItemScanData {
|
|||||||
return this.libraryFiles.filter(lf => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
return this.libraryFiles.filter(lf => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @type {LibraryItem.LibraryFileObject} */
|
||||||
|
get descTxtLibraryFile() {
|
||||||
|
return this.libraryFiles.find(lf => lf.metadata.filename === 'desc.txt')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {LibraryItem.LibraryFileObject} */
|
||||||
|
get readerTxtLibraryFile() {
|
||||||
|
return this.libraryFiles.find(lf => lf.metadata.filename === 'reader.txt')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {LibraryItem.LibraryFileObject} */
|
||||||
|
get metadataAbsLibraryFile() {
|
||||||
|
return this.libraryFiles.find(lf => lf.metadata.filename === 'metadata.abs')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {LibraryItem.LibraryFileObject} */
|
||||||
|
get metadataJsonLibraryFile() {
|
||||||
|
return this.libraryFiles.find(lf => lf.metadata.filename === 'metadata.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {LibraryItem.LibraryFileObject} */
|
||||||
|
get metadataOpfLibraryFile() {
|
||||||
|
return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.opf')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {LibraryItem} existingLibraryItem
|
* @param {LibraryItem} existingLibraryItem
|
||||||
|
@ -8,12 +8,14 @@ const fileUtils = require('../utils/fileUtils')
|
|||||||
const scanUtils = require('../utils/scandir')
|
const scanUtils = require('../utils/scandir')
|
||||||
const { ScanResult, LogLevel } = require('../utils/constants')
|
const { ScanResult, LogLevel } = require('../utils/constants')
|
||||||
const globals = require('../utils/globals')
|
const globals = require('../utils/globals')
|
||||||
|
const libraryFilters = require('../utils/queries/libraryFilters')
|
||||||
const AudioFileScanner = require('./AudioFileScanner')
|
const AudioFileScanner = require('./AudioFileScanner')
|
||||||
const ScanOptions = require('./ScanOptions')
|
const ScanOptions = require('./ScanOptions')
|
||||||
const LibraryScan = require('./LibraryScan')
|
const LibraryScan = require('./LibraryScan')
|
||||||
const LibraryItemScanData = require('./LibraryItemScanData')
|
const LibraryItemScanData = require('./LibraryItemScanData')
|
||||||
const AudioFile = require('../objects/files/AudioFile')
|
const AudioFile = require('../objects/files/AudioFile')
|
||||||
const Book = require('../models/Book')
|
const Book = require('../models/Book')
|
||||||
|
const BookScanner = require('./BookScanner')
|
||||||
|
|
||||||
class LibraryScanner {
|
class LibraryScanner {
|
||||||
constructor(coverManager, taskManager) {
|
constructor(coverManager, taskManager) {
|
||||||
@ -91,6 +93,10 @@ class LibraryScanner {
|
|||||||
* @param {import('./LibraryScan')} libraryScan
|
* @param {import('./LibraryScan')} libraryScan
|
||||||
*/
|
*/
|
||||||
async scanLibrary(libraryScan) {
|
async scanLibrary(libraryScan) {
|
||||||
|
// Make sure library filter data is set
|
||||||
|
// this is used to check for existing authors & series
|
||||||
|
await libraryFilters.getFilterData(libraryScan.library)
|
||||||
|
|
||||||
/** @type {LibraryItemScanData[]} */
|
/** @type {LibraryItemScanData[]} */
|
||||||
let libraryItemDataFound = []
|
let libraryItemDataFound = []
|
||||||
|
|
||||||
@ -159,9 +165,16 @@ class LibraryScanner {
|
|||||||
|
|
||||||
// Add new library items
|
// Add new library items
|
||||||
if (libraryItemDataFound.length) {
|
if (libraryItemDataFound.length) {
|
||||||
|
for (const libraryItemData of libraryItemDataFound) {
|
||||||
|
const newLibraryItem = await this.scanNewLibraryItem(libraryItemData, libraryScan)
|
||||||
|
if (newLibraryItem) {
|
||||||
|
libraryScan.resultsAdded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Socket emitter
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get scan data for library folder
|
* Get scan data for library folder
|
||||||
@ -217,6 +230,7 @@ class LibraryScanner {
|
|||||||
items.push(new LibraryItemScanData({
|
items.push(new LibraryItemScanData({
|
||||||
libraryFolderId: folder.id,
|
libraryFolderId: folder.id,
|
||||||
libraryId: folder.libraryId,
|
libraryId: folder.libraryId,
|
||||||
|
mediaType: library.mediaType,
|
||||||
ino: libraryItemFolderStats.ino,
|
ino: libraryItemFolderStats.ino,
|
||||||
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
|
mtimeMs: libraryItemFolderStats.mtimeMs || 0,
|
||||||
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
|
ctimeMs: libraryItemFolderStats.ctimeMs || 0,
|
||||||
@ -304,7 +318,7 @@ class LibraryScanner {
|
|||||||
media.audioFiles.push(...scannedAudioFiles)
|
media.audioFiles.push(...scannedAudioFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
media.audioFiles = AudioFileScanner.runSmartTrackOrder(media, media.audioFiles)
|
media.audioFiles = AudioFileScanner.runSmartTrackOrder(existingLibraryItem.relPath, media.audioFiles)
|
||||||
|
|
||||||
media.duration = 0
|
media.duration = 0
|
||||||
media.audioFiles.forEach((af) => {
|
media.audioFiles.forEach((af) => {
|
||||||
@ -373,6 +387,8 @@ class LibraryScanner {
|
|||||||
if (hasMediaChanges) {
|
if (hasMediaChanges) {
|
||||||
await media.save()
|
await media.save()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// TODO: Scan updated podcast
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -382,10 +398,15 @@ class LibraryScanner {
|
|||||||
* @param {LibraryScan} libraryScan
|
* @param {LibraryScan} libraryScan
|
||||||
*/
|
*/
|
||||||
async scanNewLibraryItem(libraryItemData, libraryScan) {
|
async scanNewLibraryItem(libraryItemData, libraryScan) {
|
||||||
|
|
||||||
if (libraryScan.libraryMediaType === 'book') {
|
if (libraryScan.libraryMediaType === 'book') {
|
||||||
let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(libraryScan.libraryMediaType, libraryItemData, libraryItemData.audioLibraryFiles)
|
const newLibraryItem = await BookScanner.scanNewBookLibraryItem(libraryItemData, libraryScan)
|
||||||
// TODO: Create new book
|
if (newLibraryItem) {
|
||||||
|
libraryScan.addLog(LogLevel.INFO, `Created new library item "${newLibraryItem.relPath}"`)
|
||||||
|
}
|
||||||
|
return newLibraryItem
|
||||||
|
} else {
|
||||||
|
// TODO: Scan new podcast
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -82,6 +82,11 @@ function getIno(path) {
|
|||||||
}
|
}
|
||||||
module.exports.getIno = getIno
|
module.exports.getIno = getIno
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read contents of file
|
||||||
|
* @param {string} path
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
async function readTextFile(path) {
|
async function readTextFile(path) {
|
||||||
try {
|
try {
|
||||||
var data = await fs.readFile(path)
|
var data = await fs.readFile(path)
|
||||||
|
@ -24,7 +24,7 @@ const CurrentAbMetadataVersion = 2
|
|||||||
|
|
||||||
const commaSeparatedToArray = (v) => {
|
const commaSeparatedToArray = (v) => {
|
||||||
if (!v) return []
|
if (!v) return []
|
||||||
return v.split(',').map(_v => _v.trim()).filter(_v => _v)
|
return [...new Set(v.split(',').map(_v => _v.trim()).filter(_v => _v))]
|
||||||
}
|
}
|
||||||
|
|
||||||
const podcastMetadataMapper = {
|
const podcastMetadataMapper = {
|
||||||
@ -401,7 +401,10 @@ function checkArraysChanged(abmetadataArray, mediaArray) {
|
|||||||
function parseJsonMetadataText(text) {
|
function parseJsonMetadataText(text) {
|
||||||
try {
|
try {
|
||||||
const abmetadataData = JSON.parse(text)
|
const abmetadataData = JSON.parse(text)
|
||||||
if (abmetadataData.metadata?.series?.length) {
|
if (!abmetadataData.metadata) abmetadataData.metadata = {}
|
||||||
|
|
||||||
|
if (abmetadataData.metadata.series?.length) {
|
||||||
|
abmetadataData.metadata.series = [...new Set(abmetadataData.metadata.series.map(t => t?.trim()).filter(t => t))]
|
||||||
abmetadataData.metadata.series = abmetadataData.metadata.series.map(series => {
|
abmetadataData.metadata.series = abmetadataData.metadata.series.map(series => {
|
||||||
let sequence = null
|
let sequence = null
|
||||||
let name = series
|
let name = series
|
||||||
@ -418,12 +421,31 @@ function parseJsonMetadataText(text) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// clean tags & remove dupes
|
||||||
|
if (abmetadataData.tags?.length) {
|
||||||
|
abmetadataData.tags = [...new Set(abmetadataData.tags.map(t => t?.trim()).filter(t => t))]
|
||||||
|
}
|
||||||
|
// TODO: Clean chapters
|
||||||
|
if (abmetadataData.chapters?.length) {
|
||||||
|
|
||||||
|
}
|
||||||
|
// clean remove dupes
|
||||||
|
if (abmetadataData.metadata.authors?.length) {
|
||||||
|
abmetadataData.metadata.authors = [...new Set(abmetadataData.metadata.authors.map(t => t?.trim()).filter(t => t))]
|
||||||
|
}
|
||||||
|
if (abmetadataData.metadata.narrators?.length) {
|
||||||
|
abmetadataData.metadata.narrators = [...new Set(abmetadataData.metadata.narrators.map(t => t?.trim()).filter(t => t))]
|
||||||
|
}
|
||||||
|
if (abmetadataData.metadata.genres?.length) {
|
||||||
|
abmetadataData.metadata.genres = [...new Set(abmetadataData.metadata.genres.map(t => t?.trim()).filter(t => t))]
|
||||||
|
}
|
||||||
return abmetadataData
|
return abmetadataData
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error)
|
Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
module.exports.parseJson = parseJsonMetadataText
|
||||||
|
|
||||||
function cleanChaptersArray(chaptersArray, mediaTitle) {
|
function cleanChaptersArray(chaptersArray, mediaTitle) {
|
||||||
const chapters = []
|
const chapters = []
|
||||||
|
@ -147,10 +147,22 @@ const getTitleParts = (title) => {
|
|||||||
return [title, null]
|
return [title, null]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove sortingPrefixes from title
|
||||||
|
* @example "The Good Book" => "Good Book"
|
||||||
|
* @param {string} title
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
module.exports.getTitleIgnorePrefix = (title) => {
|
module.exports.getTitleIgnorePrefix = (title) => {
|
||||||
return getTitleParts(title)[0]
|
return getTitleParts(title)[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put sorting prefix at the end of title
|
||||||
|
* @example "The Good Book" => "Good Book, The"
|
||||||
|
* @param {string} title
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
module.exports.getTitlePrefixAtEnd = (title) => {
|
module.exports.getTitlePrefixAtEnd = (title) => {
|
||||||
let [sort, prefix] = getTitleParts(title)
|
let [sort, prefix] = getTitleParts(title)
|
||||||
return prefix ? `${sort}, ${prefix}` : title
|
return prefix ? `${sort}, ${prefix}` : title
|
||||||
|
@ -159,8 +159,8 @@ module.exports.parseOpfMetadataXML = async (xml) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const creators = parseCreators(metadata)
|
const creators = parseCreators(metadata)
|
||||||
const authors = (fetchCreators(creators, 'aut') || []).filter(au => au && au.trim())
|
const authors = (fetchCreators(creators, 'aut') || []).map(au => au?.trim()).filter(au => au)
|
||||||
const narrators = (fetchNarrators(creators, metadata) || []).filter(nrt => nrt && nrt.trim())
|
const narrators = (fetchNarrators(creators, metadata) || []).map(nrt => nrt?.trim()).filter(nrt => nrt)
|
||||||
const data = {
|
const data = {
|
||||||
title: fetchTitle(metadata),
|
title: fetchTitle(metadata),
|
||||||
subtitle: fetchSubtitle(metadata),
|
subtitle: fetchSubtitle(metadata),
|
||||||
|
Loading…
Reference in New Issue
Block a user