2023-07-05 01:14:44 +02:00
|
|
|
const { DataTypes, Model } = require('sequelize')
|
|
|
|
const Logger = require('../Logger')
|
2025-01-03 00:21:07 +01:00
|
|
|
const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
|
2025-01-02 19:49:58 +01:00
|
|
|
const parseNameString = require('../utils/parsers/parseNameString')
|
2023-07-05 01:14:44 +02:00
|
|
|
|
2023-08-25 00:55:29 +02:00
|
|
|
/**
|
|
|
|
* @typedef EBookFileObject
|
|
|
|
* @property {string} ino
|
|
|
|
* @property {string} ebookFormat
|
|
|
|
* @property {number} addedAt
|
|
|
|
* @property {number} updatedAt
|
2024-03-21 20:38:52 +01:00
|
|
|
* @property {{filename:string, ext:string, path:string, relPath:strFing, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
|
2023-08-25 00:55:29 +02:00
|
|
|
*/
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @typedef ChapterObject
|
|
|
|
* @property {number} id
|
|
|
|
* @property {number} start
|
|
|
|
* @property {number} end
|
|
|
|
* @property {string} title
|
|
|
|
*/
|
|
|
|
|
2024-01-16 23:31:16 +01:00
|
|
|
/**
|
|
|
|
* @typedef SeriesExpandedProperties
|
|
|
|
* @property {{sequence:string}} bookSeries
|
2024-05-29 00:24:02 +02:00
|
|
|
*
|
2024-01-16 23:31:16 +01:00
|
|
|
* @typedef {import('./Series') & SeriesExpandedProperties} SeriesExpanded
|
2024-05-29 00:24:02 +02:00
|
|
|
*
|
2024-01-16 23:31:16 +01:00
|
|
|
* @typedef BookExpandedProperties
|
|
|
|
* @property {import('./Author')[]} authors
|
|
|
|
* @property {SeriesExpanded[]} series
|
2024-05-29 00:24:02 +02:00
|
|
|
*
|
2024-01-16 23:31:16 +01:00
|
|
|
* @typedef {Book & BookExpandedProperties} BookExpanded
|
2024-12-15 17:53:31 +01:00
|
|
|
*
|
|
|
|
* Collections use BookExpandedWithLibraryItem
|
|
|
|
* @typedef BookExpandedWithLibraryItemProperties
|
|
|
|
* @property {import('./LibraryItem')} libraryItem
|
|
|
|
*
|
|
|
|
* @typedef {BookExpanded & BookExpandedWithLibraryItemProperties} BookExpandedWithLibraryItem
|
2024-01-16 23:31:16 +01:00
|
|
|
*/
|
|
|
|
|
2023-08-28 00:19:57 +02:00
|
|
|
/**
|
|
|
|
* @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
|
2025-01-03 18:16:03 +01:00
|
|
|
*
|
|
|
|
* @typedef AudioTrackProperties
|
|
|
|
* @property {string} title
|
|
|
|
* @property {string} contentUrl
|
|
|
|
* @property {number} startOffset
|
|
|
|
*
|
|
|
|
* @typedef {AudioFileObject & AudioTrackProperties} AudioTrack
|
2023-08-28 00:19:57 +02:00
|
|
|
*/
|
|
|
|
|
2023-08-16 01:03:43 +02:00
|
|
|
class Book extends Model {
|
|
|
|
constructor(values, options) {
|
|
|
|
super(values, options)
|
2023-07-29 01:03:31 +02:00
|
|
|
|
2023-08-25 00:55:29 +02:00
|
|
|
/** @type {string} */
|
2023-08-16 01:03:43 +02:00
|
|
|
this.id
|
|
|
|
/** @type {string} */
|
|
|
|
this.title
|
|
|
|
/** @type {string} */
|
|
|
|
this.titleIgnorePrefix
|
|
|
|
/** @type {string} */
|
2024-01-16 23:31:16 +01:00
|
|
|
this.subtitle
|
|
|
|
/** @type {string} */
|
2023-08-16 01:03:43 +02:00
|
|
|
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
|
2023-08-25 00:55:29 +02:00
|
|
|
/** @type {string[]} */
|
2023-08-16 01:03:43 +02:00
|
|
|
this.narrators
|
2023-08-28 00:19:57 +02:00
|
|
|
/** @type {AudioFileObject[]} */
|
2023-08-16 01:03:43 +02:00
|
|
|
this.audioFiles
|
2023-08-25 00:55:29 +02:00
|
|
|
/** @type {EBookFileObject} */
|
2023-08-16 01:03:43 +02:00
|
|
|
this.ebookFile
|
2023-08-25 00:55:29 +02:00
|
|
|
/** @type {ChapterObject[]} */
|
2023-08-16 01:03:43 +02:00
|
|
|
this.chapters
|
2023-08-25 00:55:29 +02:00
|
|
|
/** @type {string[]} */
|
2023-08-16 01:03:43 +02:00
|
|
|
this.tags
|
2023-08-25 00:55:29 +02:00
|
|
|
/** @type {string[]} */
|
2023-08-16 01:03:43 +02:00
|
|
|
this.genres
|
|
|
|
/** @type {Date} */
|
|
|
|
this.updatedAt
|
|
|
|
/** @type {Date} */
|
|
|
|
this.createdAt
|
2024-12-14 23:55:56 +01:00
|
|
|
|
2025-01-02 19:49:58 +01:00
|
|
|
// Expanded properties
|
|
|
|
|
2024-12-14 23:55:56 +01:00
|
|
|
/** @type {import('./Author')[]} - optional if expanded */
|
|
|
|
this.authors
|
2025-01-02 19:49:58 +01:00
|
|
|
/** @type {import('./Series')[]} - optional if expanded */
|
|
|
|
this.series
|
2023-08-16 01:03:43 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Initialize model
|
2024-05-29 00:24:02 +02:00
|
|
|
* @param {import('../Database').sequelize} sequelize
|
2023-08-16 01:03:43 +02:00
|
|
|
*/
|
|
|
|
static init(sequelize) {
|
2024-05-29 00:24:02 +02:00
|
|
|
super.init(
|
|
|
|
{
|
|
|
|
id: {
|
|
|
|
type: DataTypes.UUID,
|
|
|
|
defaultValue: DataTypes.UUIDV4,
|
|
|
|
primaryKey: true
|
2023-08-16 01:03:43 +02:00
|
|
|
},
|
2024-05-29 00:24:02 +02:00
|
|
|
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']
|
2025-01-01 07:37:57 +01:00
|
|
|
},
|
|
|
|
{
|
|
|
|
fields: ['duration']
|
2024-05-29 00:24:02 +02:00
|
|
|
}
|
|
|
|
]
|
|
|
|
}
|
|
|
|
)
|
2023-08-16 01:03:43 +02:00
|
|
|
}
|
2024-12-14 23:55:56 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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(', ')
|
|
|
|
}
|
2025-01-02 19:49:58 +01:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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(', ')
|
|
|
|
}
|
|
|
|
|
2024-12-14 23:55:56 +01:00
|
|
|
get includedAudioFiles() {
|
|
|
|
return this.audioFiles.filter((af) => !af.exclude)
|
|
|
|
}
|
2025-01-02 19:49:58 +01:00
|
|
|
|
2025-01-03 18:16:03 +01:00
|
|
|
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) {
|
2024-12-14 23:55:56 +01:00
|
|
|
let startOffset = 0
|
|
|
|
return this.includedAudioFiles.map((af) => {
|
|
|
|
const track = structuredClone(af)
|
2025-01-03 18:16:03 +01:00
|
|
|
track.title = af.metadata.filename
|
2024-12-14 23:55:56 +01:00
|
|
|
track.startOffset = startOffset
|
2025-01-03 18:16:03 +01:00
|
|
|
track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}`
|
2024-12-14 23:55:56 +01:00
|
|
|
startOffset += track.duration
|
|
|
|
return track
|
|
|
|
})
|
|
|
|
}
|
2025-01-02 19:49:58 +01:00
|
|
|
|
2025-01-03 18:16:03 +01:00
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @returns {ChapterObject[]}
|
|
|
|
*/
|
|
|
|
getChapters() {
|
|
|
|
return structuredClone(this.chapters) || []
|
2025-01-02 19:49:58 +01:00
|
|
|
}
|
|
|
|
|
2025-01-03 18:16:03 +01:00
|
|
|
getPlaybackTitle() {
|
|
|
|
return this.title
|
|
|
|
}
|
|
|
|
|
|
|
|
getPlaybackAuthor() {
|
|
|
|
return this.authorName
|
|
|
|
}
|
|
|
|
|
|
|
|
getPlaybackDuration() {
|
|
|
|
return this.duration
|
2025-01-02 19:49:58 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-01-03 00:21:07 +01:00
|
|
|
/**
|
|
|
|
*
|
|
|
|
* @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()
|
|
|
|
}
|
|
|
|
|
2025-01-05 19:05:01 +01:00
|
|
|
return hasUpdates
|
|
|
|
}
|
2025-01-03 00:21:07 +01:00
|
|
|
|
2025-01-05 19:05:01 +01:00
|
|
|
/**
|
|
|
|
* 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}`)
|
2025-01-03 00:21:07 +01:00
|
|
|
}
|
|
|
|
|
2025-01-05 19:05:01 +01:00
|
|
|
/** @type {typeof import('./Author')} */
|
|
|
|
const authorModel = this.sequelize.models.author
|
2025-01-03 00:21:07 +01:00
|
|
|
|
2025-01-05 19:05:01 +01:00
|
|
|
/** @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()
|
2025-01-03 00:21:07 +01:00
|
|
|
hasUpdates = true
|
2025-01-05 19:05:01 +01:00
|
|
|
Logger.debug(`[Book] "${this.title}" Updated series "${existingSeries.name}" sequence ${seriesObjSequence}`)
|
2025-01-03 00:21:07 +01:00
|
|
|
}
|
2025-01-05 19:05:01 +01:00
|
|
|
} 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}"`)
|
2025-01-03 00:21:07 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-01-05 19:05:01 +01:00
|
|
|
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
|
|
|
|
}
|
2025-01-03 00:21:07 +01:00
|
|
|
}
|
|
|
|
|
2025-01-02 19:49:58 +01:00
|
|
|
/**
|
|
|
|
* 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 || [])],
|
2025-01-03 18:16:03 +01:00
|
|
|
numTracks: this.includedAudioFiles.length,
|
2025-01-02 19:49:58 +01:00
|
|
|
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,
|
2025-01-03 18:16:03 +01:00
|
|
|
tracks: this.getTracklist(libraryItemId)
|
2025-01-02 19:49:58 +01:00
|
|
|
}
|
|
|
|
}
|
2023-08-16 01:03:43 +02:00
|
|
|
}
|
2023-07-05 01:14:44 +02:00
|
|
|
|
2024-05-29 00:24:02 +02:00
|
|
|
module.exports = Book
|