diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 17c7be8387..f08a60115b 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -24,6 +24,16 @@ const ShareManager = require('../managers/ShareManager') * @property {import('../models/User')} user * * @typedef {Request & RequestUserObject} RequestWithUser + * + * @typedef RequestEntityObject + * @property {import('../models/LibraryItem')} libraryItem + * + * @typedef {RequestWithUser & RequestEntityObject} LibraryItemControllerRequest + * + * @typedef RequestLibraryFileObject + * @property {import('../objects/files/LibraryFile')} libraryFile + * + * @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile */ class LibraryItemController { @@ -35,17 +45,17 @@ class LibraryItemController { * ?include=progress,rssfeed,downloads,share * ?expanded=1 * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async findOne(req, res) { const includeEntities = (req.query.include || '').split(',') if (req.query.expanded == 1) { - var item = req.libraryItem.toJSONExpanded() + const item = req.libraryItem.toOldJSONExpanded() // Include users media 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) } @@ -68,28 +78,32 @@ class LibraryItemController { return res.json(item) } - res.json(req.libraryItem) + res.json(req.libraryItem.toOldJSON()) } /** + * PATCH: /api/items/:id * - * @param {RequestWithUser} req + * @deprecated + * Use the updateMedia /api/items/:id/media endpoint instead or updateCover /api/items/:id/cover + * + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async update(req, res) { - var libraryItem = req.libraryItem // 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)) { - await CacheManager.purgeCoverCache(libraryItem.id) + if (req.libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) { + await CacheManager.purgeCoverCache(req.libraryItem.id) } - const hasUpdates = libraryItem.update(req.body) + const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem) + const hasUpdates = oldLibraryItem.update(req.body) if (hasUpdates) { Logger.debug(`[LibraryItemController] Updated now saving`) - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) + await Database.updateLibraryItem(oldLibraryItem) + SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) } - res.json(libraryItem.toJSON()) + res.json(oldLibraryItem.toJSON()) } /** @@ -100,7 +114,7 @@ class LibraryItemController { * * @this {import('../routers/ApiRouter')} * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async delete(req, res) { @@ -111,14 +125,14 @@ class LibraryItemController { const authorIds = [] const seriesIds = [] 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 { mediaItemIds.push(req.libraryItem.media.id) - if (req.libraryItem.media.metadata.authors?.length) { - authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id)) + if (req.libraryItem.media.authors?.length) { + authorIds.push(...req.libraryItem.media.authors.map((au) => au.id)) } - if (req.libraryItem.media.metadata.series?.length) { - seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id)) + if (req.libraryItem.media.series?.length) { + seriesIds.push(...req.libraryItem.media.series.map((se) => se.id)) } } @@ -155,7 +169,7 @@ class LibraryItemController { * GET: /api/items/:id/download * Download library item. Zip file if multiple files. * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async download(req, res) { @@ -164,7 +178,7 @@ class LibraryItemController { return res.sendStatus(403) } 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}"`) @@ -194,11 +208,10 @@ class LibraryItemController { * * @this {import('../routers/ApiRouter')} * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async updateMedia(req, res) { - const libraryItem = req.libraryItem const mediaPayload = req.body if (mediaPayload.url) { @@ -207,44 +220,41 @@ class LibraryItemController { } // Book specific - if (libraryItem.isBook) { - await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId) + if (req.libraryItem.isBook) { + await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, req.libraryItem.libraryId) } // Podcast specific let isPodcastAutoDownloadUpdated = false - if (libraryItem.isPodcast) { - if (mediaPayload.autoDownloadEpisodes !== undefined && libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) { + if (req.libraryItem.isPodcast) { + if (mediaPayload.autoDownloadEpisodes !== undefined && req.libraryItem.media.autoDownloadEpisodes !== mediaPayload.autoDownloadEpisodes) { 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 } } // Book specific - Get all series being removed from this item let seriesRemoved = [] - if (libraryItem.isBook && mediaPayload.metadata?.series) { + if (req.libraryItem.isBook && mediaPayload.metadata?.series) { const seriesIdsInUpdate = mediaPayload.metadata.series?.map((se) => se.id) || [] - seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) + seriesRemoved = req.libraryItem.media.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) } let authorsRemoved = [] - if (libraryItem.isBook && mediaPayload.metadata?.authors) { + if (req.libraryItem.isBook && mediaPayload.metadata?.authors) { const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id) - authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) + authorsRemoved = req.libraryItem.media.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) } - const hasUpdates = libraryItem.media.update(mediaPayload) || mediaPayload.url + const hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url if (hasUpdates) { - libraryItem.updatedAt = Date.now() - if (isPodcastAutoDownloadUpdated) { - this.cronManager.checkUpdatePodcastCron(libraryItem) + this.cronManager.checkUpdatePodcastCron(req.libraryItem) } - Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) + Logger.debug(`[LibraryItemController] Updated library item media ${req.libraryItem.media.title}`) + SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) if (authorsRemoved.length) { // Check remove empty authors @@ -259,14 +269,14 @@ class LibraryItemController { } res.json({ updated: hasUpdates, - libraryItem + libraryItem: req.libraryItem.toOldJSON() }) } /** * POST: /api/items/:id/cover * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res * @param {boolean} [updateAndReturnJson=true] */ @@ -276,15 +286,13 @@ class LibraryItemController { return res.sendStatus(403) } - let libraryItem = req.libraryItem - let result = null if (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.downloadCoverFromUrlNew(req.body.url, req.libraryItem.id, req.libraryItem.isFile ? null : req.libraryItem.path) } else if (req.files?.cover) { Logger.debug(`[LibraryItemController] Handling uploaded cover`) - result = await CoverManager.uploadCover(libraryItem, req.files.cover) + result = await CoverManager.uploadCover(req.libraryItem, req.files.cover) } else { return res.status(400).send('Invalid request no file or url') } @@ -296,8 +304,15 @@ class LibraryItemController { } if (updateAndReturnJson) { - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) + req.libraryItem.media.coverPath = result.cover + req.libraryItem.media.changed('coverPath', true) + await req.libraryItem.media.save() + + // client uses updatedAt timestamp in URL to force refresh cover + req.libraryItem.changed('updatedAt', true) + await req.libraryItem.save() + + SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) res.json({ success: true, cover: result.cover @@ -308,22 +323,28 @@ class LibraryItemController { /** * PATCH: /api/items/:id/cover * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async updateCover(req, res) { - const libraryItem = req.libraryItem if (!req.body.cover) { 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.libraryItem) if (validationResult.error) { return res.status(500).send(validationResult.error) } if (validationResult.updated) { - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) + req.libraryItem.media.coverPath = validationResult.cover + req.libraryItem.media.changed('coverPath', true) + await req.libraryItem.media.save() + + // client uses updatedAt timestamp in URL to force refresh cover + req.libraryItem.changed('updatedAt', true) + await req.libraryItem.save() + + SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) } res.json({ success: true, @@ -334,17 +355,22 @@ class LibraryItemController { /** * DELETE: /api/items/:id/cover * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async removeCover(req, res) { - var libraryItem = req.libraryItem + if (req.libraryItem.media.coverPath) { + req.libraryItem.media.coverPath = null + req.libraryItem.media.changed('coverPath', true) + await req.libraryItem.media.save() - if (libraryItem.media.coverPath) { - libraryItem.updateMediaCover('') - await CacheManager.purgeCoverCache(libraryItem.id) - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) + // client uses updatedAt timestamp in URL to force refresh cover + req.libraryItem.changed('updatedAt', true) + await req.libraryItem.save() + + await CacheManager.purgeCoverCache(req.libraryItem.id) + + SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) } res.sendStatus(200) @@ -353,7 +379,7 @@ class LibraryItemController { /** * GET: /api/items/:id/cover * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async getCover(req, res) { @@ -395,11 +421,11 @@ class LibraryItemController { * * @this {import('../routers/ApiRouter')} * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ startPlaybackSession(req, res) { - if (!req.libraryItem.media.numTracks) { + if (!req.libraryItem.hasAudioTracks) { Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${req.libraryItem.id}`) return res.sendStatus(404) } @@ -412,18 +438,18 @@ class LibraryItemController { * * @this {import('../routers/ApiRouter')} * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ startEpisodePlaybackSession(req, res) { - var libraryItem = req.libraryItem - if (!libraryItem.media.numTracks) { - Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${libraryItem.id}`) - return res.sendStatus(404) + if (!req.libraryItem.isPodcast) { + Logger.error(`[LibraryItemController] startEpisodePlaybackSession invalid media type ${req.libraryItem.id}`) + return res.sendStatus(400) } - var episodeId = req.params.episodeId - if (!libraryItem.media.episodes.find((ep) => ep.id === episodeId)) { - Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${libraryItem.id}`) + + const episodeId = req.params.episodeId + 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) } @@ -433,30 +459,55 @@ class LibraryItemController { /** * PATCH: /api/items/:id/tracks * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async updateTracks(req, res) { - var libraryItem = req.libraryItem - var orderedFileData = req.body.orderedFileData - if (!libraryItem.media.updateAudioTracks) { - Logger.error(`[LibraryItemController] updateTracks invalid media type ${libraryItem.id}`) - return res.sendStatus(500) + const orderedFileData = req.body?.orderedFileData + + if (!req.libraryItem.isBook) { + Logger.error(`[LibraryItemController] updateTracks invalid media type ${req.libraryItem.id}`) + return res.sendStatus(400) + } + if (!Array.isArray(orderedFileData) || !orderedFileData.length) { + Logger.error(`[LibraryItemController] updateTracks invalid orderedFileData ${req.libraryItem.id}`) + return res.sendStatus(400) + } + // Ensure that each orderedFileData has a valid ino and is in the book audioFiles + if (orderedFileData.some((fileData) => !fileData?.ino || !req.libraryItem.media.audioFiles.some((af) => af.ino === fileData.ino))) { + Logger.error(`[LibraryItemController] updateTracks invalid orderedFileData ${req.libraryItem.id}`) + return res.sendStatus(400) } - libraryItem.media.updateAudioTracks(orderedFileData) - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) - res.json(libraryItem.toJSON()) + + let index = 1 + const updatedAudioFiles = orderedFileData.map((fileData) => { + const audioFile = req.libraryItem.media.audioFiles.find((af) => af.ino === fileData.ino) + audioFile.manuallyVerified = true + audioFile.exclude = !!fileData.exclude + if (audioFile.exclude) { + audioFile.index = -1 + } else { + audioFile.index = index++ + } + return audioFile + }) + updatedAudioFiles.sort((a, b) => a.index - b.index) + + req.libraryItem.media.audioFiles = updatedAudioFiles + req.libraryItem.media.changed('audioFiles', true) + await req.libraryItem.media.save() + + SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) + res.json(req.libraryItem.toOldJSON()) } /** * POST /api/items/:id/match * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async match(req, res) { - const libraryItem = req.libraryItem const reqBody = req.body || {} const options = {} @@ -473,7 +524,8 @@ class LibraryItemController { options.overrideDetails = !!reqBody.overrideDetails } - var matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options) + const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem) + var matchResult = await Scanner.quickMatchLibraryItem(this, oldLibraryItem, options) res.json(matchResult) } @@ -741,7 +793,7 @@ class LibraryItemController { /** * POST: /api/items/:id/scan * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async scan(req, res) { @@ -765,7 +817,7 @@ class LibraryItemController { /** * GET: /api/items/:id/metadata-object * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ getMetadataObject(req, res) { @@ -774,7 +826,7 @@ class LibraryItemController { 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`) return res.sendStatus(500) } @@ -785,7 +837,7 @@ class LibraryItemController { /** * POST: /api/items/:id/chapters * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async updateMediaChapters(req, res) { @@ -794,26 +846,51 @@ class LibraryItemController { return res.sendStatus(403) } - if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) { + if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.hasAudioTracks) { Logger.error(`[LibraryItemController] Invalid library item`) return res.sendStatus(500) } - if (!req.body.chapters) { + if (!Array.isArray(req.body.chapters) || req.body.chapters.some((c) => !c.title || typeof c.title !== 'string' || c.start === undefined || typeof c.start !== 'number' || c.end === undefined || typeof c.end !== 'number')) { Logger.error(`[LibraryItemController] Invalid payload`) return res.sendStatus(400) } const chapters = req.body.chapters || [] - const wasUpdated = req.libraryItem.media.updateChapters(chapters) - if (wasUpdated) { - await Database.updateLibraryItem(req.libraryItem) - SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) + + let hasUpdates = false + if (chapters.length !== req.libraryItem.media.chapters.length) { + req.libraryItem.media.chapters = chapters.map((c, index) => { + return { + id: index, + title: c.title, + start: c.start, + end: c.end + } + }) + hasUpdates = true + } else { + for (const [index, chapter] of chapters.entries()) { + const currentChapter = req.libraryItem.media.chapters[index] + if (currentChapter.title !== chapter.title || currentChapter.start !== chapter.start || currentChapter.end !== chapter.end) { + currentChapter.title = chapter.title + currentChapter.start = chapter.start + currentChapter.end = chapter.end + hasUpdates = true + } + } + } + + if (hasUpdates) { + req.libraryItem.media.changed('chapters', true) + await req.libraryItem.media.save() + + SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) } res.json({ success: true, - updated: wasUpdated + updated: hasUpdates }) } @@ -821,7 +898,7 @@ class LibraryItemController { * GET: /api/items/:id/ffprobe/:fileid * FFProbe JSON result from audio file * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async getFFprobeData(req, res) { @@ -829,25 +906,21 @@ class LibraryItemController { Logger.error(`[LibraryItemController] Non-admin user "${req.user.username}" attempted to get ffprobe data`) return res.sendStatus(403) } - if (req.libraryFile.fileType !== 'audio') { - Logger.error(`[LibraryItemController] Invalid filetype "${req.libraryFile.fileType}" for fileid "${req.params.fileid}". Expected audio file`) - return res.sendStatus(400) - } - const audioFile = req.libraryItem.media.findFileWithInode(req.params.fileid) + const audioFile = req.libraryItem.getAudioFileWithIno(req.params.fileid) if (!audioFile) { Logger.error(`[LibraryItemController] Audio file not found with inode value ${req.params.fileid}`) return res.sendStatus(404) } - const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile) + const ffprobeData = await AudioFileScanner.probeAudioFile(audioFile.metadata.path) res.json(ffprobeData) } /** * GET api/items/:id/file/:fileid * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequestWithFile} req * @param {Response} res */ async getLibraryFile(req, res) { @@ -870,7 +943,7 @@ class LibraryItemController { /** * DELETE api/items/:id/file/:fileid * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequestWithFile} req * @param {Response} res */ async deleteLibraryFile(req, res) { @@ -881,17 +954,35 @@ class LibraryItemController { await fs.remove(libraryFile.metadata.path).catch((error) => { Logger.error(`[LibraryItemController] Failed to delete library file at "${libraryFile.metadata.path}"`, error) }) - req.libraryItem.removeLibraryFile(req.params.fileid) - if (req.libraryItem.media.removeFileWithInode(req.params.fileid)) { - // If book has no more media files then mark it as missing - if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaEntities) { - req.libraryItem.setMissing() + req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((lf) => lf.ino !== req.params.fileid) + req.libraryItem.changed('libraryFiles', true) + + if (req.libraryItem.isBook) { + if (req.libraryItem.media.audioFiles.some((af) => af.ino === req.params.fileid)) { + req.libraryItem.media.audioFiles = req.libraryItem.media.audioFiles.filter((af) => af.ino !== req.params.fileid) + req.libraryItem.media.changed('audioFiles', true) + } else if (req.libraryItem.media.ebookFile?.ino === req.params.fileid) { + req.libraryItem.media.ebookFile = null + req.libraryItem.media.changed('ebookFile', true) } + if (!req.libraryItem.media.hasMediaFiles) { + req.libraryItem.isMissing = true + } + } else if (req.libraryItem.media.podcastEpisodes.some((ep) => ep.audioFile.ino === req.params.fileid)) { + const episodeToRemove = req.libraryItem.media.podcastEpisodes.find((ep) => ep.audioFile.ino === req.params.fileid) + await episodeToRemove.destroy() + + req.libraryItem.media.podcastEpisodes = req.libraryItem.media.podcastEpisodes.filter((ep) => ep.audioFile.ino !== req.params.fileid) } - req.libraryItem.updatedAt = Date.now() - await Database.updateLibraryItem(req.libraryItem) - SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) + + if (req.libraryItem.media.changed()) { + await req.libraryItem.media.save() + } + + await req.libraryItem.save() + + SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) res.sendStatus(200) } @@ -899,7 +990,7 @@ class LibraryItemController { * GET api/items/:id/file/:fileid/download * Same as GET api/items/:id/file/:fileid but allows logging and restricting downloads * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequestWithFile} req * @param {Response} res */ async downloadLibraryFile(req, res) { @@ -911,7 +1002,7 @@ class LibraryItemController { 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) { const encodedURI = encodeUriPath(global.XAccel + libraryFile.metadata.path) @@ -947,13 +1038,13 @@ class LibraryItemController { * fileid is only required when reading a supplementary ebook * when no fileid is passed in the primary ebook will be returned * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async getEBookFile(req, res) { let ebookFile = null if (req.params.fileid) { - ebookFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid) + ebookFile = req.libraryItem.getLibraryFileWithIno(req.params.fileid) if (!ebookFile?.isEBookFile) { Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`) return res.status(400).send('Invalid ebook file id') @@ -963,12 +1054,12 @@ class LibraryItemController { } 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) } 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) { const encodedURI = encodeUriPath(global.XAccel + ebookFilePath) @@ -991,28 +1082,55 @@ class LibraryItemController { * 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 * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequestWithFile} req * @param {Response} res */ async updateEbookFileStatus(req, res) { - const ebookLibraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid) - if (!ebookLibraryFile?.isEBookFile) { + if (!req.libraryItem.isBook) { + Logger.error(`[LibraryItemController] Invalid media type for ebook file status update`) + return res.sendStatus(400) + } + if (!req.libraryFile?.isEBookFile) { Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`) return res.status(400).send('Invalid ebook file id') } + const ebookLibraryFile = req.libraryFile + let primaryEbookFile = null + + const ebookLibraryFileInos = req.libraryItem + .getLibraryFiles() + .filter((lf) => lf.isEBookFile) + .map((lf) => lf.ino) + if (ebookLibraryFile.isSupplementary) { Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to primary`) - req.libraryItem.setPrimaryEbook(ebookLibraryFile) + + primaryEbookFile = ebookLibraryFile.toJSON() + delete primaryEbookFile.isSupplementary + delete primaryEbookFile.fileType + primaryEbookFile.ebookFormat = ebookLibraryFile.metadata.format } else { Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to supplementary`) - ebookLibraryFile.isSupplementary = true - req.libraryItem.setPrimaryEbook(null) } - req.libraryItem.updatedAt = Date.now() - await Database.updateLibraryItem(req.libraryItem) - SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) + req.libraryItem.media.ebookFile = primaryEbookFile + req.libraryItem.media.changed('ebookFile', true) + await req.libraryItem.media.save() + + req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.map((lf) => { + if (ebookLibraryFileInos.includes(lf.ino)) { + lf.isSupplementary = lf.ino !== primaryEbookFile?.ino + } + return lf + }) + req.libraryItem.changed('libraryFiles', true) + + req.libraryItem.isMissing = !req.libraryItem.media.hasMediaFiles + + await req.libraryItem.save() + + SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) res.sendStatus(200) } @@ -1023,7 +1141,7 @@ class LibraryItemController { * @param {NextFunction} next */ async middleware(req, res, next) { - req.libraryItem = await Database.libraryItemModel.getOldById(req.params.id) + req.libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id) if (!req.libraryItem?.media) return res.sendStatus(404) // Check user can access this library item @@ -1033,7 +1151,7 @@ class LibraryItemController { // For library file routes, get the library file if (req.params.fileid) { - req.libraryFile = req.libraryItem.libraryFiles.find((lf) => lf.ino === req.params.fileid) + req.libraryFile = req.libraryItem.getLibraryFileWithIno(req.params.fileid) if (!req.libraryFile) { Logger.error(`[LibraryItemController] Library file "${req.params.fileid}" does not exist for library item`) return res.sendStatus(404) diff --git a/server/managers/AudioMetadataManager.js b/server/managers/AudioMetadataManager.js index 7911178e34..36aecb97de 100644 --- a/server/managers/AudioMetadataManager.js +++ b/server/managers/AudioMetadataManager.js @@ -34,8 +34,13 @@ class AudioMetadataMangaer { return this.tasksQueued.some((t) => t.data.libraryItemId === libraryItemId) || this.tasksRunning.some((t) => t.data.libraryItemId === libraryItemId) } + /** + * + * @param {import('../models/LibraryItem')} libraryItem + * @returns + */ getMetadataObjectForApi(libraryItem) { - return ffmpegHelpers.getFFMetadataObject(libraryItem, libraryItem.media.includedAudioFiles.length) + return ffmpegHelpers.getFFMetadataObject(libraryItem.toOldJSONExpanded(), libraryItem.media.includedAudioFiles.length) } /** diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js index 2b3a697d75..c995a446d6 100644 --- a/server/managers/CoverManager.js +++ b/server/managers/CoverManager.js @@ -79,6 +79,12 @@ class CoverManager { return imgType } + /** + * + * @param {import('../models/LibraryItem')} libraryItem + * @param {*} coverFile - file object from req.files + * @returns {Promise<{error:string}|{cover:string}>} + */ async uploadCover(libraryItem, coverFile) { const extname = Path.extname(coverFile.name.toLowerCase()) if (!extname || !globals.SupportedImageTypes.includes(extname.slice(1))) { @@ -110,14 +116,20 @@ class CoverManager { await this.removeOldCovers(coverDirPath, extname) await CacheManager.purgeCoverCache(libraryItem.id) - Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.metadata.title}"`) + Logger.info(`[CoverManager] Uploaded libraryItem cover "${coverFullPath}" for "${libraryItem.media.title}"`) - libraryItem.updateMediaCover(coverFullPath) return { cover: coverFullPath } } + /** + * + * @param {Object} libraryItem - old library item + * @param {string} url + * @param {boolean} [forceLibraryItemFolder=false] + * @returns {Promise<{error:string}|{cover:string}>} + */ async downloadCoverFromUrl(libraryItem, url, forceLibraryItemFolder = false) { try { // Force save cover with library item is used for adding new podcasts @@ -166,6 +178,12 @@ class CoverManager { } } + /** + * + * @param {string} coverPath + * @param {import('../models/LibraryItem')} libraryItem + * @returns {Promise<{error:string}|{cover:string,updated:boolean}>} + */ async validateCoverPath(coverPath, libraryItem) { // Invalid cover path if (!coverPath || coverPath.startsWith('http:') || coverPath.startsWith('https:')) { @@ -235,7 +253,6 @@ class CoverManager { await CacheManager.purgeCoverCache(libraryItem.id) - libraryItem.updateMediaCover(coverPath) return { cover: coverPath, updated: true diff --git a/server/managers/CronManager.js b/server/managers/CronManager.js index 7a8c9bd0e3..a4dbe6b456 100644 --- a/server/managers/CronManager.js +++ b/server/managers/CronManager.js @@ -215,6 +215,10 @@ class CronManager { this.podcastCrons = this.podcastCrons.filter((pc) => pc.expression !== podcastCron.expression) } + /** + * + * @param {import('../models/LibraryItem')} libraryItem - this can be the old model + */ checkUpdatePodcastCron(libraryItem) { // Remove from old cron by library item id const existingCron = this.podcastCrons.find((pc) => pc.libraryItemIds.includes(libraryItem.id)) @@ -230,7 +234,10 @@ class CronManager { const cronMatchingExpression = this.podcastCrons.find((pc) => pc.expression === libraryItem.media.autoDownloadSchedule) if (cronMatchingExpression) { cronMatchingExpression.libraryItemIds.push(libraryItem.id) - Logger.info(`[CronManager] Added podcast "${libraryItem.media.metadata.title}" to auto dl episode cron "${cronMatchingExpression.expression}"`) + + // TODO: Update after old model removed + const podcastTitle = libraryItem.media.title || libraryItem.media.metadata?.title + Logger.info(`[CronManager] Added podcast "${podcastTitle}" to auto dl episode cron "${cronMatchingExpression.expression}"`) } else { this.startPodcastCron(libraryItem.media.autoDownloadSchedule, [libraryItem.id]) } diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index ce43fc8c41..aace3df7c3 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -39,7 +39,7 @@ class PlaybackSessionManager { /** * - * @param {import('../controllers/SessionController').RequestWithUser} req + * @param {import('../controllers/LibraryItemController').LibraryItemControllerRequest} req * @param {Object} [clientDeviceInfo] * @returns {Promise} */ @@ -67,14 +67,14 @@ class PlaybackSessionManager { /** * - * @param {import('../controllers/SessionController').RequestWithUser} req + * @param {import('../controllers/LibraryItemController').LibraryItemControllerRequest} req * @param {import('express').Response} res * @param {string} [episodeId] */ async startSessionRequest(req, res, episodeId) { const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo) 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) res.json(session.toJSONForClient(libraryItem)) } diff --git a/server/models/Book.js b/server/models/Book.js index a904f53693..756a9dea93 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -1,5 +1,7 @@ const { DataTypes, Model } = require('sequelize') const Logger = require('../Logger') +const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils') +const parseNameString = require('../utils/parsers/parseNameString') /** * @typedef EBookFileObject @@ -113,8 +115,12 @@ class Book extends Model { /** @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) { @@ -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 * @param {import('../Database').sequelize} sequelize @@ -343,9 +323,50 @@ class Book extends Model { } 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 trackList() { let startOffset = 0 return this.includedAudioFiles.map((af) => { @@ -355,6 +376,299 @@ class Book extends Model { 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 + } + } + + /** + * + * @param {Object} payload - old book object + * @returns {Promise} + */ + 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() + } + + if (Array.isArray(payload.metadata?.authors)) { + const authorsRemoved = this.authors.filter((au) => !payload.metadata.authors.some((a) => a.id === au.id)) + const newAuthors = payload.metadata.authors.filter((a) => !this.authors.some((au) => au.id === a.id)) + + for (const author of authorsRemoved) { + await this.sequelize.models.bookAuthor.removeByIds(author.id, this.id) + Logger.debug(`[Book] "${this.title}" Removed author ${author.id}`) + hasUpdates = true + } + for (const author of newAuthors) { + await this.sequelize.models.bookAuthor.create({ bookId: this.id, authorId: author.id }) + Logger.debug(`[Book] "${this.title}" Added author ${author.id}`) + hasUpdates = true + } + } + + if (Array.isArray(payload.metadata?.series)) { + const seriesRemoved = this.series.filter((se) => !payload.metadata.series.some((s) => s.id === se.id)) + const newSeries = payload.metadata.series.filter((s) => !this.series.some((se) => se.id === s.id)) + + for (const series of seriesRemoved) { + await this.sequelize.models.bookSeries.removeByIds(series.id, this.id) + Logger.debug(`[Book] "${this.title}" Removed series ${series.id}`) + hasUpdates = true + } + for (const series of newSeries) { + await this.sequelize.models.bookSeries.create({ bookId: this.id, seriesId: series.id, sequence: series.sequence }) + Logger.debug(`[Book] "${this.title}" Added series ${series.id}`) + hasUpdates = true + } + for (const series of payload.metadata.series) { + const existingSeries = this.series.find((se) => se.id === series.id) + if (existingSeries && existingSeries.bookSeries.sequence !== series.sequence) { + await existingSeries.bookSeries.update({ sequence: series.sequence }) + Logger.debug(`[Book] "${this.title}" Updated series ${series.id} sequence ${series.sequence}`) + hasUpdates = true + } + } + } + + return 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.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 diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 2aa41b703e..03e67a9ed3 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -865,54 +865,6 @@ class LibraryItem extends Model { return libraryItem.media.coverPath } - /** - * - * @param {import('sequelize').FindOptions} options - * @returns {Promise} - */ - getMedia(options) { - if (!this.mediaType) return Promise.resolve(null) - const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaType)}` - return this[mixinMethodName](options) - } - - /** - * - * @returns {Promise} - */ - 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} @@ -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} + */ + getMedia(options) { + if (!this.mediaType) return Promise.resolve(null) + const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaType)}` + return this[mixinMethodName](options) + } + + /** + * + * @returns {Promise} + */ + 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 * Requires expanded library item @@ -1142,12 +1152,133 @@ class LibraryItem extends Model { Logger.error(`[LibraryItem] hasAudioTracks: Library item "${this.id}" does not have media`) return false } - if (this.mediaType === 'book') { + if (this.isBook) { return this.media.audioFiles?.length > 0 } else { return this.media.podcastEpisodes?.length > 0 } } + + /** + * + * @param {string} ino + * @returns {import('./Book').AudioFileObject} + */ + getAudioFileWithIno(ino) { + if (!this.media) { + Logger.error(`[LibraryItem] getAudioFileWithIno: Library item "${this.id}" does not have media`) + return null + } + if (this.isBook) { + return this.media.audioFiles.find((af) => af.ino === ino) + } else { + return this.media.podcastEpisodes.find((pe) => pe.audioFile?.ino === ino)?.audioFile + } + } + + /** + * + * @param {string} ino + * @returns {LibraryFile} + */ + getLibraryFileWithIno(ino) { + const libraryFile = this.libraryFiles.find((lf) => lf.ino === ino) + if (!libraryFile) return null + return new LibraryFile(libraryFile) + } + + getLibraryFiles() { + return this.libraryFiles.map((lf) => new LibraryFile(lf)) + } + + getLibraryFilesJson() { + return this.libraryFiles.map((lf) => new LibraryFile(lf).toJSON()) + } + + 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), + // LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database + libraryFiles: this.getLibraryFilesJson() + } + } + + 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), + // LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database + libraryFiles: this.getLibraryFilesJson(), + size: this.size + } + } } module.exports = LibraryItem diff --git a/server/models/Podcast.js b/server/models/Podcast.js index 60f879d0e4..172e36a2cd 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -1,4 +1,6 @@ const { DataTypes, Model } = require('sequelize') +const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils') +const Logger = require('../Logger') /** * @typedef PodcastExpandedProperties @@ -47,6 +49,8 @@ class Podcast extends Model { this.lastEpisodeCheck /** @type {number} */ this.maxEpisodesToKeep + /** @type {number} */ + this.maxNewEpisodesToDownload /** @type {string} */ this.coverPath /** @type {string[]} */ @@ -57,6 +61,9 @@ class Podcast extends Model { this.createdAt /** @type {Date} */ this.updatedAt + + /** @type {import('./PodcastEpisode')[]} */ + this.podcastEpisodes } static getOldPodcast(libraryItemExpanded) { @@ -119,25 +126,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 * @param {import('../Database').sequelize} sequelize @@ -179,6 +167,210 @@ 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 + } + } + + /** + * + * @param {Object} payload - Old podcast object + * @returns {Promise} + */ + async updateFromRequest(payload) { + if (!payload) return false + + let hasUpdates = false + + if (payload.metadata) { + const stringKeys = ['title', 'author', 'releaseDate', 'feedUrl', 'imageUrl', 'description', 'itunesPageUrl', 'itunesId', 'itunesArtistId', 'language', 'type'] + stringKeys.forEach((key) => { + let newKey = key + if (key === 'type') { + newKey = 'podcastType' + } else if (key === 'feedUrl') { + newKey = 'feedURL' + } else if (key === 'imageUrl') { + newKey = 'imageURL' + } else if (key === 'itunesPageUrl') { + newKey = 'itunesPageURL' + } + if (typeof payload.metadata[key] === 'string' && payload.metadata[key] !== this[newKey]) { + this[newKey] = payload.metadata[key] + if (key === 'title') { + this.titleIgnorePrefix = getTitleIgnorePrefix(this.title) + } + + hasUpdates = true + } + }) + + if (payload.metadata.explicit !== undefined && payload.metadata.explicit !== this.explicit) { + this.explicit = !!payload.metadata.explicit + hasUpdates = true + } + + if (Array.isArray(payload.metadata.genres) && !payload.metadata.genres.some((item) => typeof item !== 'string') && JSON.stringify(this.genres) !== JSON.stringify(payload.metadata.genres)) { + this.genres = payload.metadata.genres + this.changed('genres', true) + hasUpdates = true + } + } + + if (Array.isArray(payload.tags) && !payload.tags.some((item) => typeof item !== 'string') && JSON.stringify(this.tags) !== JSON.stringify(payload.tags)) { + this.tags = payload.tags + this.changed('tags', true) + hasUpdates = true + } + + if (payload.autoDownloadEpisodes !== undefined && payload.autoDownloadEpisodes !== this.autoDownloadEpisodes) { + this.autoDownloadEpisodes = !!payload.autoDownloadEpisodes + hasUpdates = true + } + if (typeof payload.autoDownloadSchedule === 'string' && payload.autoDownloadSchedule !== this.autoDownloadSchedule) { + this.autoDownloadSchedule = payload.autoDownloadSchedule + hasUpdates = true + } + + const numberKeys = ['maxEpisodesToKeep', 'maxNewEpisodesToDownload'] + numberKeys.forEach((key) => { + if (typeof payload[key] === 'number' && payload[key] !== this[key]) { + this[key] = payload[key] + hasUpdates = true + } + }) + + if (hasUpdates) { + Logger.debug(`[Podcast] changed keys:`, this.changed()) + await this.save() + } + + return hasUpdates + } + + /** + * 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 diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 1fa32da7a3..23d237e020 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -53,42 +53,6 @@ class PodcastEpisode extends Model { 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) { const podcastEpisode = this.getFromOld(oldEpisode) return this.create(podcastEpisode) @@ -184,7 +148,51 @@ class PodcastEpisode extends Model { 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) { + if (!libraryItemId) { + throw new Error(`[PodcastEpisode] Cannot convert to old JSON because libraryItemId is not provided`) + } + let enclosure = null if (this.enclosureURL) { enclosure = { @@ -209,8 +217,8 @@ class PodcastEpisode extends Model { enclosure, guid: this.extraData?.guid || null, pubDate: this.pubDate, - chapters: this.chapters?.map((ch) => ({ ...ch })) || [], - audioFile: this.audioFile || null, + chapters: structuredClone(this.chapters), + audioFile: structuredClone(this.audioFile), publishedAt: this.publishedAt?.valueOf() || null, addedAt: this.createdAt.valueOf(), updatedAt: this.updatedAt.valueOf() @@ -221,7 +229,7 @@ class PodcastEpisode extends Model { const json = this.toOldJSON(libraryItemId) json.audioTrack = this.track - json.size = this.audioFile?.metadata.size || 0 + json.size = this.size json.duration = this.audioFile?.duration || 0 return json diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index 84a37897a7..6578bae0c5 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -177,9 +177,6 @@ class LibraryItem { get hasAudioFiles() { return this.libraryFiles.some((lf) => lf.fileType === 'audio') } - get hasMediaEntities() { - return this.media.hasMediaEntities - } // Data comes from scandir library item data // TODO: Remove this function. Only used when creating a new podcast now @@ -327,20 +324,5 @@ class LibraryItem { } return false } - - /** - * Set the EBookFile from a LibraryFile - * If null then ebookFile will be removed from the book - * all ebook library files that are not primary are marked as supplementary - * - * @param {LibraryFile} [libraryFile] - */ - setPrimaryEbook(ebookLibraryFile = null) { - const ebookLibraryFiles = this.libraryFiles.filter((lf) => lf.isEBookFile) - for (const libraryFile of ebookLibraryFiles) { - libraryFile.isSupplementary = ebookLibraryFile?.ino !== libraryFile.ino - } - this.media.setEbookFile(ebookLibraryFile) - } } module.exports = LibraryItem diff --git a/server/objects/mediaTypes/Book.js b/server/objects/mediaTypes/Book.js index 8fdff98892..5d45501859 100644 --- a/server/objects/mediaTypes/Book.js +++ b/server/objects/mediaTypes/Book.js @@ -33,8 +33,8 @@ class Book { this.metadata = new BookMetadata(book.metadata) this.coverPath = book.coverPath this.tags = [...book.tags] - this.audioFiles = book.audioFiles.map(f => new AudioFile(f)) - this.chapters = book.chapters.map(c => ({ ...c })) + this.audioFiles = book.audioFiles.map((f) => new AudioFile(f)) + this.chapters = book.chapters.map((c) => ({ ...c })) this.ebookFile = book.ebookFile ? new EBookFile(book.ebookFile) : null this.lastCoverSearch = book.lastCoverSearch || null this.lastCoverSearchQuery = book.lastCoverSearchQuery || null @@ -47,8 +47,8 @@ class Book { metadata: this.metadata.toJSON(), coverPath: this.coverPath, tags: [...this.tags], - audioFiles: this.audioFiles.map(f => f.toJSON()), - chapters: this.chapters.map(c => ({ ...c })), + audioFiles: this.audioFiles.map((f) => f.toJSON()), + chapters: this.chapters.map((c) => ({ ...c })), ebookFile: this.ebookFile ? this.ebookFile.toJSON() : null } } @@ -75,11 +75,11 @@ class Book { metadata: this.metadata.toJSONExpanded(), coverPath: this.coverPath, tags: [...this.tags], - audioFiles: this.audioFiles.map(f => f.toJSON()), - chapters: this.chapters.map(c => ({ ...c })), + audioFiles: this.audioFiles.map((f) => f.toJSON()), + chapters: this.chapters.map((c) => ({ ...c })), duration: this.duration, size: this.size, - tracks: this.tracks.map(t => t.toJSON()), + tracks: this.tracks.map((t) => t.toJSON()), ebookFile: this.ebookFile?.toJSON() || null } } @@ -87,24 +87,21 @@ class Book { toJSONForMetadataFile() { return { tags: [...this.tags], - chapters: this.chapters.map(c => ({ ...c })), + chapters: this.chapters.map((c) => ({ ...c })), ...this.metadata.toJSONForMetadataFile() } } get size() { var total = 0 - this.audioFiles.forEach((af) => total += af.metadata.size) + this.audioFiles.forEach((af) => (total += af.metadata.size)) if (this.ebookFile) { total += this.ebookFile.metadata.size } return total } - get hasMediaEntities() { - return !!this.tracks.length || this.ebookFile - } get includedAudioFiles() { - return this.audioFiles.filter(af => !af.exclude) + return this.audioFiles.filter((af) => !af.exclude) } get tracks() { let startOffset = 0 @@ -117,7 +114,7 @@ class Book { } get duration() { let total = 0 - this.tracks.forEach((track) => total += track.duration) + this.tracks.forEach((track) => (total += track.duration)) return total } get numTracks() { @@ -129,8 +126,6 @@ class Book { update(payload) { const json = this.toJSON() - delete json.audiobooks // do not update media entities here - delete json.ebooks let hasUpdates = false for (const key in json) { @@ -149,30 +144,6 @@ class Book { return hasUpdates } - updateChapters(chapters) { - var hasUpdates = this.chapters.length !== chapters.length - if (hasUpdates) { - this.chapters = chapters.map(ch => ({ - id: ch.id, - start: ch.start, - end: ch.end, - title: ch.title - })) - } else { - for (let i = 0; i < this.chapters.length; i++) { - const currChapter = this.chapters[i] - const newChapter = chapters[i] - if (!hasUpdates && (currChapter.title !== newChapter.title || currChapter.start !== newChapter.start || currChapter.end !== newChapter.end)) { - hasUpdates = true - } - this.chapters[i].title = newChapter.title - this.chapters[i].start = newChapter.start - this.chapters[i].end = newChapter.end - } - } - return hasUpdates - } - updateCover(coverPath) { coverPath = filePathToPOSIX(coverPath) if (this.coverPath === coverPath) return false @@ -180,75 +151,6 @@ class Book { return true } - removeFileWithInode(inode) { - if (this.audioFiles.some(af => af.ino === inode)) { - this.audioFiles = this.audioFiles.filter(af => af.ino !== inode) - return true - } - if (this.ebookFile && this.ebookFile.ino === inode) { - this.ebookFile = null - return true - } - return false - } - - /** - * Get audio file or ebook file from inode - * @param {string} inode - * @returns {(AudioFile|EBookFile|null)} - */ - findFileWithInode(inode) { - const audioFile = this.audioFiles.find(af => af.ino === inode) - if (audioFile) return audioFile - if (this.ebookFile && this.ebookFile.ino === inode) return this.ebookFile - return null - } - - /** - * Set the EBookFile from a LibraryFile - * If null then ebookFile will be removed from the book - * - * @param {LibraryFile} [libraryFile] - */ - setEbookFile(libraryFile = null) { - if (!libraryFile) { - this.ebookFile = null - } else { - const ebookFile = new EBookFile() - ebookFile.setData(libraryFile) - this.ebookFile = ebookFile - } - } - - addAudioFile(audioFile) { - this.audioFiles.push(audioFile) - } - - updateAudioTracks(orderedFileData) { - let index = 1 - this.audioFiles = orderedFileData.map((fileData) => { - const audioFile = this.audioFiles.find(af => af.ino === fileData.ino) - audioFile.manuallyVerified = true - audioFile.error = null - if (fileData.exclude !== undefined) { - audioFile.exclude = !!fileData.exclude - } - if (audioFile.exclude) { - audioFile.index = -1 - } else { - audioFile.index = index++ - } - return audioFile - }) - - this.rebuildTracks() - } - - rebuildTracks() { - Logger.debug(`[Book] Tracks being rebuilt...!`) - this.audioFiles.sort((a, b) => a.index - b.index) - } - // Only checks container format checkCanDirectPlay(payload) { var supportedMimeTypes = payload.supportedMimeTypes || [] @@ -268,7 +170,7 @@ class Book { } getChapters() { - return this.chapters?.map(ch => ({ ...ch })) || [] + return this.chapters?.map((ch) => ({ ...ch })) || [] } } module.exports = Book diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index c7d91d0da9..d33b28ba2c 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -124,9 +124,6 @@ class Podcast { this.episodes.forEach((ep) => (total += ep.size)) return total } - get hasMediaEntities() { - return !!this.episodes.length - } get duration() { let total = 0 this.episodes.forEach((ep) => (total += ep.duration)) @@ -181,20 +178,6 @@ class Podcast { return true } - removeFileWithInode(inode) { - const hasEpisode = this.episodes.some((ep) => ep.audioFile.ino === inode) - if (hasEpisode) { - this.episodes = this.episodes.filter((ep) => ep.audioFile.ino !== inode) - } - return hasEpisode - } - - findFileWithInode(inode) { - var episode = this.episodes.find((ep) => ep.audioFile.ino === inode) - if (episode) return episode.audioFile - return null - } - setData(mediaData) { this.metadata = new PodcastMetadata() if (mediaData.metadata) { diff --git a/server/scanner/AudioFileScanner.js b/server/scanner/AudioFileScanner.js index 6c808aaa1c..00bd44d339 100644 --- a/server/scanner/AudioFileScanner.js +++ b/server/scanner/AudioFileScanner.js @@ -202,12 +202,12 @@ class AudioFileScanner { /** * - * @param {AudioFile} audioFile + * @param {string} audioFilePath * @returns {object} */ - probeAudioFile(audioFile) { - Logger.debug(`[AudioFileScanner] Running ffprobe for audio file at "${audioFile.metadata.path}"`) - return prober.rawProbe(audioFile.metadata.path) + probeAudioFile(audioFilePath) { + Logger.debug(`[AudioFileScanner] Running ffprobe for audio file at "${audioFilePath}"`) + return prober.rawProbe(audioFilePath) } /** diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js index 3fcd1cf817..fb65cc4bcf 100644 --- a/test/server/controllers/LibraryItemController.test.js +++ b/test/server/controllers/LibraryItemController.test.js @@ -82,11 +82,11 @@ describe('LibraryItemController', () => { }) it('should remove authors and series with no books on library item delete', async () => { - const oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem1Id) + const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItem1Id) const fakeReq = { query: {}, - libraryItem: oldLibraryItem + libraryItem } const fakeRes = { sendStatus: sinon.spy() @@ -156,7 +156,7 @@ describe('LibraryItemController', () => { }) it('should remove authors and series with no books on library item update media', async () => { - const oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem1Id) + const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItem1Id) // Update library item 1 remove all authors and series const fakeReq = { @@ -167,7 +167,7 @@ describe('LibraryItemController', () => { series: [] } }, - libraryItem: oldLibraryItem + libraryItem } const fakeRes = { json: sinon.spy()