audiobookshelf/server/models/Book.js
2025-01-05 12:05:01 -06:00

789 lines
24 KiB
JavaScript

const { DataTypes, Model } = require('sequelize')
const Logger = require('../Logger')
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
const parseNameString = require('../utils/parsers/parseNameString')
/**
* @typedef EBookFileObject
* @property {string} ino
* @property {string} ebookFormat
* @property {number} addedAt
* @property {number} updatedAt
* @property {{filename:string, ext:string, path:string, relPath:strFing, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
*/
/**
* @typedef ChapterObject
* @property {number} id
* @property {number} start
* @property {number} end
* @property {string} title
*/
/**
* @typedef SeriesExpandedProperties
* @property {{sequence:string}} bookSeries
*
* @typedef {import('./Series') & SeriesExpandedProperties} SeriesExpanded
*
* @typedef BookExpandedProperties
* @property {import('./Author')[]} authors
* @property {SeriesExpanded[]} series
*
* @typedef {Book & BookExpandedProperties} BookExpanded
*
* Collections use BookExpandedWithLibraryItem
* @typedef BookExpandedWithLibraryItemProperties
* @property {import('./LibraryItem')} libraryItem
*
* @typedef {BookExpanded & BookExpandedWithLibraryItemProperties} BookExpandedWithLibraryItem
*/
/**
* @typedef AudioFileObject
* @property {number} index
* @property {string} ino
* @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
* @property {number} addedAt
* @property {number} updatedAt
* @property {number} trackNumFromMeta
* @property {number} discNumFromMeta
* @property {number} trackNumFromFilename
* @property {number} discNumFromFilename
* @property {boolean} manuallyVerified
* @property {string} format
* @property {number} duration
* @property {number} bitRate
* @property {string} language
* @property {string} codec
* @property {string} timeBase
* @property {number} channels
* @property {string} channelLayout
* @property {ChapterObject[]} chapters
* @property {Object} metaTags
* @property {string} mimeType
*
* @typedef AudioTrackProperties
* @property {string} title
* @property {string} contentUrl
* @property {number} startOffset
*
* @typedef {AudioFileObject & AudioTrackProperties} AudioTrack
*/
class Book extends Model {
constructor(values, options) {
super(values, options)
/** @type {string} */
this.id
/** @type {string} */
this.title
/** @type {string} */
this.titleIgnorePrefix
/** @type {string} */
this.subtitle
/** @type {string} */
this.publishedYear
/** @type {string} */
this.publishedDate
/** @type {string} */
this.publisher
/** @type {string} */
this.description
/** @type {string} */
this.isbn
/** @type {string} */
this.asin
/** @type {string} */
this.language
/** @type {boolean} */
this.explicit
/** @type {boolean} */
this.abridged
/** @type {string} */
this.coverPath
/** @type {number} */
this.duration
/** @type {string[]} */
this.narrators
/** @type {AudioFileObject[]} */
this.audioFiles
/** @type {EBookFileObject} */
this.ebookFile
/** @type {ChapterObject[]} */
this.chapters
/** @type {string[]} */
this.tags
/** @type {string[]} */
this.genres
/** @type {Date} */
this.updatedAt
/** @type {Date} */
this.createdAt
// Expanded properties
/** @type {import('./Author')[]} - optional if expanded */
this.authors
/** @type {import('./Series')[]} - optional if expanded */
this.series
}
static getOldBook(libraryItemExpanded) {
const bookExpanded = libraryItemExpanded.media
let authors = []
if (bookExpanded.authors?.length) {
authors = bookExpanded.authors.map((au) => {
return {
id: au.id,
name: au.name
}
})
} else if (bookExpanded.bookAuthors?.length) {
authors = bookExpanded.bookAuthors
.map((ba) => {
if (ba.author) {
return {
id: ba.author.id,
name: ba.author.name
}
} else {
Logger.error(`[Book] Invalid bookExpanded bookAuthors: no author`, ba)
return null
}
})
.filter((a) => a)
}
let series = []
if (bookExpanded.series?.length) {
series = bookExpanded.series.map((se) => {
return {
id: se.id,
name: se.name,
sequence: se.bookSeries.sequence
}
})
} else if (bookExpanded.bookSeries?.length) {
series = bookExpanded.bookSeries
.map((bs) => {
if (bs.series) {
return {
id: bs.series.id,
name: bs.series.name,
sequence: bs.sequence
}
} else {
Logger.error(`[Book] Invalid bookExpanded bookSeries: no series`, bs)
return null
}
})
.filter((s) => s)
}
return {
id: bookExpanded.id,
libraryItemId: libraryItemExpanded.id,
coverPath: bookExpanded.coverPath,
tags: bookExpanded.tags,
audioFiles: bookExpanded.audioFiles,
chapters: bookExpanded.chapters,
ebookFile: bookExpanded.ebookFile,
metadata: {
title: bookExpanded.title,
subtitle: bookExpanded.subtitle,
authors: authors,
narrators: bookExpanded.narrators,
series: series,
genres: bookExpanded.genres,
publishedYear: bookExpanded.publishedYear,
publishedDate: bookExpanded.publishedDate,
publisher: bookExpanded.publisher,
description: bookExpanded.description,
isbn: bookExpanded.isbn,
asin: bookExpanded.asin,
language: bookExpanded.language,
explicit: bookExpanded.explicit,
abridged: bookExpanded.abridged
}
}
}
/**
* @param {object} oldBook
* @returns {boolean} true if updated
*/
static saveFromOld(oldBook) {
const book = this.getFromOld(oldBook)
return this.update(book, {
where: {
id: book.id
}
})
.then((result) => result[0] > 0)
.catch((error) => {
Logger.error(`[Book] Failed to save book ${book.id}`, error)
return false
})
}
static getFromOld(oldBook) {
return {
id: oldBook.id,
title: oldBook.metadata.title,
titleIgnorePrefix: oldBook.metadata.titleIgnorePrefix,
subtitle: oldBook.metadata.subtitle,
publishedYear: oldBook.metadata.publishedYear,
publishedDate: oldBook.metadata.publishedDate,
publisher: oldBook.metadata.publisher,
description: oldBook.metadata.description,
isbn: oldBook.metadata.isbn,
asin: oldBook.metadata.asin,
language: oldBook.metadata.language,
explicit: !!oldBook.metadata.explicit,
abridged: !!oldBook.metadata.abridged,
narrators: oldBook.metadata.narrators,
ebookFile: oldBook.ebookFile?.toJSON() || null,
coverPath: oldBook.coverPath,
duration: oldBook.duration,
audioFiles: oldBook.audioFiles?.map((af) => af.toJSON()) || [],
chapters: oldBook.chapters,
tags: oldBook.tags,
genres: oldBook.metadata.genres
}
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
title: DataTypes.STRING,
titleIgnorePrefix: DataTypes.STRING,
subtitle: DataTypes.STRING,
publishedYear: DataTypes.STRING,
publishedDate: DataTypes.STRING,
publisher: DataTypes.STRING,
description: DataTypes.TEXT,
isbn: DataTypes.STRING,
asin: DataTypes.STRING,
language: DataTypes.STRING,
explicit: DataTypes.BOOLEAN,
abridged: DataTypes.BOOLEAN,
coverPath: DataTypes.STRING,
duration: DataTypes.FLOAT,
narrators: DataTypes.JSON,
audioFiles: DataTypes.JSON,
ebookFile: DataTypes.JSON,
chapters: DataTypes.JSON,
tags: DataTypes.JSON,
genres: DataTypes.JSON
},
{
sequelize,
modelName: 'book',
indexes: [
{
fields: [
{
name: 'title',
collate: 'NOCASE'
}
]
},
// {
// fields: [{
// name: 'titleIgnorePrefix',
// collate: 'NOCASE'
// }]
// },
{
fields: ['publishedYear']
},
{
fields: ['duration']
}
]
}
)
}
/**
* Comma separated array of author names
* Requires authors to be loaded
*
* @returns {string}
*/
get authorName() {
if (this.authors === undefined) {
Logger.error(`[Book] authorName: Cannot get authorName because authors are not loaded`)
return ''
}
return this.authors.map((au) => au.name).join(', ')
}
/**
* Comma separated array of author names in Last, First format
* Requires authors to be loaded
*
* @returns {string}
*/
get authorNameLF() {
if (this.authors === undefined) {
Logger.error(`[Book] authorNameLF: Cannot get authorNameLF because authors are not loaded`)
return ''
}
// Last, First
if (!this.authors.length) return ''
return this.authors.map((au) => parseNameString.nameToLastFirst(au.name)).join(', ')
}
/**
* Comma separated array of series with sequence
* Requires series to be loaded
*
* @returns {string}
*/
get seriesName() {
if (this.series === undefined) {
Logger.error(`[Book] seriesName: Cannot get seriesName because series are not loaded`)
return ''
}
if (!this.series.length) return ''
return this.series
.map((se) => {
const sequence = se.bookSeries?.sequence || ''
if (!sequence) return se.name
return `${se.name} #${sequence}`
})
.join(', ')
}
get includedAudioFiles() {
return this.audioFiles.filter((af) => !af.exclude)
}
get hasMediaFiles() {
return !!this.hasAudioTracks || !!this.ebookFile
}
get hasAudioTracks() {
return !!this.includedAudioFiles.length
}
/**
* Supported mime types are sent from the web client and are retrieved using the browser Audio player "canPlayType" function.
*
* @param {string[]} supportedMimeTypes
* @returns {boolean}
*/
checkCanDirectPlay(supportedMimeTypes) {
if (!Array.isArray(supportedMimeTypes)) {
Logger.error(`[Book] checkCanDirectPlay: supportedMimeTypes is not an array`, supportedMimeTypes)
return false
}
return this.includedAudioFiles.every((af) => supportedMimeTypes.includes(af.mimeType))
}
/**
* Get the track list to be used in client audio players
* AudioTrack is the AudioFile with startOffset, contentUrl and title
*
* @param {string} libraryItemId
* @returns {AudioTrack[]}
*/
getTracklist(libraryItemId) {
let startOffset = 0
return this.includedAudioFiles.map((af) => {
const track = structuredClone(af)
track.title = af.metadata.filename
track.startOffset = startOffset
track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}`
startOffset += track.duration
return track
})
}
/**
*
* @returns {ChapterObject[]}
*/
getChapters() {
return structuredClone(this.chapters) || []
}
getPlaybackTitle() {
return this.title
}
getPlaybackAuthor() {
return this.authorName
}
getPlaybackDuration() {
return this.duration
}
/**
* Total file size of all audio files and ebook file
*
* @returns {number}
*/
get size() {
let total = 0
this.audioFiles.forEach((af) => (total += af.metadata.size))
if (this.ebookFile) {
total += this.ebookFile.metadata.size
}
return total
}
getAbsMetadataJson() {
return {
tags: this.tags || [],
chapters: this.chapters?.map((c) => ({ ...c })) || [],
title: this.title,
subtitle: this.subtitle,
authors: this.authors.map((a) => a.name),
narrators: this.narrators,
series: this.series.map((se) => {
const sequence = se.bookSeries?.sequence || ''
if (!sequence) return se.name
return `${se.name} #${sequence}`
}),
genres: this.genres || [],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
publisher: this.publisher,
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: !!this.explicit,
abridged: !!this.abridged
}
}
/**
*
* @param {Object} payload - old book object
* @returns {Promise<boolean>}
*/
async updateFromRequest(payload) {
if (!payload) return false
let hasUpdates = false
if (payload.metadata) {
const metadataStringKeys = ['title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language']
metadataStringKeys.forEach((key) => {
if (typeof payload.metadata[key] === 'string' && this[key] !== payload.metadata[key]) {
this[key] = payload.metadata[key] || null
if (key === 'title') {
this.titleIgnorePrefix = getTitleIgnorePrefix(this.title)
}
hasUpdates = true
}
})
if (payload.metadata.explicit !== undefined && this.explicit !== !!payload.metadata.explicit) {
this.explicit = !!payload.metadata.explicit
hasUpdates = true
}
if (payload.metadata.abridged !== undefined && this.abridged !== !!payload.metadata.abridged) {
this.abridged = !!payload.metadata.abridged
hasUpdates = true
}
const arrayOfStringsKeys = ['narrators', 'genres']
arrayOfStringsKeys.forEach((key) => {
if (Array.isArray(payload.metadata[key]) && !payload.metadata[key].some((item) => typeof item !== 'string') && JSON.stringify(this[key]) !== JSON.stringify(payload.metadata[key])) {
this[key] = payload.metadata[key]
this.changed(key, true)
hasUpdates = true
}
})
}
if (Array.isArray(payload.tags) && !payload.tags.some((tag) => typeof tag !== 'string') && JSON.stringify(this.tags) !== JSON.stringify(payload.tags)) {
this.tags = payload.tags
this.changed('tags', true)
hasUpdates = true
}
// TODO: Remove support for updating audioFiles, chapters and ebookFile here
const arrayOfObjectsKeys = ['audioFiles', 'chapters']
arrayOfObjectsKeys.forEach((key) => {
if (Array.isArray(payload[key]) && !payload[key].some((item) => typeof item !== 'object') && JSON.stringify(this[key]) !== JSON.stringify(payload[key])) {
this[key] = payload[key]
this.changed(key, true)
hasUpdates = true
}
})
if (payload.ebookFile && JSON.stringify(this.ebookFile) !== JSON.stringify(payload.ebookFile)) {
this.ebookFile = payload.ebookFile
this.changed('ebookFile', true)
hasUpdates = true
}
if (hasUpdates) {
Logger.debug(`[Book] "${this.title}" changed keys:`, this.changed())
await this.save()
}
return hasUpdates
}
/**
* Creates or removes authors from the book using the author names from the request
*
* @param {string[]} authors
* @param {string} libraryId
* @returns {Promise<{authorsRemoved: import('./Author')[], authorsAdded: import('./Author')[]}>}
*/
async updateAuthorsFromRequest(authors, libraryId) {
if (!Array.isArray(authors)) return null
if (!this.authors) {
throw new Error(`[Book] Cannot update authors because authors are not loaded for book ${this.id}`)
}
/** @type {typeof import('./Author')} */
const authorModel = this.sequelize.models.author
/** @type {typeof import('./BookAuthor')} */
const bookAuthorModel = this.sequelize.models.bookAuthor
const authorsCleaned = authors.map((a) => a.toLowerCase()).filter((a) => a)
const authorsRemoved = this.authors.filter((au) => !authorsCleaned.includes(au.name.toLowerCase()))
const newAuthorNames = authors.filter((a) => !this.authors.some((au) => au.name.toLowerCase() === a.toLowerCase()))
for (const author of authorsRemoved) {
await bookAuthorModel.removeByIds(author.id, this.id)
Logger.debug(`[Book] "${this.title}" Removed author "${author.name}"`)
this.authors = this.authors.filter((au) => au.id !== author.id)
}
const authorsAdded = []
for (const authorName of newAuthorNames) {
const author = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId)
await bookAuthorModel.create({ bookId: this.id, authorId: author.id })
Logger.debug(`[Book] "${this.title}" Added author "${author.name}"`)
this.authors.push(author)
authorsAdded.push(author)
}
return {
authorsRemoved,
authorsAdded
}
}
/**
* Creates or removes series from the book using the series names from the request.
* Updates series sequence if it has changed.
*
* @param {{ name: string, sequence: string }[]} seriesObjects
* @param {string} libraryId
* @returns {Promise<{seriesRemoved: import('./Series')[], seriesAdded: import('./Series')[], hasUpdates: boolean}>}
*/
async updateSeriesFromRequest(seriesObjects, libraryId) {
if (!Array.isArray(seriesObjects) || seriesObjects.some((se) => !se.name || typeof se.name !== 'string')) return null
if (!this.series) {
throw new Error(`[Book] Cannot update series because series are not loaded for book ${this.id}`)
}
/** @type {typeof import('./Series')} */
const seriesModel = this.sequelize.models.series
/** @type {typeof import('./BookSeries')} */
const bookSeriesModel = this.sequelize.models.bookSeries
const seriesNamesCleaned = seriesObjects.map((se) => se.name.toLowerCase())
const seriesRemoved = this.series.filter((se) => !seriesNamesCleaned.includes(se.name.toLowerCase()))
const seriesAdded = []
let hasUpdates = false
for (const seriesObj of seriesObjects) {
const seriesObjSequence = typeof seriesObj.sequence === 'string' ? seriesObj.sequence : null
const existingSeries = this.series.find((se) => se.name.toLowerCase() === seriesObj.name.toLowerCase())
if (existingSeries) {
if (existingSeries.bookSeries.sequence !== seriesObjSequence) {
existingSeries.bookSeries.sequence = seriesObjSequence
await existingSeries.bookSeries.save()
hasUpdates = true
Logger.debug(`[Book] "${this.title}" Updated series "${existingSeries.name}" sequence ${seriesObjSequence}`)
}
} else {
const series = await seriesModel.findOrCreateByNameAndLibrary(seriesObj.name, libraryId)
series.bookSeries = await bookSeriesModel.create({ bookId: this.id, seriesId: series.id, sequence: seriesObjSequence })
this.series.push(series)
seriesAdded.push(series)
hasUpdates = true
Logger.debug(`[Book] "${this.title}" Added series "${series.name}"`)
}
}
for (const series of seriesRemoved) {
await bookSeriesModel.removeByIds(series.id, this.id)
this.series = this.series.filter((se) => se.id !== series.id)
Logger.debug(`[Book] "${this.title}" Removed series ${series.id}`)
hasUpdates = true
}
return {
seriesRemoved,
seriesAdded,
hasUpdates
}
}
/**
* Old model kept metadata in a separate object
*/
oldMetadataToJSON() {
const authors = this.authors.map((au) => ({ id: au.id, name: au.name }))
const series = this.series.map((se) => ({ id: se.id, name: se.name, sequence: se.bookSeries.sequence }))
return {
title: this.title,
subtitle: this.subtitle,
authors,
narrators: [...(this.narrators || [])],
series,
genres: [...(this.genres || [])],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
publisher: this.publisher,
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: this.explicit,
abridged: this.abridged
}
}
oldMetadataToJSONMinified() {
return {
title: this.title,
titleIgnorePrefix: getTitlePrefixAtEnd(this.title),
subtitle: this.subtitle,
authorName: this.authorName,
authorNameLF: this.authorNameLF,
narratorName: (this.narrators || []).join(', '),
seriesName: this.seriesName,
genres: [...(this.genres || [])],
publishedYear: this.publishedYear,
publishedDate: this.publishedDate,
publisher: this.publisher,
description: this.description,
isbn: this.isbn,
asin: this.asin,
language: this.language,
explicit: this.explicit,
abridged: this.abridged
}
}
oldMetadataToJSONExpanded() {
const oldMetadataJSON = this.oldMetadataToJSON()
oldMetadataJSON.titleIgnorePrefix = getTitlePrefixAtEnd(this.title)
oldMetadataJSON.authorName = this.authorName
oldMetadataJSON.authorNameLF = this.authorNameLF
oldMetadataJSON.narratorName = (this.narrators || []).join(', ')
oldMetadataJSON.seriesName = this.seriesName
return oldMetadataJSON
}
/**
* The old model stored a minified series and authors array with the book object.
* Minified series is { id, name, sequence }
* Minified author is { id, name }
*
* @param {string} libraryItemId
*/
toOldJSON(libraryItemId) {
if (!libraryItemId) {
throw new Error(`[Book] Cannot convert to old JSON because libraryItemId is not provided`)
}
if (!this.authors) {
throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)
}
if (!this.series) {
throw new Error(`[Book] Cannot convert to old JSON because series are not loaded`)
}
return {
id: this.id,
libraryItemId: libraryItemId,
metadata: this.oldMetadataToJSON(),
coverPath: this.coverPath,
tags: [...(this.tags || [])],
audioFiles: structuredClone(this.audioFiles),
chapters: structuredClone(this.chapters),
ebookFile: structuredClone(this.ebookFile)
}
}
toOldJSONMinified() {
if (!this.authors) {
throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)
}
if (!this.series) {
throw new Error(`[Book] Cannot convert to old JSON because series are not loaded`)
}
return {
id: this.id,
metadata: this.oldMetadataToJSONMinified(),
coverPath: this.coverPath,
tags: [...(this.tags || [])],
numTracks: this.includedAudioFiles.length,
numAudioFiles: this.audioFiles?.length || 0,
numChapters: this.chapters?.length || 0,
duration: this.duration,
size: this.size,
ebookFormat: this.ebookFile?.ebookFormat
}
}
toOldJSONExpanded(libraryItemId) {
if (!libraryItemId) {
throw new Error(`[Book] Cannot convert to old JSON because libraryItemId is not provided`)
}
if (!this.authors) {
throw new Error(`[Book] Cannot convert to old JSON because authors are not loaded`)
}
if (!this.series) {
throw new Error(`[Book] Cannot convert to old JSON because series are not loaded`)
}
return {
id: this.id,
libraryItemId: libraryItemId,
metadata: this.oldMetadataToJSONExpanded(),
coverPath: this.coverPath,
tags: [...(this.tags || [])],
audioFiles: structuredClone(this.audioFiles),
chapters: structuredClone(this.chapters),
ebookFile: structuredClone(this.ebookFile),
duration: this.duration,
size: this.size,
tracks: this.getTracklist(libraryItemId)
}
}
}
module.exports = Book