Implementing toOld functions for LibraryItem/Book/Podcast

This commit is contained in:
advplyr 2025-01-02 12:49:58 -06:00
parent de8b0abc3a
commit dd0ebdf2d8
6 changed files with 682 additions and 253 deletions

View File

@ -24,6 +24,17 @@ const ShareManager = require('../managers/ShareManager')
* @property {import('../models/User')} user * @property {import('../models/User')} user
* *
* @typedef {Request & RequestUserObject} RequestWithUser * @typedef {Request & RequestUserObject} RequestWithUser
*
* @typedef RequestEntityObject
* @property {import('../models/LibraryItem')} libraryItem
* @property {Object} oldLibraryItem - To be removed
*
* @typedef {RequestWithUser & RequestEntityObject} LibraryItemControllerRequest
*
* @typedef RequestLibraryFileObject
* @property {import('../models/LibraryItem').LibraryFileObject} libraryFile
*
* @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile
*/ */
class LibraryItemController { class LibraryItemController {
@ -35,17 +46,17 @@ class LibraryItemController {
* ?include=progress,rssfeed,downloads,share * ?include=progress,rssfeed,downloads,share
* ?expanded=1 * ?expanded=1
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async findOne(req, res) { async findOne(req, res) {
const includeEntities = (req.query.include || '').split(',') const includeEntities = (req.query.include || '').split(',')
if (req.query.expanded == 1) { if (req.query.expanded == 1) {
var item = req.libraryItem.toJSONExpanded() const item = req.libraryItem.toOldJSONExpanded()
// Include users media progress // Include users media progress
if (includeEntities.includes('progress')) { if (includeEntities.includes('progress')) {
var episodeId = req.query.episode || null const episodeId = req.query.episode || null
item.userMediaProgress = req.user.getOldMediaProgress(item.id, episodeId) item.userMediaProgress = req.user.getOldMediaProgress(item.id, episodeId)
} }
@ -68,28 +79,27 @@ class LibraryItemController {
return res.json(item) return res.json(item)
} }
res.json(req.libraryItem) res.json(req.libraryItem.toOldJSON())
} }
/** /**
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async update(req, res) { async update(req, res) {
var libraryItem = req.libraryItem
// Item has cover and update is removing cover so purge it from cache // Item has cover and update is removing cover so purge it from cache
if (libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) { if (req.libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) {
await CacheManager.purgeCoverCache(libraryItem.id) await CacheManager.purgeCoverCache(req.libraryItem.id)
} }
const hasUpdates = libraryItem.update(req.body) const hasUpdates = req.oldLibraryItem.update(req.body)
if (hasUpdates) { if (hasUpdates) {
Logger.debug(`[LibraryItemController] Updated now saving`) Logger.debug(`[LibraryItemController] Updated now saving`)
await Database.updateLibraryItem(libraryItem) await Database.updateLibraryItem(req.oldLibraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded())
} }
res.json(libraryItem.toJSON()) res.json(req.oldLibraryItem.toJSON())
} }
/** /**
@ -100,7 +110,7 @@ class LibraryItemController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async delete(req, res) { async delete(req, res) {
@ -111,14 +121,14 @@ class LibraryItemController {
const authorIds = [] const authorIds = []
const seriesIds = [] const seriesIds = []
if (req.libraryItem.isPodcast) { if (req.libraryItem.isPodcast) {
mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id)) mediaItemIds.push(...req.libraryItem.media.podcastEpisodes.map((ep) => ep.id))
} else { } else {
mediaItemIds.push(req.libraryItem.media.id) mediaItemIds.push(req.libraryItem.media.id)
if (req.libraryItem.media.metadata.authors?.length) { if (req.libraryItem.media.authors?.length) {
authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id)) authorIds.push(...req.libraryItem.media.authors.map((au) => au.id))
} }
if (req.libraryItem.media.metadata.series?.length) { if (req.libraryItem.media.series?.length) {
seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id)) seriesIds.push(...req.libraryItem.media.series.map((se) => se.id))
} }
} }
@ -155,7 +165,7 @@ class LibraryItemController {
* GET: /api/items/:id/download * GET: /api/items/:id/download
* Download library item. Zip file if multiple files. * Download library item. Zip file if multiple files.
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async download(req, res) { async download(req, res) {
@ -164,7 +174,7 @@ class LibraryItemController {
return res.sendStatus(403) return res.sendStatus(403)
} }
const libraryItemPath = req.libraryItem.path const libraryItemPath = req.libraryItem.path
const itemTitle = req.libraryItem.media.metadata.title const itemTitle = req.libraryItem.media.title
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`) Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${itemTitle}" at "${libraryItemPath}"`)
@ -194,11 +204,10 @@ class LibraryItemController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async updateMedia(req, res) { async updateMedia(req, res) {
const libraryItem = req.libraryItem
const mediaPayload = req.body const mediaPayload = req.body
if (mediaPayload.url) { if (mediaPayload.url) {
@ -207,44 +216,44 @@ class LibraryItemController {
} }
// Book specific // Book specific
if (libraryItem.isBook) { if (req.libraryItem.isBook) {
await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId) await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, req.libraryItem.libraryId)
} }
// Podcast specific // Podcast specific
let isPodcastAutoDownloadUpdated = false let isPodcastAutoDownloadUpdated = false
if (libraryItem.isPodcast) { if (req.libraryItem.isPodcast) {
if (mediaPayload.autoDownloadEpisodes !== undefined && libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) { if (mediaPayload.autoDownloadEpisodes !== undefined && req.libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) {
isPodcastAutoDownloadUpdated = true isPodcastAutoDownloadUpdated = true
} else if (mediaPayload.autoDownloadSchedule !== undefined && libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) { } else if (mediaPayload.autoDownloadSchedule !== undefined && req.libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
isPodcastAutoDownloadUpdated = true isPodcastAutoDownloadUpdated = true
} }
} }
// Book specific - Get all series being removed from this item // Book specific - Get all series being removed from this item
let seriesRemoved = [] let seriesRemoved = []
if (libraryItem.isBook && mediaPayload.metadata?.series) { if (req.libraryItem.isBook && mediaPayload.metadata?.series) {
const seriesIdsInUpdate = mediaPayload.metadata.series?.map((se) => se.id) || [] const seriesIdsInUpdate = mediaPayload.metadata.series?.map((se) => se.id) || []
seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) seriesRemoved = req.oldLibraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
} }
let authorsRemoved = [] let authorsRemoved = []
if (libraryItem.isBook && mediaPayload.metadata?.authors) { if (req.libraryItem.isBook && mediaPayload.metadata?.authors) {
const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id) const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) authorsRemoved = req.oldLibraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
} }
const hasUpdates = libraryItem.media.update(mediaPayload) || mediaPayload.url const hasUpdates = req.oldLibraryItem.media.update(mediaPayload) || mediaPayload.url
if (hasUpdates) { if (hasUpdates) {
libraryItem.updatedAt = Date.now() req.oldLibraryItem.updatedAt = Date.now()
if (isPodcastAutoDownloadUpdated) { if (isPodcastAutoDownloadUpdated) {
this.cronManager.checkUpdatePodcastCron(libraryItem) this.cronManager.checkUpdatePodcastCron(req.oldLibraryItem)
} }
Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) Logger.debug(`[LibraryItemController] Updated library item media ${req.oldLibraryItem.media.metadata.title}`)
await Database.updateLibraryItem(libraryItem) await Database.updateLibraryItem(req.oldLibraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded())
if (authorsRemoved.length) { if (authorsRemoved.length) {
// Check remove empty authors // Check remove empty authors
@ -259,14 +268,14 @@ class LibraryItemController {
} }
res.json({ res.json({
updated: hasUpdates, updated: hasUpdates,
libraryItem libraryItem: req.oldLibraryItem
}) })
} }
/** /**
* POST: /api/items/:id/cover * POST: /api/items/:id/cover
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
* @param {boolean} [updateAndReturnJson=true] * @param {boolean} [updateAndReturnJson=true]
*/ */
@ -276,15 +285,13 @@ class LibraryItemController {
return res.sendStatus(403) return res.sendStatus(403)
} }
let libraryItem = req.libraryItem
let result = null let result = null
if (req.body?.url) { if (req.body?.url) {
Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`) Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`)
result = await CoverManager.downloadCoverFromUrl(libraryItem, req.body.url) result = await CoverManager.downloadCoverFromUrl(req.oldLibraryItem, req.body.url)
} else if (req.files?.cover) { } else if (req.files?.cover) {
Logger.debug(`[LibraryItemController] Handling uploaded cover`) Logger.debug(`[LibraryItemController] Handling uploaded cover`)
result = await CoverManager.uploadCover(libraryItem, req.files.cover) result = await CoverManager.uploadCover(req.oldLibraryItem, req.files.cover)
} else { } else {
return res.status(400).send('Invalid request no file or url') return res.status(400).send('Invalid request no file or url')
} }
@ -296,8 +303,8 @@ class LibraryItemController {
} }
if (updateAndReturnJson) { if (updateAndReturnJson) {
await Database.updateLibraryItem(libraryItem) await Database.updateLibraryItem(req.oldLibraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded())
res.json({ res.json({
success: true, success: true,
cover: result.cover cover: result.cover
@ -308,22 +315,21 @@ class LibraryItemController {
/** /**
* PATCH: /api/items/:id/cover * PATCH: /api/items/:id/cover
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async updateCover(req, res) { async updateCover(req, res) {
const libraryItem = req.libraryItem
if (!req.body.cover) { if (!req.body.cover) {
return res.status(400).send('Invalid request no cover path') return res.status(400).send('Invalid request no cover path')
} }
const validationResult = await CoverManager.validateCoverPath(req.body.cover, libraryItem) const validationResult = await CoverManager.validateCoverPath(req.body.cover, req.oldLibraryItem)
if (validationResult.error) { if (validationResult.error) {
return res.status(500).send(validationResult.error) return res.status(500).send(validationResult.error)
} }
if (validationResult.updated) { if (validationResult.updated) {
await Database.updateLibraryItem(libraryItem) await Database.updateLibraryItem(req.oldLibraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded())
} }
res.json({ res.json({
success: true, success: true,
@ -334,17 +340,15 @@ class LibraryItemController {
/** /**
* DELETE: /api/items/:id/cover * DELETE: /api/items/:id/cover
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async removeCover(req, res) { async removeCover(req, res) {
var libraryItem = req.libraryItem if (req.libraryItem.media.coverPath) {
req.oldLibraryItem.updateMediaCover('')
if (libraryItem.media.coverPath) { await CacheManager.purgeCoverCache(req.libraryItem.id)
libraryItem.updateMediaCover('') await Database.updateLibraryItem(req.oldLibraryItem)
await CacheManager.purgeCoverCache(libraryItem.id) SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded())
await Database.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
} }
res.sendStatus(200) res.sendStatus(200)
@ -353,7 +357,7 @@ class LibraryItemController {
/** /**
* GET: /api/items/:id/cover * GET: /api/items/:id/cover
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async getCover(req, res) { async getCover(req, res) {
@ -395,11 +399,11 @@ class LibraryItemController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
startPlaybackSession(req, res) { startPlaybackSession(req, res) {
if (!req.libraryItem.media.numTracks) { if (!req.libraryItem.hasAudioTracks) {
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`) Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`)
return res.sendStatus(404) return res.sendStatus(404)
} }
@ -412,18 +416,18 @@ class LibraryItemController {
* *
* @this {import('../routers/ApiRouter')} * @this {import('../routers/ApiRouter')}
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
startEpisodePlaybackSession(req, res) { startEpisodePlaybackSession(req, res) {
var libraryItem = req.libraryItem if (!req.libraryItem.isPodcast) {
if (!libraryItem.media.numTracks) { Logger.error(`[LibraryItemController] startEpisodePlaybackSession invalid media type ${req.libraryItem.id}`)
Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${libraryItem.id}`) return res.sendStatus(400)
return res.sendStatus(404)
} }
var episodeId = req.params.episodeId
if (!libraryItem.media.episodes.find((ep) => ep.id === episodeId)) { const episodeId = req.params.episodeId
Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${libraryItem.id}`) if (!req.libraryItem.media.podcastEpisodes.some((ep) => ep.id === episodeId)) {
Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${req.libraryItem.id}`)
return res.sendStatus(404) return res.sendStatus(404)
} }
@ -433,30 +437,34 @@ class LibraryItemController {
/** /**
* PATCH: /api/items/:id/tracks * PATCH: /api/items/:id/tracks
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async updateTracks(req, res) { async updateTracks(req, res) {
var libraryItem = req.libraryItem const orderedFileData = req.body?.orderedFileData
var orderedFileData = req.body.orderedFileData
if (!libraryItem.media.updateAudioTracks) { if (!req.libraryItem.isBook) {
Logger.error(`[LibraryItemController] updateTracks invalid media type ${libraryItem.id}`) Logger.error(`[LibraryItemController] updateTracks invalid media type ${req.libraryItem.id}`)
return res.sendStatus(500) return res.sendStatus(400)
} }
libraryItem.media.updateAudioTracks(orderedFileData) if (!Array.isArray(orderedFileData) || !orderedFileData.length) {
await Database.updateLibraryItem(libraryItem) Logger.error(`[LibraryItemController] updateTracks invalid orderedFileData ${req.libraryItem.id}`)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) return res.sendStatus(400)
res.json(libraryItem.toJSON()) }
req.oldLibraryItem.media.updateAudioTracks(orderedFileData)
await Database.updateLibraryItem(req.oldLibraryItem)
SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded())
res.json(req.oldLibraryItem.toJSON())
} }
/** /**
* POST /api/items/:id/match * POST /api/items/:id/match
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async match(req, res) { async match(req, res) {
const libraryItem = req.libraryItem
const reqBody = req.body || {} const reqBody = req.body || {}
const options = {} const options = {}
@ -473,7 +481,7 @@ class LibraryItemController {
options.overrideDetails = !!reqBody.overrideDetails options.overrideDetails = !!reqBody.overrideDetails
} }
var matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options) var matchResult = await Scanner.quickMatchLibraryItem(this, req.oldLibraryItem, options)
res.json(matchResult) res.json(matchResult)
} }
@ -741,7 +749,7 @@ class LibraryItemController {
/** /**
* POST: /api/items/:id/scan * POST: /api/items/:id/scan
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async scan(req, res) { async scan(req, res) {
@ -765,7 +773,7 @@ class LibraryItemController {
/** /**
* GET: /api/items/:id/metadata-object * GET: /api/items/:id/metadata-object
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
getMetadataObject(req, res) { getMetadataObject(req, res) {
@ -774,18 +782,18 @@ class LibraryItemController {
return res.sendStatus(403) return res.sendStatus(403)
} }
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) { if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.includedAudioFiles.length) {
Logger.error(`[LibraryItemController] Invalid library item`) Logger.error(`[LibraryItemController] Invalid library item`)
return res.sendStatus(500) return res.sendStatus(500)
} }
res.json(this.audioMetadataManager.getMetadataObjectForApi(req.libraryItem)) res.json(this.audioMetadataManager.getMetadataObjectForApi(req.oldLibraryItem))
} }
/** /**
* POST: /api/items/:id/chapters * POST: /api/items/:id/chapters
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async updateMediaChapters(req, res) { async updateMediaChapters(req, res) {
@ -794,7 +802,7 @@ class LibraryItemController {
return res.sendStatus(403) return res.sendStatus(403)
} }
if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) { if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.includedAudioFiles.length) {
Logger.error(`[LibraryItemController] Invalid library item`) Logger.error(`[LibraryItemController] Invalid library item`)
return res.sendStatus(500) return res.sendStatus(500)
} }
@ -805,10 +813,10 @@ class LibraryItemController {
} }
const chapters = req.body.chapters || [] const chapters = req.body.chapters || []
const wasUpdated = req.libraryItem.media.updateChapters(chapters) const wasUpdated = req.oldLibraryItem.media.updateChapters(chapters)
if (wasUpdated) { if (wasUpdated) {
await Database.updateLibraryItem(req.libraryItem) await Database.updateLibraryItem(req.oldLibraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded())
} }
res.json({ res.json({
@ -821,7 +829,7 @@ class LibraryItemController {
* GET: /api/items/:id/ffprobe/:fileid * GET: /api/items/:id/ffprobe/:fileid
* FFProbe JSON result from audio file * FFProbe JSON result from audio file
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequestWithFile} req
* @param {Response} res * @param {Response} res
*/ */
async getFFprobeData(req, res) { async getFFprobeData(req, res) {
@ -834,7 +842,7 @@ class LibraryItemController {
return res.sendStatus(400) return res.sendStatus(400)
} }
const audioFile = req.libraryItem.media.findFileWithInode(req.params.fileid) const audioFile = req.oldLibraryItem.media.findFileWithInode(req.params.fileid)
if (!audioFile) { if (!audioFile) {
Logger.error(`[LibraryItemController] Audio file not found with inode value ${req.params.fileid}`) Logger.error(`[LibraryItemController] Audio file not found with inode value ${req.params.fileid}`)
return res.sendStatus(404) return res.sendStatus(404)
@ -847,7 +855,7 @@ class LibraryItemController {
/** /**
* GET api/items/:id/file/:fileid * GET api/items/:id/file/:fileid
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequestWithFile} req
* @param {Response} res * @param {Response} res
*/ */
async getLibraryFile(req, res) { async getLibraryFile(req, res) {
@ -870,7 +878,7 @@ class LibraryItemController {
/** /**
* DELETE api/items/:id/file/:fileid * DELETE api/items/:id/file/:fileid
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequestWithFile} req
* @param {Response} res * @param {Response} res
*/ */
async deleteLibraryFile(req, res) { async deleteLibraryFile(req, res) {
@ -881,17 +889,17 @@ class LibraryItemController {
await fs.remove(libraryFile.metadata.path).catch((error) => { await fs.remove(libraryFile.metadata.path).catch((error) => {
Logger.error(`[LibraryItemController] Failed to delete library file at "${libraryFile.metadata.path}"`, error) Logger.error(`[LibraryItemController] Failed to delete library file at "${libraryFile.metadata.path}"`, error)
}) })
req.libraryItem.removeLibraryFile(req.params.fileid) req.oldLibraryItem.removeLibraryFile(req.params.fileid)
if (req.libraryItem.media.removeFileWithInode(req.params.fileid)) { if (req.oldLibraryItem.media.removeFileWithInode(req.params.fileid)) {
// If book has no more media files then mark it as missing // If book has no more media files then mark it as missing
if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaEntities) { if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaFiles) {
req.libraryItem.setMissing() req.oldLibraryItem.setMissing()
} }
} }
req.libraryItem.updatedAt = Date.now() req.oldLibraryItem.updatedAt = Date.now()
await Database.updateLibraryItem(req.libraryItem) await Database.updateLibraryItem(req.oldLibraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded())
res.sendStatus(200) res.sendStatus(200)
} }
@ -899,7 +907,7 @@ class LibraryItemController {
* GET api/items/:id/file/:fileid/download * GET api/items/:id/file/:fileid/download
* Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads * Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequestWithFile} req
* @param {Response} res * @param {Response} res
*/ */
async downloadLibraryFile(req, res) { async downloadLibraryFile(req, res) {
@ -911,7 +919,7 @@ class LibraryItemController {
return res.sendStatus(403) return res.sendStatus(403)
} }
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.metadata.title}" file at "${libraryFile.metadata.path}"`) Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.title}" file at "${libraryFile.metadata.path}"`)
if (global.XAccel) { if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + libraryFile.metadata.path) const encodedURI = encodeUriPath(global.XAccel + libraryFile.metadata.path)
@ -947,28 +955,28 @@ class LibraryItemController {
* fileid is only required when reading a supplementary ebook * fileid is only required when reading a supplementary ebook
* when no fileid is passed in the primary ebook will be returned * when no fileid is passed in the primary ebook will be returned
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async getEBookFile(req, res) { async getEBookFile(req, res) {
let ebookFile = null let ebookFile = null
if (req.params.fileid) { if (req.params.fileid) {
ebookFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid) ebookFile = req.oldLibraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
if (!ebookFile?.isEBookFile) { if (!ebookFile?.isEBookFile) {
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`) Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
return res.status(400).send('Invalid ebook file id') return res.status(400).send('Invalid ebook file id')
} }
} else { } else {
ebookFile = req.libraryItem.media.ebookFile ebookFile = req.oldLibraryItem.media.ebookFile
} }
if (!ebookFile) { if (!ebookFile) {
Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.metadata.title}"`) Logger.error(`[LibraryItemController] No ebookFile for library item "${req.libraryItem.media.title}"`)
return res.sendStatus(404) return res.sendStatus(404)
} }
const ebookFilePath = ebookFile.metadata.path const ebookFilePath = ebookFile.metadata.path
Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.metadata.title}" ebook at "${ebookFilePath}"`) Logger.info(`[LibraryItemController] User "${req.user.username}" requested download for item "${req.libraryItem.media.title}" ebook at "${ebookFilePath}"`)
if (global.XAccel) { if (global.XAccel) {
const encodedURI = encodeUriPath(global.XAccel + ebookFilePath) const encodedURI = encodeUriPath(global.XAccel + ebookFilePath)
@ -991,11 +999,11 @@ class LibraryItemController {
* if an ebook file is the primary ebook, then it will be changed to supplementary * if an ebook file is the primary ebook, then it will be changed to supplementary
* if an ebook file is supplementary, then it will be changed to primary * if an ebook file is supplementary, then it will be changed to primary
* *
* @param {RequestWithUser} req * @param {LibraryItemControllerRequest} req
* @param {Response} res * @param {Response} res
*/ */
async updateEbookFileStatus(req, res) { async updateEbookFileStatus(req, res) {
const ebookLibraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid) const ebookLibraryFile = req.oldLibraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
if (!ebookLibraryFile?.isEBookFile) { if (!ebookLibraryFile?.isEBookFile) {
Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`) Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`)
return res.status(400).send('Invalid ebook file id') return res.status(400).send('Invalid ebook file id')
@ -1003,16 +1011,16 @@ class LibraryItemController {
if (ebookLibraryFile.isSupplementary) { if (ebookLibraryFile.isSupplementary) {
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to primary`) Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to primary`)
req.libraryItem.setPrimaryEbook(ebookLibraryFile) req.oldLibraryItem.setPrimaryEbook(ebookLibraryFile)
} else { } else {
Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to supplementary`) Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to supplementary`)
ebookLibraryFile.isSupplementary = true ebookLibraryFile.isSupplementary = true
req.libraryItem.setPrimaryEbook(null) req.oldLibraryItem.setPrimaryEbook(null)
} }
req.libraryItem.updatedAt = Date.now() req.oldLibraryItem.updatedAt = Date.now()
await Database.updateLibraryItem(req.libraryItem) await Database.updateLibraryItem(req.oldLibraryItem)
SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded())
res.sendStatus(200) res.sendStatus(200)
} }
@ -1023,7 +1031,8 @@ class LibraryItemController {
* @param {NextFunction} next * @param {NextFunction} next
*/ */
async middleware(req, res, next) { async middleware(req, res, next) {
req.libraryItem = await Database.libraryItemModel.getOldById(req.params.id) req.libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id)
req.oldLibraryItem = await Database.libraryItemModel.getOldLibraryItem(req.libraryItem)
if (!req.libraryItem?.media) return res.sendStatus(404) if (!req.libraryItem?.media) return res.sendStatus(404)
// Check user can access this library item // Check user can access this library item
@ -1033,7 +1042,7 @@ class LibraryItemController {
// For library file routes, get the library file // For library file routes, get the library file
if (req.params.fileid) { if (req.params.fileid) {
req.libraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid) req.libraryFile = req.oldLibraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid)
if (!req.libraryFile) { if (!req.libraryFile) {
Logger.error(`[LibraryItemController] Library file "${req.params.fileid}" does not exist for library item`) Logger.error(`[LibraryItemController] Library file "${req.params.fileid}" does not exist for library item`)
return res.sendStatus(404) return res.sendStatus(404)

View File

@ -39,7 +39,7 @@ class PlaybackSessionManager {
/** /**
* *
* @param {import('../controllers/SessionController').RequestWithUser} req * @param {import('../controllers/LibraryItemController').LibraryItemControllerRequest} req
* @param {Object} [clientDeviceInfo] * @param {Object} [clientDeviceInfo]
* @returns {Promise<DeviceInfo>} * @returns {Promise<DeviceInfo>}
*/ */
@ -67,14 +67,14 @@ class PlaybackSessionManager {
/** /**
* *
* @param {import('../controllers/SessionController').RequestWithUser} req * @param {import('../controllers/LibraryItemController').LibraryItemControllerRequest} req
* @param {import('express').Response} res * @param {import('express').Response} res
* @param {string} [episodeId] * @param {string} [episodeId]
*/ */
async startSessionRequest(req, res, episodeId) { async startSessionRequest(req, res, episodeId) {
const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo) const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo)
Logger.debug(`[PlaybackSessionManager] startSessionRequest for device ${deviceInfo.deviceDescription}`) Logger.debug(`[PlaybackSessionManager] startSessionRequest for device ${deviceInfo.deviceDescription}`)
const { libraryItem, body: options } = req const { oldLibraryItem: libraryItem, body: options } = req
const session = await this.startSession(req.user, deviceInfo, libraryItem, episodeId, options) const session = await this.startSession(req.user, deviceInfo, libraryItem, episodeId, options)
res.json(session.toJSONForClient(libraryItem)) res.json(session.toJSONForClient(libraryItem))
} }

View File

@ -1,5 +1,7 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const Logger = require('../Logger') const Logger = require('../Logger')
const { getTitlePrefixAtEnd } = require('../utils')
const parseNameString = require('../utils/parsers/parseNameString')
/** /**
* @typedef EBookFileObject * @typedef EBookFileObject
@ -113,8 +115,12 @@ class Book extends Model {
/** @type {Date} */ /** @type {Date} */
this.createdAt this.createdAt
// Expanded properties
/** @type {import('./Author')[]} - optional if expanded */ /** @type {import('./Author')[]} - optional if expanded */
this.authors this.authors
/** @type {import('./Series')[]} - optional if expanded */
this.series
} }
static getOldBook(libraryItemExpanded) { static getOldBook(libraryItemExpanded) {
@ -241,32 +247,6 @@ class Book extends Model {
} }
} }
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
}
}
/** /**
* Initialize model * Initialize model
* @param {import('../Database').sequelize} sequelize * @param {import('../Database').sequelize} sequelize
@ -343,9 +323,50 @@ class Book extends Model {
} }
return this.authors.map((au) => au.name).join(', ') 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() { get includedAudioFiles() {
return this.audioFiles.filter((af) => !af.exclude) return this.audioFiles.filter((af) => !af.exclude)
} }
get trackList() { get trackList() {
let startOffset = 0 let startOffset = 0
return this.includedAudioFiles.map((af) => { return this.includedAudioFiles.map((af) => {
@ -355,6 +376,189 @@ class Book extends Model {
return track return track
}) })
} }
get hasMediaFiles() {
return !!this.hasAudioTracks || !!this.ebookFile
}
get hasAudioTracks() {
return !!this.includedAudioFiles.length
}
/**
* 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
}
}
/**
* 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.trackList.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: structuredClone(this.trackList)
}
}
} }
module.exports = Book module.exports = Book

View File

@ -865,54 +865,6 @@ class LibraryItem extends Model {
return libraryItem.media.coverPath return libraryItem.media.coverPath
} }
/**
*
* @param {import('sequelize').FindOptions} options
* @returns {Promise<Book|Podcast>}
*/
getMedia(options) {
if (!this.mediaType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaType)}`
return this[mixinMethodName](options)
}
/**
*
* @returns {Promise<Book|Podcast>}
*/
getMediaExpanded() {
if (this.mediaType === 'podcast') {
return this.getMedia({
include: [
{
model: this.sequelize.models.podcastEpisode
}
]
})
} else {
return this.getMedia({
include: [
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
],
order: [
[this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
[this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
]
})
}
}
/** /**
* *
* @returns {Promise} * @returns {Promise}
@ -1131,6 +1083,64 @@ class LibraryItem extends Model {
}) })
} }
get isBook() {
return this.mediaType === 'book'
}
get isPodcast() {
return this.mediaType === 'podcast'
}
get hasAudioTracks() {
return this.media.hasAudioTracks()
}
/**
*
* @param {import('sequelize').FindOptions} options
* @returns {Promise<Book|Podcast>}
*/
getMedia(options) {
if (!this.mediaType) return Promise.resolve(null)
const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaType)}`
return this[mixinMethodName](options)
}
/**
*
* @returns {Promise<Book|Podcast>}
*/
getMediaExpanded() {
if (this.mediaType === 'podcast') {
return this.getMedia({
include: [
{
model: this.sequelize.models.podcastEpisode
}
]
})
} else {
return this.getMedia({
include: [
{
model: this.sequelize.models.author,
through: {
attributes: []
}
},
{
model: this.sequelize.models.series,
through: {
attributes: ['sequence']
}
}
],
order: [
[this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
[this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
]
})
}
}
/** /**
* Check if book or podcast library item has audio tracks * Check if book or podcast library item has audio tracks
* Requires expanded library item * Requires expanded library item
@ -1148,6 +1158,89 @@ class LibraryItem extends Model {
return this.media.podcastEpisodes?.length > 0 return this.media.podcastEpisodes?.length > 0
} }
} }
toOldJSON() {
if (!this.media) {
throw new Error(`[LibraryItem] Cannot convert to old JSON without media for library item "${this.id}"`)
}
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.extraData?.oldLibraryItemId || null,
libraryId: this.libraryId,
folderId: this.libraryFolderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtime?.valueOf(),
ctimeMs: this.ctime?.valueOf(),
birthtimeMs: this.birthtime?.valueOf(),
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf(),
lastScan: this.lastScan?.valueOf(),
scanVersion: this.lastScanVersion,
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toOldJSON(this.id),
libraryFiles: structuredClone(this.libraryFiles)
}
}
toOldJSONMinified() {
if (!this.media) {
throw new Error(`[LibraryItem] Cannot convert to old JSON without media for library item "${this.id}"`)
}
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.extraData?.oldLibraryItemId || null,
libraryId: this.libraryId,
folderId: this.libraryFolderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtime?.valueOf(),
ctimeMs: this.ctime?.valueOf(),
birthtimeMs: this.birthtime?.valueOf(),
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf(),
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toOldJSONMinified(),
numFiles: this.libraryFiles.length,
size: this.size
}
}
toOldJSONExpanded() {
return {
id: this.id,
ino: this.ino,
oldLibraryItemId: this.extraData?.oldLibraryItemId || null,
libraryId: this.libraryId,
folderId: this.libraryFolderId,
path: this.path,
relPath: this.relPath,
isFile: this.isFile,
mtimeMs: this.mtime?.valueOf(),
ctimeMs: this.ctime?.valueOf(),
birthtimeMs: this.birthtime?.valueOf(),
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf(),
lastScan: this.lastScan?.valueOf(),
scanVersion: this.lastScanVersion,
isMissing: !!this.isMissing,
isInvalid: !!this.isInvalid,
mediaType: this.mediaType,
media: this.media.toOldJSONExpanded(this.id),
libraryFiles: structuredClone(this.libraryFiles),
size: this.size
}
}
} }
module.exports = LibraryItem module.exports = LibraryItem

View File

@ -1,4 +1,5 @@
const { DataTypes, Model } = require('sequelize') const { DataTypes, Model } = require('sequelize')
const { getTitlePrefixAtEnd } = require('../utils')
/** /**
* @typedef PodcastExpandedProperties * @typedef PodcastExpandedProperties
@ -47,6 +48,8 @@ class Podcast extends Model {
this.lastEpisodeCheck this.lastEpisodeCheck
/** @type {number} */ /** @type {number} */
this.maxEpisodesToKeep this.maxEpisodesToKeep
/** @type {number} */
this.maxNewEpisodesToDownload
/** @type {string} */ /** @type {string} */
this.coverPath this.coverPath
/** @type {string[]} */ /** @type {string[]} */
@ -57,6 +60,9 @@ class Podcast extends Model {
this.createdAt this.createdAt
/** @type {Date} */ /** @type {Date} */
this.updatedAt this.updatedAt
/** @type {import('./PodcastEpisode')[]} */
this.podcastEpisodes
} }
static getOldPodcast(libraryItemExpanded) { static getOldPodcast(libraryItemExpanded) {
@ -119,25 +125,6 @@ class Podcast extends Model {
} }
} }
getAbsMetadataJson() {
return {
tags: this.tags || [],
title: this.title,
author: this.author,
description: this.description,
releaseDate: this.releaseDate,
genres: this.genres || [],
feedURL: this.feedURL,
imageURL: this.imageURL,
itunesPageURL: this.itunesPageURL,
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
language: this.language,
explicit: !!this.explicit,
podcastType: this.podcastType
}
}
/** /**
* Initialize model * Initialize model
* @param {import('../Database').sequelize} sequelize * @param {import('../Database').sequelize} sequelize
@ -179,6 +166,134 @@ class Podcast extends Model {
} }
) )
} }
get hasMediaFiles() {
return !!this.podcastEpisodes?.length
}
get hasAudioTracks() {
return this.hasMediaFiles
}
get size() {
if (!this.podcastEpisodes?.length) return 0
return this.podcastEpisodes.reduce((total, episode) => total + episode.size, 0)
}
getAbsMetadataJson() {
return {
tags: this.tags || [],
title: this.title,
author: this.author,
description: this.description,
releaseDate: this.releaseDate,
genres: this.genres || [],
feedURL: this.feedURL,
imageURL: this.imageURL,
itunesPageURL: this.itunesPageURL,
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
language: this.language,
explicit: !!this.explicit,
podcastType: this.podcastType
}
}
/**
* Old model kept metadata in a separate object
*/
oldMetadataToJSON() {
return {
title: this.title,
author: this.author,
description: this.description,
releaseDate: this.releaseDate,
genres: [...(this.genres || [])],
feedUrl: this.feedURL,
imageUrl: this.imageURL,
itunesPageUrl: this.itunesPageURL,
itunesId: this.itunesId,
itunesArtistId: this.itunesArtistId,
explicit: this.explicit,
language: this.language,
type: this.podcastType
}
}
oldMetadataToJSONExpanded() {
const oldMetadataJSON = this.oldMetadataToJSON()
oldMetadataJSON.titleIgnorePrefix = getTitlePrefixAtEnd(this.title)
return oldMetadataJSON
}
/**
* The old model stored episodes with the podcast object
*
* @param {string} libraryItemId
*/
toOldJSON(libraryItemId) {
if (!libraryItemId) {
throw new Error(`[Podcast] Cannot convert to old JSON because libraryItemId is not provided`)
}
if (!this.podcastEpisodes) {
throw new Error(`[Podcast] Cannot convert to old JSON because episodes are not provided`)
}
return {
id: this.id,
libraryItemId: libraryItemId,
metadata: this.oldMetadataToJSON(),
coverPath: this.coverPath,
tags: [...(this.tags || [])],
episodes: this.podcastEpisodes.map((episode) => episode.toOldJSON(libraryItemId)),
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
maxEpisodesToKeep: this.maxEpisodesToKeep,
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload
}
}
toOldJSONMinified() {
return {
id: this.id,
// Minified metadata and expanded metadata are the same
metadata: this.oldMetadataToJSONExpanded(),
coverPath: this.coverPath,
tags: [...(this.tags || [])],
numEpisodes: this.podcastEpisodes?.length || 0,
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
maxEpisodesToKeep: this.maxEpisodesToKeep,
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
size: this.size
}
}
toOldJSONExpanded(libraryItemId) {
if (!libraryItemId) {
throw new Error(`[Podcast] Cannot convert to old JSON because libraryItemId is not provided`)
}
if (!this.podcastEpisodes) {
throw new Error(`[Podcast] Cannot convert to old JSON because episodes are not provided`)
}
return {
id: this.id,
libraryItemId: libraryItemId,
metadata: this.oldMetadataToJSONExpanded(),
coverPath: this.coverPath,
tags: [...(this.tags || [])],
episodes: this.podcastEpisodes.map((e) => e.toOldJSONExpanded(libraryItemId)),
autoDownloadEpisodes: this.autoDownloadEpisodes,
autoDownloadSchedule: this.autoDownloadSchedule,
lastEpisodeCheck: this.lastEpisodeCheck?.valueOf() || null,
maxEpisodesToKeep: this.maxEpisodesToKeep,
maxNewEpisodesToDownload: this.maxNewEpisodesToDownload,
size: this.size
}
}
} }
module.exports = Podcast module.exports = Podcast

View File

@ -53,42 +53,6 @@ class PodcastEpisode extends Model {
this.updatedAt this.updatedAt
} }
/**
* @param {string} libraryItemId
* @returns {oldPodcastEpisode}
*/
getOldPodcastEpisode(libraryItemId = null) {
let enclosure = null
if (this.enclosureURL) {
enclosure = {
url: this.enclosureURL,
type: this.enclosureType,
length: this.enclosureSize !== null ? String(this.enclosureSize) : null
}
}
return new oldPodcastEpisode({
libraryItemId: libraryItemId || null,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.extraData?.oldEpisodeId || null,
index: this.index,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
title: this.title,
subtitle: this.subtitle,
description: this.description,
enclosure,
guid: this.extraData?.guid || null,
pubDate: this.pubDate,
chapters: this.chapters,
audioFile: this.audioFile,
publishedAt: this.publishedAt?.valueOf() || null,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
})
}
static createFromOld(oldEpisode) { static createFromOld(oldEpisode) {
const podcastEpisode = this.getFromOld(oldEpisode) const podcastEpisode = this.getFromOld(oldEpisode)
return this.create(podcastEpisode) return this.create(podcastEpisode)
@ -184,7 +148,51 @@ class PodcastEpisode extends Model {
return track return track
} }
get size() {
return this.audioFile?.metadata.size || 0
}
/**
* @param {string} libraryItemId
* @returns {oldPodcastEpisode}
*/
getOldPodcastEpisode(libraryItemId = null) {
let enclosure = null
if (this.enclosureURL) {
enclosure = {
url: this.enclosureURL,
type: this.enclosureType,
length: this.enclosureSize !== null ? String(this.enclosureSize) : null
}
}
return new oldPodcastEpisode({
libraryItemId: libraryItemId || null,
podcastId: this.podcastId,
id: this.id,
oldEpisodeId: this.extraData?.oldEpisodeId || null,
index: this.index,
season: this.season,
episode: this.episode,
episodeType: this.episodeType,
title: this.title,
subtitle: this.subtitle,
description: this.description,
enclosure,
guid: this.extraData?.guid || null,
pubDate: this.pubDate,
chapters: this.chapters,
audioFile: this.audioFile,
publishedAt: this.publishedAt?.valueOf() || null,
addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf()
})
}
toOldJSON(libraryItemId) { toOldJSON(libraryItemId) {
if (!libraryItemId) {
throw new Error(`[PodcastEpisode] Cannot convert to old JSON because libraryItemId is not provided`)
}
let enclosure = null let enclosure = null
if (this.enclosureURL) { if (this.enclosureURL) {
enclosure = { enclosure = {
@ -209,8 +217,8 @@ class PodcastEpisode extends Model {
enclosure, enclosure,
guid: this.extraData?.guid || null, guid: this.extraData?.guid || null,
pubDate: this.pubDate, pubDate: this.pubDate,
chapters: this.chapters?.map((ch) => ({ ...ch })) || [], chapters: structuredClone(this.chapters),
audioFile: this.audioFile || null, audioFile: structuredClone(this.audioFile),
publishedAt: this.publishedAt?.valueOf() || null, publishedAt: this.publishedAt?.valueOf() || null,
addedAt: this.createdAt.valueOf(), addedAt: this.createdAt.valueOf(),
updatedAt: this.updatedAt.valueOf() updatedAt: this.updatedAt.valueOf()
@ -221,7 +229,7 @@ class PodcastEpisode extends Model {
const json = this.toOldJSON(libraryItemId) const json = this.toOldJSON(libraryItemId)
json.audioTrack = this.track json.audioTrack = this.track
json.size = this.audioFile?.metadata.size || 0 json.size = this.size
json.duration = this.audioFile?.duration || 0 json.duration = this.audioFile?.duration || 0
return json return json