From 0d8d0a650bc41039fa23e059a2a735532a5a28c1 Mon Sep 17 00:00:00 2001 From: sbyrx Date: Wed, 1 Jan 2025 16:01:26 +0000 Subject: [PATCH 001/118] Adds a configuration for podcast feed and episode download timeout --- server/Server.js | 6 ++++++ server/utils/ffmpegHelpers.js | 2 +- server/utils/podcastUtils.js | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/server/Server.js b/server/Server.js index 46850cbb7b..183c28a3da 100644 --- a/server/Server.js +++ b/server/Server.js @@ -65,6 +65,12 @@ class Server { } } + if (process.env.PODCAST_DOWNLOAD_TIMEOUT) { + global.PodcastDownloadTimeout = process.env.PODCAST_DOWNLOAD_TIMEOUT + } else { + global.PodcastDownloadTimeout = 30000 + } + if (!fs.pathExistsSync(global.ConfigPath)) { fs.mkdirSync(global.ConfigPath) } diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index c70242252c..d9e7716130 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -106,7 +106,7 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { headers: { 'User-Agent': 'audiobookshelf (+https://audiobookshelf.org)' }, - timeout: 30000 + timeout: global.PodcastDownloadTimeout }).catch((error) => { Logger.error(`[ffmpegHelpers] Failed to download podcast episode with url "${podcastEpisodeDownload.url}"`, error) return null diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 26bd173398..0c742407aa 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -238,7 +238,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { return axios({ url: feedUrl, method: 'GET', - timeout: 12000, + timeout: global.PodcastDownloadTimeout, responseType: 'arraybuffer', headers: { Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8', From fe2ba083be74de090f359c544f8e03bfe542dd34 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 2 Jan 2025 13:34:25 +0200 Subject: [PATCH 002/118] Fix ffmpeg concat file escaping --- server/utils/ffmpegHelpers.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index c70242252c..db0f23a048 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -8,8 +8,8 @@ const { filePathToPOSIX, copyToExisting } = require('./fileUtils') const LibraryItem = require('../objects/LibraryItem') function escapeSingleQuotes(path) { - // return path.replace(/'/g, '\'\\\'\'') - return filePathToPOSIX(path).replace(/ /g, '\\ ').replace(/'/g, "\\'") + // A ' within a quoted string is escaped with '\'' in ffmpeg (see https://www.ffmpeg.org/ffmpeg-utils.html#Quoting-and-escaping) + return filePathToPOSIX(path).replace(/'/g, "'\\''") } // Returns first track start time @@ -33,7 +33,7 @@ async function writeConcatFile(tracks, outputPath, startTime = 0) { var tracksToInclude = tracks.filter((t) => t.index >= trackToStartWithIndex) var trackPaths = tracksToInclude.map((t) => { - var line = 'file ' + escapeSingleQuotes(t.metadata.path) + '\n' + `duration ${t.duration}` + var line = "file '" + escapeSingleQuotes(t.metadata.path) + "'\n" + `duration ${t.duration}` return line }) var inputstr = trackPaths.join('\n\n') From dd0ebdf2d8db0bed7403d2c93e8b358c26dc7893 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 2 Jan 2025 12:49:58 -0600 Subject: [PATCH 003/118] Implementing toOld functions for LibraryItem/Book/Podcast --- server/controllers/LibraryItemController.js | 245 ++++++++++--------- server/managers/PlaybackSessionManager.js | 6 +- server/models/Book.js | 256 ++++++++++++++++++-- server/models/LibraryItem.js | 189 +++++++++++---- server/models/Podcast.js | 153 ++++++++++-- server/models/PodcastEpisode.js | 86 ++++--- 6 files changed, 682 insertions(+), 253 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 17c7be8387..8a5ab8606a 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -24,6 +24,17 @@ const ShareManager = require('../managers/ShareManager') * @property {import('../models/User')} user * * @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 { @@ -35,17 +46,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 +79,27 @@ class LibraryItemController { return res.json(item) } - res.json(req.libraryItem) + res.json(req.libraryItem.toOldJSON()) } /** * - * @param {RequestWithUser} req + * @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 hasUpdates = req.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(req.oldLibraryItem) + 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')} * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async delete(req, res) { @@ -111,14 +121,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 +165,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 +174,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 +204,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 +216,44 @@ 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.oldLibraryItem.media.metadata.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.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) { - libraryItem.updatedAt = Date.now() + req.oldLibraryItem.updatedAt = Date.now() if (isPodcastAutoDownloadUpdated) { - this.cronManager.checkUpdatePodcastCron(libraryItem) + this.cronManager.checkUpdatePodcastCron(req.oldLibraryItem) } - 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.oldLibraryItem.media.metadata.title}`) + await Database.updateLibraryItem(req.oldLibraryItem) + SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded()) if (authorsRemoved.length) { // Check remove empty authors @@ -259,14 +268,14 @@ class LibraryItemController { } res.json({ updated: hasUpdates, - libraryItem + libraryItem: req.oldLibraryItem }) } /** * POST: /api/items/:id/cover * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res * @param {boolean} [updateAndReturnJson=true] */ @@ -276,15 +285,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.downloadCoverFromUrl(req.oldLibraryItem, req.body.url) } else if (req.files?.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 { return res.status(400).send('Invalid request no file or url') } @@ -296,8 +303,8 @@ class LibraryItemController { } if (updateAndReturnJson) { - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) + await Database.updateLibraryItem(req.oldLibraryItem) + SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded()) res.json({ success: true, cover: result.cover @@ -308,22 +315,21 @@ 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.oldLibraryItem) if (validationResult.error) { return res.status(500).send(validationResult.error) } if (validationResult.updated) { - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) + await Database.updateLibraryItem(req.oldLibraryItem) + SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded()) } res.json({ success: true, @@ -334,17 +340,15 @@ 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 (libraryItem.media.coverPath) { - libraryItem.updateMediaCover('') - await CacheManager.purgeCoverCache(libraryItem.id) - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) + if (req.libraryItem.media.coverPath) { + req.oldLibraryItem.updateMediaCover('') + await CacheManager.purgeCoverCache(req.libraryItem.id) + await Database.updateLibraryItem(req.oldLibraryItem) + SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded()) } res.sendStatus(200) @@ -353,7 +357,7 @@ class LibraryItemController { /** * GET: /api/items/:id/cover * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async getCover(req, res) { @@ -395,11 +399,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 +416,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 +437,34 @@ 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) } - libraryItem.media.updateAudioTracks(orderedFileData) - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) - 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 * - * @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 +481,7 @@ class LibraryItemController { options.overrideDetails = !!reqBody.overrideDetails } - var matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options) + var matchResult = await Scanner.quickMatchLibraryItem(this, req.oldLibraryItem, options) res.json(matchResult) } @@ -741,7 +749,7 @@ class LibraryItemController { /** * POST: /api/items/:id/scan * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async scan(req, res) { @@ -765,7 +773,7 @@ class LibraryItemController { /** * GET: /api/items/:id/metadata-object * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ getMetadataObject(req, res) { @@ -774,18 +782,18 @@ 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) } - res.json(this.audioMetadataManager.getMetadataObjectForApi(req.libraryItem)) + res.json(this.audioMetadataManager.getMetadataObjectForApi(req.oldLibraryItem)) } /** * POST: /api/items/:id/chapters * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async updateMediaChapters(req, res) { @@ -794,7 +802,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) } @@ -805,10 +813,10 @@ class LibraryItemController { } const chapters = req.body.chapters || [] - const wasUpdated = req.libraryItem.media.updateChapters(chapters) + const wasUpdated = req.oldLibraryItem.media.updateChapters(chapters) if (wasUpdated) { - await Database.updateLibraryItem(req.libraryItem) - SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) + await Database.updateLibraryItem(req.oldLibraryItem) + SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded()) } res.json({ @@ -821,7 +829,7 @@ class LibraryItemController { * GET: /api/items/:id/ffprobe/:fileid * FFProbe JSON result from audio file * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequestWithFile} req * @param {Response} res */ async getFFprobeData(req, res) { @@ -834,7 +842,7 @@ class LibraryItemController { return res.sendStatus(400) } - const audioFile = req.libraryItem.media.findFileWithInode(req.params.fileid) + const audioFile = req.oldLibraryItem.media.findFileWithInode(req.params.fileid) if (!audioFile) { Logger.error(`[LibraryItemController] Audio file not found with inode value ${req.params.fileid}`) return res.sendStatus(404) @@ -847,7 +855,7 @@ class LibraryItemController { /** * GET api/items/:id/file/:fileid * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequestWithFile} req * @param {Response} res */ async getLibraryFile(req, res) { @@ -870,7 +878,7 @@ class LibraryItemController { /** * DELETE api/items/:id/file/:fileid * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequestWithFile} req * @param {Response} res */ async deleteLibraryFile(req, res) { @@ -881,17 +889,17 @@ 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) + 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 (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaEntities) { - req.libraryItem.setMissing() + if (req.libraryItem.mediaType === 'book' && !req.libraryItem.media.hasMediaFiles) { + req.oldLibraryItem.setMissing() } } - req.libraryItem.updatedAt = Date.now() - await Database.updateLibraryItem(req.libraryItem) - SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) + req.oldLibraryItem.updatedAt = Date.now() + await Database.updateLibraryItem(req.oldLibraryItem) + SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded()) res.sendStatus(200) } @@ -899,7 +907,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 +919,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,28 +955,28 @@ 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.oldLibraryItem.libraryFiles.find((lf) => lf.ino === 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') } } else { - ebookFile = req.libraryItem.media.ebookFile + ebookFile = req.oldLibraryItem.media.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) } 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,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 supplementary, then it will be changed to primary * - * @param {RequestWithUser} req + * @param {LibraryItemControllerRequest} req * @param {Response} 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) { Logger.error(`[LibraryItemController] Invalid ebook file id "${req.params.fileid}"`) return res.status(400).send('Invalid ebook file id') @@ -1003,16 +1011,16 @@ class LibraryItemController { if (ebookLibraryFile.isSupplementary) { Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to primary`) - req.libraryItem.setPrimaryEbook(ebookLibraryFile) + req.oldLibraryItem.setPrimaryEbook(ebookLibraryFile) } else { Logger.info(`[LibraryItemController] Updating ebook file "${ebookLibraryFile.metadata.filename}" to supplementary`) ebookLibraryFile.isSupplementary = true - req.libraryItem.setPrimaryEbook(null) + req.oldLibraryItem.setPrimaryEbook(null) } - req.libraryItem.updatedAt = Date.now() - await Database.updateLibraryItem(req.libraryItem) - SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) + req.oldLibraryItem.updatedAt = Date.now() + await Database.updateLibraryItem(req.oldLibraryItem) + SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded()) res.sendStatus(200) } @@ -1023,7 +1031,8 @@ 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) + req.oldLibraryItem = await Database.libraryItemModel.getOldLibraryItem(req.libraryItem) if (!req.libraryItem?.media) return res.sendStatus(404) // Check user can access this library item @@ -1033,7 +1042,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.oldLibraryItem.libraryFiles.find((lf) => lf.ino === 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/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..8f3e1caeb5 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 } = 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,189 @@ 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 + } + } + + /** + * 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..412860d22d 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 @@ -1148,6 +1158,89 @@ class LibraryItem extends Model { 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 diff --git a/server/models/Podcast.js b/server/models/Podcast.js index 60f879d0e4..ec26e091da 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -1,4 +1,5 @@ const { DataTypes, Model } = require('sequelize') +const { getTitlePrefixAtEnd } = require('../utils') /** * @typedef PodcastExpandedProperties @@ -47,6 +48,8 @@ class Podcast extends Model { this.lastEpisodeCheck /** @type {number} */ this.maxEpisodesToKeep + /** @type {number} */ + this.maxNewEpisodesToDownload /** @type {string} */ this.coverPath /** @type {string[]} */ @@ -57,6 +60,9 @@ class Podcast extends Model { this.createdAt /** @type {Date} */ this.updatedAt + + /** @type {import('./PodcastEpisode')[]} */ + this.podcastEpisodes } 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 * @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 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 From 4787e7fdb5f4bc209f978159c6f693d051faabd0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 2 Jan 2025 15:42:52 -0600 Subject: [PATCH 004/118] Updates to LibraryItemController to use new model --- server/controllers/LibraryItemController.js | 212 +++++++++++++++----- server/managers/AudioMetadataManager.js | 7 +- server/managers/CoverManager.js | 23 ++- server/models/LibraryItem.js | 44 +++- server/objects/LibraryItem.js | 15 -- server/objects/mediaTypes/Book.js | 117 ++--------- server/objects/mediaTypes/Podcast.js | 14 -- server/scanner/AudioFileScanner.js | 8 +- 8 files changed, 245 insertions(+), 195 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 8a5ab8606a..f1d11c153a 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -32,7 +32,7 @@ const ShareManager = require('../managers/ShareManager') * @typedef {RequestWithUser & RequestEntityObject} LibraryItemControllerRequest * * @typedef RequestLibraryFileObject - * @property {import('../models/LibraryItem').LibraryFileObject} libraryFile + * @property {import('../objects/files/LibraryFile')} libraryFile * * @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile */ @@ -83,6 +83,10 @@ class LibraryItemController { } /** + * PATCH: /api/items/:id + * + * @deprecated + * Use the updateMedia /api/items/:id/media endpoint instead or updateCover /api/items/:id/cover * * @param {LibraryItemControllerRequest} req * @param {Response} res @@ -288,10 +292,10 @@ class LibraryItemController { let result = null if (req.body?.url) { Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`) - result = await CoverManager.downloadCoverFromUrl(req.oldLibraryItem, 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(req.oldLibraryItem, req.files.cover) + result = await CoverManager.uploadCover(req.libraryItem, req.files.cover) } else { return res.status(400).send('Invalid request no file or url') } @@ -303,8 +307,15 @@ class LibraryItemController { } if (updateAndReturnJson) { - await Database.updateLibraryItem(req.oldLibraryItem) - SocketAuthority.emitter('item_updated', req.oldLibraryItem.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 @@ -323,13 +334,20 @@ class LibraryItemController { return res.status(400).send('Invalid request no cover path') } - const validationResult = await CoverManager.validateCoverPath(req.body.cover, req.oldLibraryItem) + 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(req.oldLibraryItem) - SocketAuthority.emitter('item_updated', req.oldLibraryItem.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, @@ -345,10 +363,17 @@ class LibraryItemController { */ async removeCover(req, res) { if (req.libraryItem.media.coverPath) { - req.oldLibraryItem.updateMediaCover('') + req.libraryItem.media.coverPath = null + 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() + await CacheManager.purgeCoverCache(req.libraryItem.id) - await Database.updateLibraryItem(req.oldLibraryItem) - SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded()) + + SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) } res.sendStatus(200) @@ -451,11 +476,32 @@ class LibraryItemController { 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) + } - req.oldLibraryItem.media.updateAudioTracks(orderedFileData) - await Database.updateLibraryItem(req.oldLibraryItem) - SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded()) - res.json(req.oldLibraryItem.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()) } /** @@ -787,7 +833,7 @@ class LibraryItemController { return res.sendStatus(500) } - res.json(this.audioMetadataManager.getMetadataObjectForApi(req.oldLibraryItem)) + res.json(this.audioMetadataManager.getMetadataObjectForApi(req.libraryItem)) } /** @@ -802,26 +848,51 @@ class LibraryItemController { return res.sendStatus(403) } - if (req.libraryItem.isMissing || !req.libraryItem.isBook || !req.libraryItem.media.includedAudioFiles.length) { + 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.oldLibraryItem.media.updateChapters(chapters) - if (wasUpdated) { - await Database.updateLibraryItem(req.oldLibraryItem) - SocketAuthority.emitter('item_updated', req.oldLibraryItem.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 }) } @@ -829,7 +900,7 @@ class LibraryItemController { * GET: /api/items/:id/ffprobe/:fileid * FFProbe JSON result from audio file * - * @param {LibraryItemControllerRequestWithFile} req + * @param {LibraryItemControllerRequest} req * @param {Response} res */ async getFFprobeData(req, res) { @@ -837,18 +908,14 @@ 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.oldLibraryItem.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) } @@ -889,17 +956,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.oldLibraryItem.removeLibraryFile(req.params.fileid) - if (req.oldLibraryItem.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.hasMediaFiles) { - req.oldLibraryItem.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) + } + + if (req.libraryItem.media.changed()) { + await req.libraryItem.media.save() } - req.oldLibraryItem.updatedAt = Date.now() - await Database.updateLibraryItem(req.oldLibraryItem) - SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded()) + + await req.libraryItem.save() + + SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) res.sendStatus(200) } @@ -961,13 +1046,13 @@ class LibraryItemController { async getEBookFile(req, res) { let ebookFile = null if (req.params.fileid) { - ebookFile = req.oldLibraryItem.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') } } else { - ebookFile = req.oldLibraryItem.media.ebookFile + ebookFile = req.libraryItem.media.ebookFile } if (!ebookFile) { @@ -999,28 +1084,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 {LibraryItemControllerRequest} req + * @param {LibraryItemControllerRequestWithFile} req * @param {Response} res */ async updateEbookFileStatus(req, res) { - const ebookLibraryFile = req.oldLibraryItem.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.oldLibraryItem.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.oldLibraryItem.setPrimaryEbook(null) } - req.oldLibraryItem.updatedAt = Date.now() - await Database.updateLibraryItem(req.oldLibraryItem) - SocketAuthority.emitter('item_updated', req.oldLibraryItem.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) } @@ -1042,7 +1154,7 @@ class LibraryItemController { // For library file routes, get the library file if (req.params.fileid) { - req.libraryFile = req.oldLibraryItem.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/models/LibraryItem.js b/server/models/LibraryItem.js index 412860d22d..03e67a9ed3 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -1152,13 +1152,49 @@ 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}"`) @@ -1184,7 +1220,8 @@ class LibraryItem extends Model { isInvalid: !!this.isInvalid, mediaType: this.mediaType, media: this.media.toOldJSON(this.id), - libraryFiles: structuredClone(this.libraryFiles) + // LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database + libraryFiles: this.getLibraryFilesJson() } } @@ -1237,7 +1274,8 @@ class LibraryItem extends Model { isInvalid: !!this.isInvalid, mediaType: this.mediaType, media: this.media.toOldJSONExpanded(this.id), - libraryFiles: structuredClone(this.libraryFiles), + // LibraryFile JSON includes a fileType property that may not be saved in libraryFiles column in the database + libraryFiles: this.getLibraryFilesJson(), size: this.size } } diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index 84a37897a7..4656a02833 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -327,20 +327,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..4701e422d3 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,14 +87,14 @@ 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 } @@ -104,7 +104,7 @@ class Book { 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 +117,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() { @@ -149,30 +149,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 +156,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 +175,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..510575ed33 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -181,20 +181,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) } /** From eb853d9f0919735c9c0f23663e9b1b195801480c Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 2 Jan 2025 15:51:21 -0600 Subject: [PATCH 005/118] Fix LibraryItemController unit test --- server/controllers/LibraryItemController.js | 2 +- .../server/controllers/LibraryItemController.test.js | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index f1d11c153a..b187d2b61c 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -1144,7 +1144,7 @@ class LibraryItemController { */ async middleware(req, res, next) { req.libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id) - req.oldLibraryItem = await Database.libraryItemModel.getOldLibraryItem(req.libraryItem) + req.oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem) if (!req.libraryItem?.media) return res.sendStatus(404) // Check user can access this library item diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js index 3fcd1cf817..846dd89135 100644 --- a/test/server/controllers/LibraryItemController.test.js +++ b/test/server/controllers/LibraryItemController.test.js @@ -82,11 +82,13 @@ 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 oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) const fakeReq = { query: {}, - libraryItem: oldLibraryItem + libraryItem, + oldLibraryItem } const fakeRes = { sendStatus: sinon.spy() @@ -156,7 +158,8 @@ 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) + const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) // Update library item 1 remove all authors and series const fakeReq = { @@ -167,7 +170,8 @@ describe('LibraryItemController', () => { series: [] } }, - libraryItem: oldLibraryItem + libraryItem, + oldLibraryItem } const fakeRes = { json: sinon.spy() From 5cd14108f93b2c7d1aff8d46fbb9ade0c687ab51 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 2 Jan 2025 15:54:10 -0600 Subject: [PATCH 006/118] Remove req.oldLibraryItem usage --- server/controllers/LibraryItemController.js | 34 ++++++++++--------- .../controllers/LibraryItemController.test.js | 8 ++--- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index b187d2b61c..4b9ee894f1 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -27,7 +27,6 @@ const ShareManager = require('../managers/ShareManager') * * @typedef RequestEntityObject * @property {import('../models/LibraryItem')} libraryItem - * @property {Object} oldLibraryItem - To be removed * * @typedef {RequestWithUser & RequestEntityObject} LibraryItemControllerRequest * @@ -97,13 +96,14 @@ class LibraryItemController { await CacheManager.purgeCoverCache(req.libraryItem.id) } - const hasUpdates = req.oldLibraryItem.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(req.oldLibraryItem) - SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded()) + await Database.updateLibraryItem(oldLibraryItem) + SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) } - res.json(req.oldLibraryItem.toJSON()) + res.json(oldLibraryItem.toJSON()) } /** @@ -234,30 +234,32 @@ class LibraryItemController { } } + const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem) + // Book specific - Get all series being removed from this item let seriesRemoved = [] if (req.libraryItem.isBook && mediaPayload.metadata?.series) { const seriesIdsInUpdate = mediaPayload.metadata.series?.map((se) => se.id) || [] - seriesRemoved = req.oldLibraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) + seriesRemoved = oldLibraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) } let authorsRemoved = [] if (req.libraryItem.isBook && mediaPayload.metadata?.authors) { const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id) - authorsRemoved = req.oldLibraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) + authorsRemoved = oldLibraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) } - const hasUpdates = req.oldLibraryItem.media.update(mediaPayload) || mediaPayload.url + const hasUpdates = oldLibraryItem.media.update(mediaPayload) || mediaPayload.url if (hasUpdates) { - req.oldLibraryItem.updatedAt = Date.now() + oldLibraryItem.updatedAt = Date.now() if (isPodcastAutoDownloadUpdated) { - this.cronManager.checkUpdatePodcastCron(req.oldLibraryItem) + this.cronManager.checkUpdatePodcastCron(oldLibraryItem) } - Logger.debug(`[LibraryItemController] Updated library item media ${req.oldLibraryItem.media.metadata.title}`) - await Database.updateLibraryItem(req.oldLibraryItem) - SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded()) + Logger.debug(`[LibraryItemController] Updated library item media ${oldLibraryItem.media.metadata.title}`) + await Database.updateLibraryItem(oldLibraryItem) + SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) if (authorsRemoved.length) { // Check remove empty authors @@ -272,7 +274,7 @@ class LibraryItemController { } res.json({ updated: hasUpdates, - libraryItem: req.oldLibraryItem + libraryItem: oldLibraryItem }) } @@ -527,7 +529,8 @@ class LibraryItemController { options.overrideDetails = !!reqBody.overrideDetails } - var matchResult = await Scanner.quickMatchLibraryItem(this, req.oldLibraryItem, options) + const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem) + var matchResult = await Scanner.quickMatchLibraryItem(this, oldLibraryItem, options) res.json(matchResult) } @@ -1144,7 +1147,6 @@ class LibraryItemController { */ async middleware(req, res, next) { req.libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id) - req.oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem) if (!req.libraryItem?.media) return res.sendStatus(404) // Check user can access this library item diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js index 846dd89135..fb65cc4bcf 100644 --- a/test/server/controllers/LibraryItemController.test.js +++ b/test/server/controllers/LibraryItemController.test.js @@ -83,12 +83,10 @@ describe('LibraryItemController', () => { it('should remove authors and series with no books on library item delete', async () => { const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItem1Id) - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) const fakeReq = { query: {}, - libraryItem, - oldLibraryItem + libraryItem } const fakeRes = { sendStatus: sinon.spy() @@ -159,7 +157,6 @@ describe('LibraryItemController', () => { it('should remove authors and series with no books on library item update media', async () => { const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItem1Id) - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) // Update library item 1 remove all authors and series const fakeReq = { @@ -170,8 +167,7 @@ describe('LibraryItemController', () => { series: [] } }, - libraryItem, - oldLibraryItem + libraryItem } const fakeRes = { json: sinon.spy() From 12c6f2e9a5b7401d5efd548361ec5d31758981a9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 2 Jan 2025 17:21:07 -0600 Subject: [PATCH 007/118] Update updateMedia endpoint to use new model --- server/controllers/LibraryItemController.js | 19 ++-- server/managers/CronManager.js | 9 +- server/models/Book.js | 112 +++++++++++++++++++- server/models/Podcast.js | 79 +++++++++++++- 4 files changed, 204 insertions(+), 15 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 4b9ee894f1..f08a60115b 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -234,32 +234,27 @@ class LibraryItemController { } } - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem) - // Book specific - Get all series being removed from this item let seriesRemoved = [] if (req.libraryItem.isBook && mediaPayload.metadata?.series) { const seriesIdsInUpdate = mediaPayload.metadata.series?.map((se) => se.id) || [] - seriesRemoved = oldLibraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) + seriesRemoved = req.libraryItem.media.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) } let authorsRemoved = [] if (req.libraryItem.isBook && mediaPayload.metadata?.authors) { const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id) - authorsRemoved = oldLibraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) + authorsRemoved = req.libraryItem.media.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) } - const hasUpdates = oldLibraryItem.media.update(mediaPayload) || mediaPayload.url + const hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url if (hasUpdates) { - oldLibraryItem.updatedAt = Date.now() - if (isPodcastAutoDownloadUpdated) { - this.cronManager.checkUpdatePodcastCron(oldLibraryItem) + this.cronManager.checkUpdatePodcastCron(req.libraryItem) } - Logger.debug(`[LibraryItemController] Updated library item media ${oldLibraryItem.media.metadata.title}`) - await Database.updateLibraryItem(oldLibraryItem) - SocketAuthority.emitter('item_updated', oldLibraryItem.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 @@ -274,7 +269,7 @@ class LibraryItemController { } res.json({ updated: hasUpdates, - libraryItem: oldLibraryItem + libraryItem: req.libraryItem.toOldJSON() }) } 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/models/Book.js b/server/models/Book.js index 8f3e1caeb5..756a9dea93 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -1,6 +1,6 @@ const { DataTypes, Model } = require('sequelize') const Logger = require('../Logger') -const { getTitlePrefixAtEnd } = require('../utils') +const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils') const parseNameString = require('../utils/parsers/parseNameString') /** @@ -425,6 +425,116 @@ class Book extends Model { } } + /** + * + * @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 */ diff --git a/server/models/Podcast.js b/server/models/Podcast.js index ec26e091da..172e36a2cd 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -1,5 +1,6 @@ const { DataTypes, Model } = require('sequelize') -const { getTitlePrefixAtEnd } = require('../utils') +const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils') +const Logger = require('../Logger') /** * @typedef PodcastExpandedProperties @@ -199,6 +200,82 @@ class Podcast extends Model { } } + /** + * + * @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 */ From 5e8678f1ccfcbb29eb57da95bd3756b9aedda7c0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 2 Jan 2025 17:25:10 -0600 Subject: [PATCH 008/118] Remove unused --- server/objects/LibraryItem.js | 3 --- server/objects/mediaTypes/Book.js | 5 ----- server/objects/mediaTypes/Podcast.js | 3 --- 3 files changed, 11 deletions(-) diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index 4656a02833..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 diff --git a/server/objects/mediaTypes/Book.js b/server/objects/mediaTypes/Book.js index 4701e422d3..5d45501859 100644 --- a/server/objects/mediaTypes/Book.js +++ b/server/objects/mediaTypes/Book.js @@ -100,9 +100,6 @@ class Book { } return total } - get hasMediaEntities() { - return !!this.tracks.length || this.ebookFile - } get includedAudioFiles() { return this.audioFiles.filter((af) => !af.exclude) } @@ -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) { diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index 510575ed33..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)) From a6fd0c95b2a83703e12b079c65f9399d139b3aea Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Thu, 2 Jan 2025 20:07:21 -0700 Subject: [PATCH 009/118] API cache manager case-insensitive match --- server/routers/ApiRouter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 235d25cd5f..a423974563 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -65,7 +65,7 @@ class ApiRouter { // // Library Routes // - this.router.get(/^\/libraries/, this.apiCacheManager.middleware) + this.router.get(/^\/libraries/i, this.apiCacheManager.middleware) this.router.post('/libraries', LibraryController.create.bind(this)) this.router.get('/libraries', LibraryController.findAll.bind(this)) this.router.get('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.findOne.bind(this)) From f70f21455f773e54494650a07688117febbc134a Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Thu, 2 Jan 2025 20:13:38 -0700 Subject: [PATCH 010/118] Req URL is lowercase in ApiCacheManager --- server/managers/ApiCacheManager.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/managers/ApiCacheManager.js b/server/managers/ApiCacheManager.js index 35009447da..81b58c9945 100644 --- a/server/managers/ApiCacheManager.js +++ b/server/managers/ApiCacheManager.js @@ -42,6 +42,8 @@ class ApiCacheManager { Logger.debug(`[ApiCacheManager] Skipping cache for random sort`) return next() } + // Force URL to be lower case for matching against routes + req.url = req.url.toLowerCase() const key = { user: req.user.username, url: req.url } const stringifiedKey = JSON.stringify(key) Logger.debug(`[ApiCacheManager] count: ${this.cache.size} size: ${this.cache.calculatedSize}`) From c251f1899d536f762412760047c0b0a50790bdcb Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 3 Jan 2025 11:16:03 -0600 Subject: [PATCH 011/118] Update PlaybackSession to use new library item model --- server/controllers/SessionController.js | 2 +- server/controllers/ShareController.js | 13 ++-- server/managers/PlaybackSessionManager.js | 10 ++-- server/models/Book.js | 64 +++++++++++++++++--- server/models/FeedEpisode.js | 24 ++++---- server/models/LibraryItem.js | 67 +++++++++++++++++++++ server/models/MediaItemShare.js | 46 +++++---------- server/models/Podcast.js | 72 +++++++++++++++++++++++ server/models/PodcastEpisode.js | 25 ++++---- server/objects/LibraryItem.js | 4 -- server/objects/PlaybackSession.js | 41 +++++-------- server/objects/Stream.js | 29 ++++----- server/objects/entities/PodcastEpisode.js | 10 ---- server/objects/mediaTypes/Book.js | 22 ------- server/objects/mediaTypes/Podcast.js | 33 ----------- server/objects/metadata/BookMetadata.js | 5 -- 16 files changed, 279 insertions(+), 188 deletions(-) diff --git a/server/controllers/SessionController.js b/server/controllers/SessionController.js index cc6c0fd729..c3361ce95e 100644 --- a/server/controllers/SessionController.js +++ b/server/controllers/SessionController.js @@ -149,7 +149,7 @@ class SessionController { * @param {Response} res */ async getOpenSession(req, res) { - const libraryItem = await Database.libraryItemModel.getOldById(req.playbackSession.libraryItemId) + const libraryItem = await Database.libraryItemModel.getExpandedById(req.playbackSession.libraryItemId) const sessionForClient = req.playbackSession.toJSONForClient(libraryItem) res.json(sessionForClient) } diff --git a/server/controllers/ShareController.js b/server/controllers/ShareController.js index 93c6e9fbcf..3e7ea1deb5 100644 --- a/server/controllers/ShareController.js +++ b/server/controllers/ShareController.js @@ -70,14 +70,13 @@ class ShareController { } try { - const oldLibraryItem = await Database.mediaItemShareModel.getMediaItemsOldLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType) - - if (!oldLibraryItem) { + const libraryItem = await Database.mediaItemShareModel.getMediaItemsLibraryItem(mediaItemShare.mediaItemId, mediaItemShare.mediaItemType) + if (!libraryItem) { return res.status(404).send('Media item not found') } let startOffset = 0 - const publicTracks = oldLibraryItem.media.includedAudioFiles.map((audioFile) => { + const publicTracks = libraryItem.media.includedAudioFiles.map((audioFile) => { const audioTrack = { index: audioFile.index, startOffset, @@ -86,7 +85,7 @@ class ShareController { contentUrl: `${global.RouterBasePath}/public/share/${slug}/track/${audioFile.index}`, mimeType: audioFile.mimeType, codec: audioFile.codec || null, - metadata: audioFile.metadata.clone() + metadata: structuredClone(audioFile.metadata) } startOffset += audioTrack.duration return audioTrack @@ -105,12 +104,12 @@ class ShareController { const deviceInfo = await this.playbackSessionManager.getDeviceInfo(req, clientDeviceInfo) const newPlaybackSession = new PlaybackSession() - newPlaybackSession.setData(oldLibraryItem, null, 'web-share', deviceInfo, startTime) + newPlaybackSession.setData(libraryItem, null, 'web-share', deviceInfo, startTime) newPlaybackSession.audioTracks = publicTracks newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY newPlaybackSession.shareSessionId = shareSessionId newPlaybackSession.mediaItemShareId = mediaItemShare.id - newPlaybackSession.coverAspectRatio = oldLibraryItem.librarySettings.coverAspectRatio + newPlaybackSession.coverAspectRatio = libraryItem.library.settings.coverAspectRatio mediaItemShare.playbackSession = newPlaybackSession.toJSONForClient() ShareManager.addOpenSharePlaybackSession(newPlaybackSession) diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index aace3df7c3..97c87bbefa 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -74,7 +74,7 @@ class PlaybackSessionManager { async startSessionRequest(req, res, episodeId) { const deviceInfo = await this.getDeviceInfo(req, req.body?.deviceInfo) Logger.debug(`[PlaybackSessionManager] startSessionRequest for device ${deviceInfo.deviceDescription}`) - const { oldLibraryItem: libraryItem, body: options } = req + const { libraryItem, body: options } = req const session = await this.startSession(req.user, deviceInfo, libraryItem, episodeId, options) res.json(session.toJSONForClient(libraryItem)) } @@ -279,7 +279,7 @@ class PlaybackSessionManager { * * @param {import('../models/User')} user * @param {DeviceInfo} deviceInfo - * @param {import('../objects/LibraryItem')} libraryItem + * @param {import('../models/LibraryItem')} libraryItem * @param {string|null} episodeId * @param {{forceDirectPlay?:boolean, forceTranscode?:boolean, mediaPlayer:string, supportedMimeTypes?:string[]}} options * @returns {Promise} @@ -292,7 +292,7 @@ class PlaybackSessionManager { await this.closeSession(user, session, null) } - const shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options, episodeId)) + const shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options.supportedMimeTypes, episodeId)) const mediaPlayer = options.mediaPlayer || 'unknown' const mediaItemId = episodeId || libraryItem.media.id @@ -300,7 +300,7 @@ class PlaybackSessionManager { let userStartTime = 0 if (userProgress) { if (userProgress.isFinished) { - Logger.info(`[PlaybackSessionManager] Starting session for user "${user.username}" and resetting progress for finished item "${libraryItem.media.metadata.title}"`) + Logger.info(`[PlaybackSessionManager] Starting session for user "${user.username}" and resetting progress for finished item "${libraryItem.media.title}"`) // Keep userStartTime as 0 so the client restarts the media } else { userStartTime = Number.parseFloat(userProgress.currentTime) || 0 @@ -312,7 +312,7 @@ class PlaybackSessionManager { let audioTracks = [] if (shouldDirectPlay) { Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}" with id ${newPlaybackSession.id} (Device: ${newPlaybackSession.deviceDescription})`) - audioTracks = libraryItem.getDirectPlayTracklist(episodeId) + audioTracks = libraryItem.getTrackList(episodeId) newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY } else { Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}" (Device: ${newPlaybackSession.deviceDescription})`) diff --git a/server/models/Book.js b/server/models/Book.js index 756a9dea93..4c2006a102 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -62,6 +62,13 @@ const parseNameString = require('../utils/parsers/parseNameString') * @property {ChapterObject[]} chapters * @property {Object} metaTags * @property {string} mimeType + * + * @typedef AudioTrackProperties + * @property {string} title + * @property {string} contentUrl + * @property {number} startOffset + * + * @typedef {AudioFileObject & AudioTrackProperties} AudioTrack */ class Book extends Model { @@ -367,22 +374,65 @@ class Book extends Model { return this.audioFiles.filter((af) => !af.exclude) } - get trackList() { + get hasMediaFiles() { + return !!this.hasAudioTracks || !!this.ebookFile + } + + get hasAudioTracks() { + return !!this.includedAudioFiles.length + } + + /** + * Supported mime types are sent from the web client and are retrieved using the browser Audio player "canPlayType" function. + * + * @param {string[]} supportedMimeTypes + * @returns {boolean} + */ + checkCanDirectPlay(supportedMimeTypes) { + if (!Array.isArray(supportedMimeTypes)) { + Logger.error(`[Book] checkCanDirectPlay: supportedMimeTypes is not an array`, supportedMimeTypes) + return false + } + return this.includedAudioFiles.every((af) => supportedMimeTypes.includes(af.mimeType)) + } + + /** + * Get the track list to be used in client audio players + * AudioTrack is the AudioFile with startOffset, contentUrl and title + * + * @param {string} libraryItemId + * @returns {AudioTrack[]} + */ + getTracklist(libraryItemId) { let startOffset = 0 return this.includedAudioFiles.map((af) => { const track = structuredClone(af) + track.title = af.metadata.filename track.startOffset = startOffset + track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}` startOffset += track.duration return track }) } - get hasMediaFiles() { - return !!this.hasAudioTracks || !!this.ebookFile + /** + * + * @returns {ChapterObject[]} + */ + getChapters() { + return structuredClone(this.chapters) || [] } - get hasAudioTracks() { - return !!this.includedAudioFiles.length + getPlaybackTitle() { + return this.title + } + + getPlaybackAuthor() { + return this.authorName + } + + getPlaybackDuration() { + return this.duration } /** @@ -635,7 +685,7 @@ class Book extends Model { metadata: this.oldMetadataToJSONMinified(), coverPath: this.coverPath, tags: [...(this.tags || [])], - numTracks: this.trackList.length, + numTracks: this.includedAudioFiles.length, numAudioFiles: this.audioFiles?.length || 0, numChapters: this.chapters?.length || 0, duration: this.duration, @@ -666,7 +716,7 @@ class Book extends Model { ebookFile: structuredClone(this.ebookFile), duration: this.duration, size: this.size, - tracks: structuredClone(this.trackList) + tracks: this.getTracklist(libraryItemId) } } } diff --git a/server/models/FeedEpisode.js b/server/models/FeedEpisode.js index 5825dd4e76..0767577ae8 100644 --- a/server/models/FeedEpisode.js +++ b/server/models/FeedEpisode.js @@ -112,15 +112,15 @@ class FeedEpisode extends Model { /** * If chapters for an audiobook match the audio tracks then use chapter titles instead of audio file names * + * @param {import('./Book').AudioTrack[]} trackList * @param {import('./Book')} book * @returns {boolean} */ - static checkUseChapterTitlesForEpisodes(book) { - const tracks = book.trackList || [] + static checkUseChapterTitlesForEpisodes(trackList, book) { const chapters = book.chapters || [] - if (tracks.length !== chapters.length) return false - for (let i = 0; i < tracks.length; i++) { - if (Math.abs(chapters[i].start - tracks[i].startOffset) >= 1) { + if (trackList.length !== chapters.length) return false + for (let i = 0; i < trackList.length; i++) { + if (Math.abs(chapters[i].start - trackList[i].startOffset) >= 1) { return false } } @@ -148,7 +148,7 @@ class FeedEpisode extends Model { const contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}` let title = Path.basename(audioTrack.metadata.filename, Path.extname(audioTrack.metadata.filename)) - if (book.trackList.length == 1) { + if (book.includedAudioFiles.length == 1) { // If audiobook is a single file, use book title instead of chapter/file title title = book.title } else { @@ -185,11 +185,12 @@ class FeedEpisode extends Model { * @returns {Promise} */ static async createFromAudiobookTracks(libraryItemExpanded, feed, slug, transaction) { - const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded.media) + const trackList = libraryItemExpanded.getTrackList() + const useChapterTitles = this.checkUseChapterTitlesForEpisodes(trackList, libraryItemExpanded.media) const feedEpisodeObjs = [] let numExisting = 0 - for (const track of libraryItemExpanded.media.trackList) { + for (const track of trackList) { // Check for existing episode by filepath const existingEpisode = feed.feedEpisodes?.find((episode) => { return episode.filePath === track.metadata.path @@ -204,7 +205,7 @@ class FeedEpisode extends Model { /** * - * @param {import('./Book')[]} books + * @param {import('./Book').BookExpandedWithLibraryItem[]} books * @param {import('./Feed')} feed * @param {string} slug * @param {import('sequelize').Transaction} transaction @@ -218,8 +219,9 @@ class FeedEpisode extends Model { const feedEpisodeObjs = [] let numExisting = 0 for (const book of books) { - const useChapterTitles = this.checkUseChapterTitlesForEpisodes(book) - for (const track of book.trackList) { + const trackList = book.libraryItem.getTrackList() + const useChapterTitles = this.checkUseChapterTitlesForEpisodes(trackList, book) + for (const track of trackList) { // Check for existing episode by filepath const existingEpisode = feed.feedEpisodes?.find((episode) => { return episode.filePath === track.metadata.path diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 03e67a9ed3..3381b94a3a 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -497,6 +497,57 @@ class LibraryItem extends Model { return libraryItem } + /** + * + * @param {import('sequelize').WhereOptions} where + * @param {import('sequelize').IncludeOptions} [include] + * @returns {Promise} + */ + static async findOneExpanded(where, include = null) { + const libraryItem = await this.findOne({ + where, + include + }) + if (!libraryItem) { + Logger.error(`[LibraryItem] Library item not found`) + return null + } + + if (libraryItem.mediaType === 'podcast') { + libraryItem.media = await libraryItem.getMedia({ + include: [ + { + model: this.sequelize.models.podcastEpisode + } + ] + }) + } else { + libraryItem.media = await libraryItem.getMedia({ + include: [ + { + model: this.sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: this.sequelize.models.series, + through: { + attributes: ['id', 'sequence'] + } + } + ], + order: [ + [this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'], + [this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC'] + ] + }) + } + + if (!libraryItem.media) return null + return libraryItem + } + /** * Get old library item by id * @param {string} libraryItemId @@ -1176,6 +1227,22 @@ class LibraryItem extends Model { } } + /** + * Get the track list to be used in client audio players + * AudioTrack is the AudioFile with startOffset and contentUrl + * Podcasts must have an episodeId to get the track list + * + * @param {string} [episodeId] + * @returns {import('./Book').AudioTrack[]} + */ + getTrackList(episodeId) { + if (!this.media) { + Logger.error(`[LibraryItem] getTrackList: Library item "${this.id}" does not have media`) + return [] + } + return this.media.getTracklist(this.id, episodeId) + } + /** * * @param {string} ino diff --git a/server/models/MediaItemShare.js b/server/models/MediaItemShare.js index 2d7b3896a7..2d5be8f69c 100644 --- a/server/models/MediaItemShare.js +++ b/server/models/MediaItemShare.js @@ -76,42 +76,26 @@ class MediaItemShare extends Model { } /** + * Expanded book that includes library settings * * @param {string} mediaItemId * @param {string} mediaItemType - * @returns {Promise} + * @returns {Promise} */ - static async getMediaItemsOldLibraryItem(mediaItemId, mediaItemType) { + static async getMediaItemsLibraryItem(mediaItemId, mediaItemType) { + /** @type {typeof import('./LibraryItem')} */ + const libraryItemModel = this.sequelize.models.libraryItem + if (mediaItemType === 'book') { - const book = await this.sequelize.models.book.findByPk(mediaItemId, { - include: [ - { - model: this.sequelize.models.author, - through: { - attributes: [] - } - }, - { - model: this.sequelize.models.series, - through: { - attributes: ['sequence'] - } - }, - { - model: this.sequelize.models.libraryItem, - include: { - model: this.sequelize.models.library, - attributes: ['settings'] - } - } - ] - }) - const libraryItem = book.libraryItem - libraryItem.media = book - delete book.libraryItem - const oldLibraryItem = this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem) - oldLibraryItem.librarySettings = libraryItem.library.settings - return oldLibraryItem + const libraryItem = await libraryItemModel.findOneExpanded( + { mediaId: mediaItemId }, + { + model: this.sequelize.models.library, + attributes: ['settings'] + } + ) + + return libraryItem } return null } diff --git a/server/models/Podcast.js b/server/models/Podcast.js index 172e36a2cd..188c1070ab 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -276,6 +276,78 @@ class Podcast extends Model { return hasUpdates } + checkCanDirectPlay(supportedMimeTypes, episodeId) { + if (!Array.isArray(supportedMimeTypes)) { + Logger.error(`[Podcast] checkCanDirectPlay: supportedMimeTypes is not an array`, supportedMimeTypes) + return false + } + const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId) + if (!episode) { + Logger.error(`[Podcast] checkCanDirectPlay: episode not found`, episodeId) + return false + } + return supportedMimeTypes.includes(episode.audioFile.mimeType) + } + + /** + * Get the track list to be used in client audio players + * AudioTrack is the AudioFile with startOffset and contentUrl + * Podcast episodes only have one track + * + * @param {string} libraryItemId + * @param {string} episodeId + * @returns {import('./Book').AudioTrack[]} + */ + getTracklist(libraryItemId, episodeId) { + const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId) + if (!episode) { + Logger.error(`[Podcast] getTracklist: episode not found`, episodeId) + return [] + } + + const audioTrack = episode.getAudioTrack(libraryItemId) + return [audioTrack] + } + + /** + * + * @param {string} episodeId + * @returns {import('./PodcastEpisode').ChapterObject[]} + */ + getChapters(episodeId) { + const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId) + if (!episode) { + Logger.error(`[Podcast] getChapters: episode not found`, episodeId) + return [] + } + + return structuredClone(episode.chapters) || [] + } + + getPlaybackTitle(episodeId) { + const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId) + if (!episode) { + Logger.error(`[Podcast] getPlaybackTitle: episode not found`, episodeId) + return '' + } + + return episode.title + } + + getPlaybackAuthor() { + return this.author + } + + getPlaybackDuration(episodeId) { + const episode = this.podcastEpisodes.find((ep) => ep.id === episodeId) + if (!episode) { + Logger.error(`[Podcast] getPlaybackDuration: episode not found`, episodeId) + return 0 + } + + return episode.duration + } + /** * Old model kept metadata in a separate object */ diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 23d237e020..24d07041cd 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -135,23 +135,28 @@ class PodcastEpisode extends Model { PodcastEpisode.belongsTo(podcast) } + get size() { + return this.audioFile?.metadata.size || 0 + } + + get duration() { + return this.audioFile?.duration || 0 + } + /** - * AudioTrack object used in old model + * Used in client players * - * @returns {import('./Book').AudioFileObject|null} + * @param {string} libraryItemId + * @returns {import('./Book').AudioTrack} */ - get track() { - if (!this.audioFile) return null + getAudioTrack(libraryItemId) { const track = structuredClone(this.audioFile) track.startOffset = 0 track.title = this.audioFile.metadata.title + track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}` return track } - get size() { - return this.audioFile?.metadata.size || 0 - } - /** * @param {string} libraryItemId * @returns {oldPodcastEpisode} @@ -228,9 +233,9 @@ class PodcastEpisode extends Model { toOldJSONExpanded(libraryItemId) { const json = this.toOldJSON(libraryItemId) - json.audioTrack = this.track + json.audioTrack = this.getAudioTrack(libraryItemId) json.size = this.size - json.duration = this.audioFile?.duration || 0 + json.duration = this.duration return json } diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index 6578bae0c5..b1cdf43b4c 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -249,10 +249,6 @@ class LibraryItem { this.updatedAt = Date.now() } - getDirectPlayTracklist(episodeId) { - return this.media.getDirectPlayTracklist(episodeId) - } - /** * Save metadata.json file * TODO: Move to new LibraryItem model diff --git a/server/objects/PlaybackSession.js b/server/objects/PlaybackSession.js index 6950a54421..ba031b6654 100644 --- a/server/objects/PlaybackSession.js +++ b/server/objects/PlaybackSession.js @@ -1,8 +1,6 @@ const date = require('../libs/dateAndTime') const uuidv4 = require('uuid').v4 const serverVersion = require('../../package.json').version -const BookMetadata = require('./metadata/BookMetadata') -const PodcastMetadata = require('./metadata/PodcastMetadata') const DeviceInfo = require('./DeviceInfo') class PlaybackSession { @@ -60,7 +58,7 @@ class PlaybackSession { bookId: this.bookId, episodeId: this.episodeId, mediaType: this.mediaType, - mediaMetadata: this.mediaMetadata?.toJSON() || null, + mediaMetadata: structuredClone(this.mediaMetadata), chapters: (this.chapters || []).map((c) => ({ ...c })), displayTitle: this.displayTitle, displayAuthor: this.displayAuthor, @@ -82,7 +80,7 @@ class PlaybackSession { /** * Session data to send to clients - * @param {Object} [libraryItem] - old library item + * @param {import('../models/LibraryItem')} [libraryItem] * @returns */ toJSONForClient(libraryItem) { @@ -94,7 +92,7 @@ class PlaybackSession { bookId: this.bookId, episodeId: this.episodeId, mediaType: this.mediaType, - mediaMetadata: this.mediaMetadata?.toJSON() || null, + mediaMetadata: structuredClone(this.mediaMetadata), chapters: (this.chapters || []).map((c) => ({ ...c })), displayTitle: this.displayTitle, displayAuthor: this.displayAuthor, @@ -112,7 +110,7 @@ class PlaybackSession { startedAt: this.startedAt, updatedAt: this.updatedAt, audioTracks: this.audioTracks.map((at) => at.toJSON?.() || { ...at }), - libraryItem: libraryItem?.toJSONExpanded() || null + libraryItem: libraryItem?.toOldJSONExpanded() || null } } @@ -148,14 +146,7 @@ class PlaybackSession { this.serverVersion = session.serverVersion this.chapters = session.chapters || [] - this.mediaMetadata = null - if (session.mediaMetadata) { - if (this.mediaType === 'book') { - this.mediaMetadata = new BookMetadata(session.mediaMetadata) - } else if (this.mediaType === 'podcast') { - this.mediaMetadata = new PodcastMetadata(session.mediaMetadata) - } - } + this.mediaMetadata = session.mediaMetadata this.displayTitle = session.displayTitle || '' this.displayAuthor = session.displayAuthor || '' this.coverPath = session.coverPath @@ -205,6 +196,15 @@ class PlaybackSession { } } + /** + * + * @param {import('../models/LibraryItem')} libraryItem + * @param {*} userId + * @param {*} mediaPlayer + * @param {*} deviceInfo + * @param {*} startTime + * @param {*} episodeId + */ setData(libraryItem, userId, mediaPlayer, deviceInfo, startTime, episodeId = null) { this.id = uuidv4() this.userId = userId @@ -213,13 +213,12 @@ class PlaybackSession { this.bookId = episodeId ? null : libraryItem.media.id this.episodeId = episodeId this.mediaType = libraryItem.mediaType - this.mediaMetadata = libraryItem.media.metadata.clone() + this.mediaMetadata = libraryItem.media.oldMetadataToJSON() this.chapters = libraryItem.media.getChapters(episodeId) this.displayTitle = libraryItem.media.getPlaybackTitle(episodeId) this.displayAuthor = libraryItem.media.getPlaybackAuthor() this.coverPath = libraryItem.media.coverPath - - this.setDuration(libraryItem, episodeId) + this.duration = libraryItem.media.getPlaybackDuration(episodeId) this.mediaPlayer = mediaPlayer this.deviceInfo = deviceInfo || new DeviceInfo() @@ -235,14 +234,6 @@ class PlaybackSession { this.updatedAt = Date.now() } - setDuration(libraryItem, episodeId) { - if (episodeId) { - this.duration = libraryItem.media.getEpisodeDuration(episodeId) - } else { - this.duration = libraryItem.media.duration - } - } - addListeningTime(timeListened) { if (!timeListened || isNaN(timeListened)) return diff --git a/server/objects/Stream.js b/server/objects/Stream.js index 2ab6f50362..5f4feeef89 100644 --- a/server/objects/Stream.js +++ b/server/objects/Stream.js @@ -18,6 +18,7 @@ class Stream extends EventEmitter { this.id = sessionId this.user = user + /** @type {import('../models/LibraryItem')} */ this.libraryItem = libraryItem this.episodeId = episodeId @@ -40,31 +41,25 @@ class Stream extends EventEmitter { this.furthestSegmentCreated = 0 } - get isPodcast() { - return this.libraryItem.mediaType === 'podcast' - } + /** + * @returns {import('../models/PodcastEpisode') | null} + */ get episode() { - if (!this.isPodcast) return null - return this.libraryItem.media.episodes.find((ep) => ep.id === this.episodeId) - } - get libraryItemId() { - return this.libraryItem.id + if (!this.libraryItem.isPodcast) return null + return this.libraryItem.media.podcastEpisodes.find((ep) => ep.id === this.episodeId) } get mediaTitle() { - if (this.episode) return this.episode.title || '' - return this.libraryItem.media.metadata.title || '' + return this.libraryItem.media.getPlaybackTitle(this.episodeId) } get totalDuration() { - if (this.episode) return this.episode.duration - return this.libraryItem.media.duration + return this.libraryItem.media.getPlaybackDuration(this.episodeId) } get tracks() { - if (this.episode) return this.episode.tracks - return this.libraryItem.media.tracks + return this.libraryItem.getTrackList(this.episodeId) } get tracksAudioFileType() { if (!this.tracks.length) return null - return this.tracks[0].metadata.format + return this.tracks[0].metadata.ext.slice(1) } get tracksMimeType() { if (!this.tracks.length) return null @@ -116,8 +111,8 @@ class Stream extends EventEmitter { return { id: this.id, userId: this.user.id, - libraryItem: this.libraryItem.toJSONExpanded(), - episode: this.episode ? this.episode.toJSONExpanded() : null, + libraryItem: this.libraryItem.toOldJSONExpanded(), + episode: this.episode ? this.episode.toOldJSONExpanded(this.libraryItem.id) : null, segmentLength: this.segmentLength, playlistPath: this.playlistPath, clientPlaylistUri: this.clientPlaylistUri, diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js index 69a9b2f058..945e0e5661 100644 --- a/server/objects/entities/PodcastEpisode.js +++ b/server/objects/entities/PodcastEpisode.js @@ -168,16 +168,6 @@ class PodcastEpisode { return hasUpdates } - // Only checks container format - checkCanDirectPlay(payload) { - const supportedMimeTypes = payload.supportedMimeTypes || [] - return supportedMimeTypes.includes(this.audioFile.mimeType) - } - - getDirectPlayTracklist() { - return this.tracks - } - checkEqualsEnclosureUrl(url) { if (!this.enclosure?.url) return false return this.enclosure.url == url diff --git a/server/objects/mediaTypes/Book.js b/server/objects/mediaTypes/Book.js index 5d45501859..488c3aac70 100644 --- a/server/objects/mediaTypes/Book.js +++ b/server/objects/mediaTypes/Book.js @@ -150,27 +150,5 @@ class Book { this.coverPath = coverPath return true } - - // Only checks container format - checkCanDirectPlay(payload) { - var supportedMimeTypes = payload.supportedMimeTypes || [] - return !this.tracks.some((t) => !supportedMimeTypes.includes(t.mimeType)) - } - - getDirectPlayTracklist() { - return this.tracks - } - - getPlaybackTitle() { - return this.metadata.title - } - - getPlaybackAuthor() { - return this.metadata.authorName - } - - getChapters() { - return this.chapters?.map((ch) => ({ ...ch })) || [] - } } module.exports = Book diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index d33b28ba2c..2a009eb2dc 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -199,19 +199,6 @@ class Podcast { return this.episodes.some((ep) => (ep.guid && ep.guid === guid) || ep.checkEqualsEnclosureUrl(url)) } - // Only checks container format - checkCanDirectPlay(payload, episodeId) { - var episode = this.episodes.find((ep) => ep.id === episodeId) - if (!episode) return false - return episode.checkCanDirectPlay(payload) - } - - getDirectPlayTracklist(episodeId) { - var episode = this.episodes.find((ep) => ep.id === episodeId) - if (!episode) return false - return episode.getDirectPlayTracklist() - } - addPodcastEpisode(podcastEpisode) { this.episodes.push(podcastEpisode) } @@ -224,22 +211,6 @@ class Podcast { return episode } - getPlaybackTitle(episodeId) { - var episode = this.episodes.find((ep) => ep.id == episodeId) - if (!episode) return this.metadata.title - return episode.title - } - - getPlaybackAuthor() { - return this.metadata.author - } - - getEpisodeDuration(episodeId) { - var episode = this.episodes.find((ep) => ep.id == episodeId) - if (!episode) return 0 - return episode.duration - } - getEpisode(episodeId) { if (!episodeId) return null @@ -248,9 +219,5 @@ class Podcast { return this.episodes.find((ep) => ep.id == episodeId) } - - getChapters(episodeId) { - return this.getEpisode(episodeId)?.chapters?.map((ch) => ({ ...ch })) || [] - } } module.exports = Podcast diff --git a/server/objects/metadata/BookMetadata.js b/server/objects/metadata/BookMetadata.js index c6192f116c..0dfe1dbf3c 100644 --- a/server/objects/metadata/BookMetadata.js +++ b/server/objects/metadata/BookMetadata.js @@ -159,11 +159,6 @@ class BookMetadata { getSeries(seriesId) { return this.series.find((se) => se.id == seriesId) } - getSeriesSequence(seriesId) { - const series = this.series.find((se) => se.id == seriesId) - if (!series) return null - return series.sequence || '' - } update(payload) { const json = this.toJSON() From 63466ec48bee46a9b4a0d4e95b1652ec79220e23 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 3 Jan 2025 12:06:20 -0600 Subject: [PATCH 012/118] Fix deleting episode library file removes episode from playlist #3784 --- server/Database.js | 21 ++++++++++ server/controllers/LibraryItemController.js | 1 + server/models/Playlist.js | 44 +++++++++++++++++++++ server/routers/ApiRouter.js | 31 +-------------- 4 files changed, 67 insertions(+), 30 deletions(-) diff --git a/server/Database.js b/server/Database.js index bd14fbd5a1..2137b3c1f2 100644 --- a/server/Database.js +++ b/server/Database.js @@ -695,6 +695,27 @@ class Database { await book.destroy() } + const playlistMediaItemsWithNoMediaItem = await this.playlistMediaItemModel.findAll({ + include: [ + { + model: this.bookModel, + attributes: ['id'] + }, + { + model: this.podcastEpisodeModel, + attributes: ['id'] + } + ], + where: { + '$book.id$': null, + '$podcastEpisode.id$': null + } + }) + for (const playlistMediaItem of playlistMediaItemsWithNoMediaItem) { + Logger.warn(`Found playlistMediaItem with no book or podcastEpisode - removing it`) + await playlistMediaItem.destroy() + } + // Remove empty series const emptySeries = await this.seriesModel.findAll({ include: { diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index f08a60115b..5a46be4b32 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -971,6 +971,7 @@ class LibraryItemController { } } 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 Database.playlistModel.removeMediaItemsFromPlaylists([episodeToRemove.id]) await episodeToRemove.destroy() req.libraryItem.media.podcastEpisodes = req.libraryItem.media.podcastEpisodes.filter((ep) => ep.audioFile.ino !== req.params.fileid) diff --git a/server/models/Playlist.js b/server/models/Playlist.js index 7817211f3a..ec56248db8 100644 --- a/server/models/Playlist.js +++ b/server/models/Playlist.js @@ -1,5 +1,6 @@ const { DataTypes, Model, Op } = require('sequelize') const Logger = require('../Logger') +const SocketAuthority = require('../SocketAuthority') class Playlist extends Model { constructor(values, options) { @@ -163,6 +164,49 @@ class Playlist extends Model { return playlists } + /** + * Removes media items and re-orders playlists + * + * @param {string[]} mediaItemIds + */ + static async removeMediaItemsFromPlaylists(mediaItemIds) { + if (!mediaItemIds?.length) return + + const playlistsWithItem = await this.getPlaylistsForMediaItemIds(mediaItemIds) + + if (!playlistsWithItem.length) return + + for (const playlist of playlistsWithItem) { + let numMediaItems = playlist.playlistMediaItems.length + + let order = 1 + // Remove items in playlist and re-order + for (const playlistMediaItem of playlist.playlistMediaItems) { + if (mediaItemIds.includes(playlistMediaItem.mediaItemId)) { + await playlistMediaItem.destroy() + numMediaItems-- + } else { + if (playlistMediaItem.order !== order) { + playlistMediaItem.update({ + order + }) + } + order++ + } + } + + // If playlist is now empty then remove it + const jsonExpanded = await playlist.getOldJsonExpanded() + if (!numMediaItems) { + Logger.info(`[ApiRouter] Playlist "${playlist.name}" has no more items - removing it`) + await playlist.destroy() + SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded) + } else { + SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded) + } + } + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 235d25cd5f..4402fd04be 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -361,36 +361,7 @@ class ApiRouter { } // remove item from playlists - const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds) - for (const playlist of playlistsWithItem) { - let numMediaItems = playlist.playlistMediaItems.length - - let order = 1 - // Remove items in playlist and re-order - for (const playlistMediaItem of playlist.playlistMediaItems) { - if (mediaItemIds.includes(playlistMediaItem.mediaItemId)) { - await playlistMediaItem.destroy() - numMediaItems-- - } else { - if (playlistMediaItem.order !== order) { - playlistMediaItem.update({ - order - }) - } - order++ - } - } - - // If playlist is now empty then remove it - const jsonExpanded = await playlist.getOldJsonExpanded() - if (!numMediaItems) { - Logger.info(`[ApiRouter] Playlist "${playlist.name}" has no more items - removing it`) - await playlist.destroy() - SocketAuthority.clientEmitter(playlist.userId, 'playlist_removed', jsonExpanded) - } else { - SocketAuthority.clientEmitter(playlist.userId, 'playlist_updated', jsonExpanded) - } - } + await Database.playlistModel.removeMediaItemsFromPlaylists(mediaItemIds) // Close rss feed - remove from db and emit socket event await RssFeedManager.closeFeedForEntityId(libraryItemId) From 6467a92de6db0c9fe4eb5a492fe5272de68922e7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 3 Jan 2025 12:12:56 -0600 Subject: [PATCH 013/118] Remove media progress when deleting podcast episode audio file --- server/controllers/LibraryItemController.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 5a46be4b32..4da68866da 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -971,7 +971,20 @@ class LibraryItemController { } } 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) + // Remove episode from all playlists await Database.playlistModel.removeMediaItemsFromPlaylists([episodeToRemove.id]) + + // Remove episode media progress + const numProgressRemoved = await Database.mediaProgressModel.destroy({ + where: { + mediaItemId: episodeToRemove.id + } + }) + if (numProgressRemoved > 0) { + Logger.info(`[LibraryItemController] Removed media progress for episode ${episodeToRemove.id}`) + } + + // Remove episode await episodeToRemove.destroy() req.libraryItem.media.podcastEpisodes = req.libraryItem.media.podcastEpisodes.filter((ep) => ep.audioFile.ino !== req.params.fileid) From 0357dc90d40e965e51b3b3e1fdd92109d56a13ba Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 3 Jan 2025 14:07:27 -0600 Subject: [PATCH 014/118] Update libraryItem.updatedAt on media update --- server/controllers/LibraryItemController.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 4da68866da..74b8bdfc4a 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -249,6 +249,9 @@ class LibraryItemController { const hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url if (hasUpdates) { + req.libraryItem.changed('updatedAt', true) + await req.libraryItem.save() + if (isPodcastAutoDownloadUpdated) { this.cronManager.checkUpdatePodcastCron(req.libraryItem) } From 69d1744496e1eecabbb0ca1d54bda929ac73bc83 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 3 Jan 2025 16:48:24 -0600 Subject: [PATCH 015/118] Update podcasts to new library item model --- .../components/modals/podcast/EditEpisode.vue | 12 ++ .../modals/podcast/tabs/EpisodeDetails.vue | 9 +- server/controllers/PodcastController.js | 164 +++++++++--------- server/managers/CronManager.js | 2 +- server/managers/PodcastManager.js | 88 +++++++--- server/models/Podcast.js | 29 ++++ server/models/PodcastEpisode.js | 17 ++ server/objects/PodcastEpisodeDownload.js | 17 +- server/objects/entities/PodcastEpisode.js | 5 - server/objects/mediaTypes/Podcast.js | 5 - server/utils/ffmpegHelpers.js | 28 +-- 11 files changed, 235 insertions(+), 141 deletions(-) diff --git a/client/components/modals/podcast/EditEpisode.vue b/client/components/modals/podcast/EditEpisode.vue index b87f89c7d8..9702ce3805 100644 --- a/client/components/modals/podcast/EditEpisode.vue +++ b/client/components/modals/podcast/EditEpisode.vue @@ -170,6 +170,12 @@ export default { this.show = false } }, + libraryItemUpdated(libraryItem) { + const episode = libraryItem.media.episodes.find((e) => e.id === this.selectedEpisodeId) + if (episode) { + this.episodeItem = episode + } + }, hotkey(action) { if (action === this.$hotkeys.Modal.NEXT_PAGE) { this.goNextEpisode() @@ -178,9 +184,15 @@ export default { } }, registerListeners() { + if (this.libraryItem) { + this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated) + } this.$eventBus.$on('modal-hotkey', this.hotkey) }, unregisterListeners() { + if (this.libraryItem) { + this.$eventBus.$on(`${this.libraryItem.id}_updated`, this.libraryItemUpdated) + } this.$eventBus.$off('modal-hotkey', this.hotkey) } }, diff --git a/client/components/modals/podcast/tabs/EpisodeDetails.vue b/client/components/modals/podcast/tabs/EpisodeDetails.vue index 2084ddee68..85cfb4ffb2 100644 --- a/client/components/modals/podcast/tabs/EpisodeDetails.vue +++ b/client/components/modals/podcast/tabs/EpisodeDetails.vue @@ -163,13 +163,10 @@ export default { this.isProcessing = false if (updateResult) { - if (updateResult) { - this.$toast.success(this.$strings.ToastItemUpdateSuccess) - return true - } else { - this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary) - } + this.$toast.success(this.$strings.ToastItemUpdateSuccess) + return true } + return false } }, diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index 3610c2ea7f..c62742a501 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -19,6 +19,11 @@ const LibraryItem = require('../objects/LibraryItem') * @property {import('../models/User')} user * * @typedef {Request & RequestUserObject} RequestWithUser + * + * @typedef RequestEntityObject + * @property {import('../models/LibraryItem')} libraryItem + * + * @typedef {RequestWithUser & RequestEntityObject} RequestWithLibraryItem */ class PodcastController { @@ -112,11 +117,6 @@ class PodcastController { res.json(libraryItem.toJSONExpanded()) - if (payload.episodesToDownload?.length) { - Logger.info(`[PodcastController] Podcast created now starting ${payload.episodesToDownload.length} episode downloads`) - this.podcastManager.downloadPodcastEpisodes(libraryItem, payload.episodesToDownload) - } - // Turn on podcast auto download cron if not already on if (libraryItem.media.autoDownloadEpisodes) { this.cronManager.checkUpdatePodcastCron(libraryItem) @@ -213,7 +213,7 @@ class PodcastController { * * @this import('../routers/ApiRouter') * - * @param {RequestWithUser} req + * @param {RequestWithLibraryItem} req * @param {Response} res */ async checkNewEpisodes(req, res) { @@ -222,15 +222,14 @@ class PodcastController { return res.sendStatus(403) } - var libraryItem = req.libraryItem - if (!libraryItem.media.metadata.feedUrl) { - Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${libraryItem.id}`) - return res.status(500).send('Podcast has no rss feed url') + if (!req.libraryItem.media.feedURL) { + Logger.error(`[PodcastController] checkNewEpisodes no feed url for item ${req.libraryItem.id}`) + return res.status(400).send('Podcast has no rss feed url') } const maxEpisodesToDownload = !isNaN(req.query.limit) ? Number(req.query.limit) : 3 - var newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) + const newEpisodes = await this.podcastManager.checkAndDownloadNewEpisodes(req.libraryItem, maxEpisodesToDownload) res.json({ episodes: newEpisodes || [] }) @@ -258,23 +257,28 @@ class PodcastController { * * @this {import('../routers/ApiRouter')} * - * @param {RequestWithUser} req + * @param {RequestWithLibraryItem} req * @param {Response} res */ getEpisodeDownloads(req, res) { - var libraryItem = req.libraryItem - - var downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(libraryItem.id) + const downloadsInQueue = this.podcastManager.getEpisodeDownloadsInQueue(req.libraryItem.id) res.json({ downloads: downloadsInQueue.map((d) => d.toJSONForClient()) }) } + /** + * GET: /api/podcasts/:id/search-episode + * Search for an episode in a podcast + * + * @param {RequestWithLibraryItem} req + * @param {Response} res + */ async findEpisode(req, res) { - const rssFeedUrl = req.libraryItem.media.metadata.feedUrl + const rssFeedUrl = req.libraryItem.media.feedURL if (!rssFeedUrl) { Logger.error(`[PodcastController] findEpisode: Podcast has no feed url`) - return res.status(500).send('Podcast does not have an RSS feed URL') + return res.status(400).send('Podcast does not have an RSS feed URL') } const searchTitle = req.query.title @@ -292,7 +296,7 @@ class PodcastController { * * @this {import('../routers/ApiRouter')} * - * @param {RequestWithUser} req + * @param {RequestWithLibraryItem} req * @param {Response} res */ async downloadEpisodes(req, res) { @@ -300,13 +304,13 @@ class PodcastController { Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to download episodes`) return res.sendStatus(403) } - const libraryItem = req.libraryItem + const episodes = req.body - if (!episodes?.length) { + if (!Array.isArray(episodes) || !episodes.length) { return res.sendStatus(400) } - this.podcastManager.downloadPodcastEpisodes(libraryItem, episodes) + this.podcastManager.downloadPodcastEpisodes(req.libraryItem, episodes) res.sendStatus(200) } @@ -315,7 +319,7 @@ class PodcastController { * * @this {import('../routers/ApiRouter')} * - * @param {RequestWithUser} req + * @param {RequestWithLibraryItem} req * @param {Response} res */ async quickMatchEpisodes(req, res) { @@ -325,10 +329,11 @@ class PodcastController { } const overrideDetails = req.query.override === '1' - const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails }) + const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem) + const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(oldLibraryItem, { overrideDetails }) if (episodesUpdated) { - await Database.updateLibraryItem(req.libraryItem) - SocketAuthority.emitter('item_updated', req.libraryItem.toJSONExpanded()) + await Database.updateLibraryItem(oldLibraryItem) + SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) } res.json({ @@ -339,58 +344,76 @@ class PodcastController { /** * PATCH: /api/podcasts/:id/episode/:episodeId * - * @param {RequestWithUser} req + * @param {RequestWithLibraryItem} req * @param {Response} res */ async updateEpisode(req, res) { - const libraryItem = req.libraryItem - - var episodeId = req.params.episodeId - if (!libraryItem.media.checkHasEpisode(episodeId)) { + /** @type {import('../models/PodcastEpisode')} */ + const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === req.params.episodeId) + if (!episode) { return res.status(404).send('Episode not found') } - if (libraryItem.media.updateEpisode(episodeId, req.body)) { - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) + const updatePayload = {} + const supportedStringKeys = ['title', 'subtitle', 'description', 'pubDate', 'episode', 'season', 'episodeType'] + for (const key in req.body) { + if (supportedStringKeys.includes(key) && typeof req.body[key] === 'string') { + updatePayload[key] = req.body[key] + } else if (key === 'chapters' && Array.isArray(req.body[key]) && req.body[key].every((ch) => typeof ch === 'object' && ch.title && ch.start)) { + updatePayload[key] = req.body[key] + } else if (key === 'publishedAt' && typeof req.body[key] === 'number') { + updatePayload[key] = req.body[key] + } + } + + if (Object.keys(updatePayload).length) { + episode.set(updatePayload) + if (episode.changed()) { + Logger.info(`[PodcastController] Updated episode "${episode.title}" keys`, episode.changed()) + await episode.save() + + SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) + } else { + Logger.info(`[PodcastController] No changes to episode "${episode.title}"`) + } } - res.json(libraryItem.toJSONExpanded()) + res.json(req.libraryItem.toOldJSONExpanded()) } /** * GET: /api/podcasts/:id/episode/:episodeId * - * @param {RequestWithUser} req + * @param {RequestWithLibraryItem} req * @param {Response} res */ async getEpisode(req, res) { const episodeId = req.params.episodeId - const libraryItem = req.libraryItem - const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId) + /** @type {import('../models/PodcastEpisode')} */ + const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === episodeId) if (!episode) { - Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${libraryItem.id}`) + Logger.error(`[PodcastController] getEpisode episode ${episodeId} not found for item ${req.libraryItem.id}`) return res.sendStatus(404) } - res.json(episode) + res.json(episode.toOldJSON(req.libraryItem.id)) } /** * DELETE: /api/podcasts/:id/episode/:episodeId * - * @param {RequestWithUser} req + * @param {RequestWithLibraryItem} req * @param {Response} res */ async removeEpisode(req, res) { const episodeId = req.params.episodeId - const libraryItem = req.libraryItem const hardDelete = req.query.hard === '1' - const episode = libraryItem.media.episodes.find((ep) => ep.id === episodeId) + /** @type {import('../models/PodcastEpisode')} */ + const episode = req.libraryItem.media.podcastEpisodes.find((ep) => ep.id === episodeId) if (!episode) { - Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`) + Logger.error(`[PodcastController] removeEpisode episode ${episodeId} not found for item ${req.libraryItem.id}`) return res.sendStatus(404) } @@ -407,36 +430,8 @@ class PodcastController { }) } - // Remove episode from Podcast and library file - const episodeRemoved = libraryItem.media.removeEpisode(episodeId) - if (episodeRemoved?.audioFile) { - libraryItem.removeLibraryFile(episodeRemoved.audioFile.ino) - } - - // Update/remove playlists that had this podcast episode - const playlistMediaItems = await Database.playlistMediaItemModel.findAll({ - where: { - mediaItemId: episodeId - }, - include: { - model: Database.playlistModel, - include: Database.playlistMediaItemModel - } - }) - for (const pmi of playlistMediaItems) { - const numItems = pmi.playlist.playlistMediaItems.length - 1 - - if (!numItems) { - Logger.info(`[PodcastController] Playlist "${pmi.playlist.name}" has no more items - removing it`) - const jsonExpanded = await pmi.playlist.getOldJsonExpanded() - SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_removed', jsonExpanded) - await pmi.playlist.destroy() - } else { - await pmi.destroy() - const jsonExpanded = await pmi.playlist.getOldJsonExpanded() - SocketAuthority.clientEmitter(pmi.playlist.userId, 'playlist_updated', jsonExpanded) - } - } + // Remove episode from playlists + await Database.playlistModel.removeMediaItemsFromPlaylists([episodeId]) // Remove media progress for this episode const mediaProgressRemoved = await Database.mediaProgressModel.destroy({ @@ -448,9 +443,16 @@ class PodcastController { Logger.info(`[PodcastController] Removed ${mediaProgressRemoved} media progress for episode ${episode.id}`) } - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) - res.json(libraryItem.toJSON()) + // Remove episode + await episode.destroy() + + // Remove library file + req.libraryItem.libraryFiles = req.libraryItem.libraryFiles.filter((file) => file.ino !== episode.audioFile.ino) + req.libraryItem.changed('libraryFiles', true) + await req.libraryItem.save() + + SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) + res.json(req.libraryItem.toOldJSON()) } /** @@ -460,15 +462,15 @@ class PodcastController { * @param {NextFunction} next */ async middleware(req, res, next) { - const item = await Database.libraryItemModel.getOldById(req.params.id) - if (!item?.media) return res.sendStatus(404) + const libraryItem = await Database.libraryItemModel.getExpandedById(req.params.id) + if (!libraryItem?.media) return res.sendStatus(404) - if (!item.isPodcast) { + if (!libraryItem.isPodcast) { return res.sendStatus(500) } // Check user can access this library item - if (!req.user.checkCanAccessLibraryItem(item)) { + if (!req.user.checkCanAccessLibraryItem(libraryItem)) { return res.sendStatus(403) } @@ -480,7 +482,7 @@ class PodcastController { return res.sendStatus(403) } - req.libraryItem = item + req.libraryItem = libraryItem next() } } diff --git a/server/managers/CronManager.js b/server/managers/CronManager.js index a4dbe6b456..c61fb0497c 100644 --- a/server/managers/CronManager.js +++ b/server/managers/CronManager.js @@ -181,7 +181,7 @@ class CronManager { // Get podcast library items to check const libraryItems = [] for (const libraryItemId of libraryItemIds) { - const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId) + const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItemId) if (!libraryItem) { Logger.error(`[CronManager] Library item ${libraryItemId} not found for episode check cron ${expression}`) podcastCron.libraryItemIds = podcastCron.libraryItemIds.filter((lid) => lid !== libraryItemId) // Filter it out diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 456927c8c1..92053707e7 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -52,11 +52,16 @@ class PodcastManager { } } + /** + * + * @param {import('../models/LibraryItem')} libraryItem + * @param {*} episodesToDownload + * @param {*} isAutoDownload + */ async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) { - let index = Math.max(...libraryItem.media.episodes.filter((ep) => ep.index == null || isNaN(ep.index)).map((ep) => Number(ep.index))) + 1 for (const ep of episodesToDownload) { const newPe = new PodcastEpisode() - newPe.setData(ep, index++) + newPe.setData(ep, null) newPe.libraryItemId = libraryItem.id newPe.podcastId = libraryItem.media.id const newPeDl = new PodcastEpisodeDownload() @@ -263,16 +268,21 @@ class PodcastManager { return newAudioFile } - // Returns false if auto download episodes was disabled (disabled if reaches max failed checks) + /** + * + * @param {import('../models/LibraryItem')} libraryItem + * @returns {Promise} - Returns false if auto download episodes was disabled (disabled if reaches max failed checks) + */ async runEpisodeCheck(libraryItem) { - const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0) - const latestEpisodePublishedAt = libraryItem.media.latestEpisodePublished - Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" | Last check: ${lastEpisodeCheckDate} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`) + const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0 + const latestEpisodePublishedAt = libraryItem.media.getLatestEpisodePublishedAt() + + Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" | Last check: ${new Date(lastEpisodeCheck)} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`) // Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate // lastEpisodeCheckDate will be the current time when adding a new podcast const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate - Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`) + Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`) var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload) Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes?.length || 'N/A'} episodes found`) @@ -283,36 +293,47 @@ class PodcastManager { if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0 this.failedCheckMap[libraryItem.id]++ if (this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) { - Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}" - disabling auto download`) + Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}" - disabling auto download`) libraryItem.media.autoDownloadEpisodes = false delete this.failedCheckMap[libraryItem.id] } else { - Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.metadata.title}"`) + Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}"`) } } else if (newEpisodes.length) { delete this.failedCheckMap[libraryItem.id] - Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`) + Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.title}" - starting download`) this.downloadPodcastEpisodes(libraryItem, newEpisodes, true) } else { delete this.failedCheckMap[libraryItem.id] - Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.metadata.title}"`) + Logger.debug(`[PodcastManager] No new episodes for "${libraryItem.media.title}"`) } - libraryItem.media.lastEpisodeCheck = Date.now() - libraryItem.updatedAt = Date.now() - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) + libraryItem.media.lastEpisodeCheck = new Date() + await libraryItem.media.save() + + libraryItem.changed('updatedAt', true) + await libraryItem.save() + + SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) + return libraryItem.media.autoDownloadEpisodes } + /** + * + * @param {import('../models/LibraryItem')} podcastLibraryItem + * @param {number} dateToCheckForEpisodesAfter - Unix timestamp + * @param {number} maxNewEpisodes + * @returns + */ async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter, maxNewEpisodes = 3) { - if (!podcastLibraryItem.media.metadata.feedUrl) { - Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`) + if (!podcastLibraryItem.media.feedURL) { + Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`) return false } - const feed = await getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl) + const feed = await getPodcastFeed(podcastLibraryItem.media.feedURL) if (!feed?.episodes) { - Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`, feed) + Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed) return false } @@ -326,21 +347,32 @@ class PodcastManager { return newEpisodes } + /** + * + * @param {import('../models/LibraryItem')} libraryItem + * @param {*} maxEpisodesToDownload + * @returns + */ async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) { - const lastEpisodeCheckDate = new Date(libraryItem.media.lastEpisodeCheck || 0) - Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.metadata.title}" - Last episode check: ${lastEpisodeCheckDate}`) - var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, libraryItem.media.lastEpisodeCheck, maxEpisodesToDownload) + const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0 + const lastEpisodeCheckDate = lastEpisodeCheck > 0 ? libraryItem.media.lastEpisodeCheck : 'Never' + Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.title}" - Last episode check: ${lastEpisodeCheckDate}`) + + var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, lastEpisodeCheck, maxEpisodesToDownload) if (newEpisodes.length) { - Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.metadata.title}" - starting download`) + Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.title}" - starting download`) this.downloadPodcastEpisodes(libraryItem, newEpisodes, false) } else { - Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.metadata.title}"`) + Logger.info(`[PodcastManager] No new episodes found for podcast "${libraryItem.media.title}"`) } - libraryItem.media.lastEpisodeCheck = Date.now() - libraryItem.updatedAt = Date.now() - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) + libraryItem.media.lastEpisodeCheck = new Date() + await libraryItem.media.save() + + libraryItem.changed('updatedAt', true) + await libraryItem.save() + + SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) return newEpisodes } diff --git a/server/models/Podcast.js b/server/models/Podcast.js index 188c1070ab..fd4713052a 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -259,6 +259,10 @@ class Podcast extends Model { this.autoDownloadSchedule = payload.autoDownloadSchedule hasUpdates = true } + if (typeof payload.lastEpisodeCheck === 'number' && payload.lastEpisodeCheck !== this.lastEpisodeCheck?.valueOf()) { + this.lastEpisodeCheck = payload.lastEpisodeCheck + hasUpdates = true + } const numberKeys = ['maxEpisodesToKeep', 'maxNewEpisodesToDownload'] numberKeys.forEach((key) => { @@ -348,6 +352,31 @@ class Podcast extends Model { return episode.duration } + /** + * + * @returns {number} - Unix timestamp + */ + getLatestEpisodePublishedAt() { + return this.podcastEpisodes.reduce((latest, episode) => { + if (episode.publishedAt?.valueOf() > latest) { + return episode.publishedAt.valueOf() + } + return latest + }, 0) + } + + /** + * Used for checking if an rss feed episode is already in the podcast + * + * @param {Object} feedEpisode - object from rss feed + * @returns {boolean} + */ + checkHasEpisodeByFeedEpisode(feedEpisode) { + const guid = feedEpisode.guid + const url = feedEpisode.enclosure.url + return this.podcastEpisodes.some((ep) => ep.checkMatchesGuidOrEnclosureUrl(guid, url)) + } + /** * Old model kept metadata in a separate object */ diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 24d07041cd..4c9967f898 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -143,6 +143,23 @@ class PodcastEpisode extends Model { return this.audioFile?.duration || 0 } + /** + * Used for matching the episode with an episode in the RSS feed + * + * @param {string} guid + * @param {string} enclosureURL + * @returns {boolean} + */ + checkMatchesGuidOrEnclosureUrl(guid, enclosureURL) { + if (this.extraData?.guid && this.extraData.guid === guid) { + return true + } + if (this.enclosureURL && this.enclosureURL === enclosureURL) { + return true + } + return false + } + /** * Used in client players * diff --git a/server/objects/PodcastEpisodeDownload.js b/server/objects/PodcastEpisodeDownload.js index eb9f059a33..ecda4a472c 100644 --- a/server/objects/PodcastEpisodeDownload.js +++ b/server/objects/PodcastEpisodeDownload.js @@ -6,8 +6,10 @@ const globals = require('../utils/globals') class PodcastEpisodeDownload { constructor() { this.id = null + /** @type {import('../objects/entities/PodcastEpisode')} */ this.podcastEpisode = null this.url = null + /** @type {import('../models/LibraryItem')} */ this.libraryItem = null this.libraryId = null @@ -27,7 +29,7 @@ class PodcastEpisodeDownload { id: this.id, episodeDisplayTitle: this.podcastEpisode?.title ?? null, url: this.url, - libraryItemId: this.libraryItem?.id || null, + libraryItemId: this.libraryItemId, libraryId: this.libraryId || null, isFinished: this.isFinished, failed: this.failed, @@ -35,8 +37,8 @@ class PodcastEpisodeDownload { startedAt: this.startedAt, createdAt: this.createdAt, finishedAt: this.finishedAt, - podcastTitle: this.libraryItem?.media.metadata.title ?? null, - podcastExplicit: !!this.libraryItem?.media.metadata.explicit, + podcastTitle: this.libraryItem?.media.title ?? null, + podcastExplicit: !!this.libraryItem?.media.explicit, season: this.podcastEpisode?.season ?? null, episode: this.podcastEpisode?.episode ?? null, episodeType: this.podcastEpisode?.episodeType ?? 'full', @@ -80,9 +82,16 @@ class PodcastEpisodeDownload { return this.targetFilename } get libraryItemId() { - return this.libraryItem ? this.libraryItem.id : null + return this.libraryItem?.id || null } + /** + * + * @param {import('../objects/entities/PodcastEpisode')} podcastEpisode - old model + * @param {import('../models/LibraryItem')} libraryItem + * @param {*} isAutoDownload + * @param {*} libraryId + */ setData(podcastEpisode, libraryItem, isAutoDownload, libraryId) { this.id = uuidv4() this.podcastEpisode = podcastEpisode diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js index 945e0e5661..e759a0ebc2 100644 --- a/server/objects/entities/PodcastEpisode.js +++ b/server/objects/entities/PodcastEpisode.js @@ -167,10 +167,5 @@ class PodcastEpisode { } return hasUpdates } - - checkEqualsEnclosureUrl(url) { - if (!this.enclosure?.url) return false - return this.enclosure.url == url - } } module.exports = PodcastEpisode diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index 2a009eb2dc..8d6b541d9b 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -193,11 +193,6 @@ class Podcast { checkHasEpisode(episodeId) { return this.episodes.some((ep) => ep.id === episodeId) } - checkHasEpisodeByFeedEpisode(feedEpisode) { - const guid = feedEpisode.guid - const url = feedEpisode.enclosure.url - return this.episodes.some((ep) => (ep.guid && ep.guid === guid) || ep.checkEqualsEnclosureUrl(url)) - } addPodcastEpisode(podcastEpisode) { this.episodes.push(podcastEpisode) diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index c70242252c..06e20f1dd4 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -97,6 +97,11 @@ async function resizeImage(filePath, outputPath, width, height) { } module.exports.resizeImage = resizeImage +/** + * + * @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload + * @returns + */ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { return new Promise(async (resolve) => { const response = await axios({ @@ -118,21 +123,22 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { ffmpeg.addOption('-loglevel debug') // Debug logs printed on error ffmpeg.outputOptions('-c:a', 'copy', '-map', '0:a', '-metadata', 'podcast=1') - const podcastMetadata = podcastEpisodeDownload.libraryItem.media.metadata + /** @type {import('../models/Podcast')} */ + const podcast = podcastEpisodeDownload.libraryItem.media const podcastEpisode = podcastEpisodeDownload.podcastEpisode const finalSizeInBytes = Number(podcastEpisode.enclosure?.length || 0) const taggings = { - album: podcastMetadata.title, - 'album-sort': podcastMetadata.title, - artist: podcastMetadata.author, - 'artist-sort': podcastMetadata.author, + album: podcast.title, + 'album-sort': podcast.title, + artist: podcast.author, + 'artist-sort': podcast.author, comment: podcastEpisode.description, subtitle: podcastEpisode.subtitle, disc: podcastEpisode.season, - genre: podcastMetadata.genres.length ? podcastMetadata.genres.join(';') : null, - language: podcastMetadata.language, - MVNM: podcastMetadata.title, + genre: podcast.genres.length ? podcast.genres.join(';') : null, + language: podcast.language, + MVNM: podcast.title, MVIN: podcastEpisode.episode, track: podcastEpisode.episode, 'series-part': podcastEpisode.episode, @@ -141,9 +147,9 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { year: podcastEpisode.pubYear, date: podcastEpisode.pubDate, releasedate: podcastEpisode.pubDate, - 'itunes-id': podcastMetadata.itunesId, - 'podcast-type': podcastMetadata.type, - 'episode-type': podcastMetadata.episodeType + 'itunes-id': podcast.itunesId, + 'podcast-type': podcast.podcastType, + 'episode-type': podcastEpisode.episodeType } for (const tag in taggings) { From 43d8d9b286223d8cfdca895adc56c3d09469430e Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 4 Jan 2025 20:16:48 +0200 Subject: [PATCH 016/118] Fix ffmpeg.addOption for transcoding --- server/objects/Stream.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/objects/Stream.js b/server/objects/Stream.js index 2ab6f50362..1a94a92e42 100644 --- a/server/objects/Stream.js +++ b/server/objects/Stream.js @@ -280,15 +280,15 @@ class Stream extends EventEmitter { this.ffmpeg.addOption([`-loglevel ${logLevel}`, '-map 0:a', `-c:a ${audioCodec}`]) const hlsOptions = ['-f hls', '-copyts', '-avoid_negative_ts make_non_negative', '-max_delay 5000000', '-max_muxing_queue_size 2048', `-hls_time 6`, `-hls_segment_type ${this.hlsSegmentType}`, `-start_number ${this.segmentStartNumber}`, '-hls_playlist_type vod', '-hls_list_size 0', '-hls_allow_cache 0'] + this.ffmpeg.addOption(hlsOptions) if (this.hlsSegmentType === 'fmp4') { - hlsOptions.push('-strict -2') + this.ffmpeg.addOption('-strict -2') var fmp4InitFilename = Path.join(this.streamPath, 'init.mp4') // var fmp4InitFilename = 'init.mp4' - hlsOptions.push(`-hls_fmp4_init_filename ${fmp4InitFilename}`) + this.ffmpeg.addOption('-hls_fmp4_init_filename', fmp4InitFilename) } - this.ffmpeg.addOption(hlsOptions) var segmentFilename = Path.join(this.streamPath, this.segmentBasename) - this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`) + this.ffmpeg.addOption('-hls_segment_filename', segmentFilename) this.ffmpeg.output(this.finalPlaylistPath) this.ffmpeg.on('start', (command) => { From d8823c8b1ca1b94fa9effea8da388f946d9d2005 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 4 Jan 2025 12:41:09 -0600 Subject: [PATCH 017/118] Update podcasts to new library item model --- server/controllers/PodcastController.js | 104 ++++++-- server/managers/CoverManager.js | 7 +- server/managers/CronManager.js | 2 +- server/managers/NotificationManager.js | 15 +- server/managers/PodcastManager.js | 294 +++++++++++++-------- server/models/Podcast.js | 41 ++- server/models/PodcastEpisode.js | 34 +++ server/objects/LibraryItem.js | 40 --- server/objects/PodcastEpisodeDownload.js | 41 +-- server/objects/entities/PodcastEpisode.js | 22 -- server/objects/mediaTypes/Podcast.js | 36 --- server/objects/metadata/PodcastMetadata.js | 18 -- server/utils/ffmpegHelpers.js | 4 +- server/utils/podcastUtils.js | 45 +++- 14 files changed, 419 insertions(+), 284 deletions(-) diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index c62742a501..3d8ff240a4 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -1,3 +1,4 @@ +const Path = require('path') const { Request, Response, NextFunction } = require('express') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') @@ -12,8 +13,6 @@ const { validateUrl } = require('../utils/index') const Scanner = require('../scanner/Scanner') const CoverManager = require('../managers/CoverManager') -const LibraryItem = require('../objects/LibraryItem') - /** * @typedef RequestUserObject * @property {import('../models/User')} user @@ -42,6 +41,9 @@ class PodcastController { return res.sendStatus(403) } const payload = req.body + if (!payload.media || !payload.media.metadata) { + return res.status(400).send('Invalid request body. "media" and "media.metadata" are required') + } const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId) if (!library) { @@ -83,43 +85,87 @@ class PodcastController { let relPath = payload.path.replace(folder.fullPath, '') if (relPath.startsWith('/')) relPath = relPath.slice(1) - const libraryItemPayload = { - path: podcastPath, - relPath, - folderId: payload.folderId, - libraryId: payload.libraryId, - ino: libraryItemFolderStats.ino, - mtimeMs: libraryItemFolderStats.mtimeMs || 0, - ctimeMs: libraryItemFolderStats.ctimeMs || 0, - birthtimeMs: libraryItemFolderStats.birthtimeMs || 0, - media: payload.media - } - - const libraryItem = new LibraryItem() - libraryItem.setData('podcast', libraryItemPayload) + let newLibraryItem = null + const transaction = await Database.sequelize.transaction() + try { + const podcast = await Database.podcastModel.createFromRequest(payload.media, transaction) + + newLibraryItem = await Database.libraryItemModel.create( + { + ino: libraryItemFolderStats.ino, + path: podcastPath, + relPath, + mediaId: podcast.id, + mediaType: 'podcast', + isFile: false, + isMissing: false, + isInvalid: false, + mtime: libraryItemFolderStats.mtimeMs || 0, + ctime: libraryItemFolderStats.ctimeMs || 0, + birthtime: libraryItemFolderStats.birthtimeMs || 0, + size: 0, + libraryFiles: [], + extraData: {}, + libraryId: library.id, + libraryFolderId: folder.id + }, + { transaction } + ) + + await transaction.commit() + } catch (error) { + Logger.error(`[PodcastController] Failed to create podcast: ${error}`) + await transaction.rollback() + return res.status(500).send('Failed to create podcast') + } + + newLibraryItem.media = await newLibraryItem.getMediaExpanded() // Download and save cover image - if (payload.media.metadata.imageUrl) { - // TODO: Scan cover image to library files + if (typeof payload.media.metadata.imageUrl === 'string' && payload.media.metadata.imageUrl.startsWith('http')) { // Podcast cover will always go into library item folder - const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, payload.media.metadata.imageUrl, true) - if (coverResponse) { - if (coverResponse.error) { - Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`) - } else if (coverResponse.cover) { - libraryItem.media.coverPath = coverResponse.cover + const coverResponse = await CoverManager.downloadCoverFromUrlNew(payload.media.metadata.imageUrl, newLibraryItem.id, newLibraryItem.path, true) + if (coverResponse.error) { + Logger.error(`[PodcastController] Download cover error from "${payload.media.metadata.imageUrl}": ${coverResponse.error}`) + } else if (coverResponse.cover) { + const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover) + if (!coverImageFileStats) { + Logger.error(`[PodcastController] Failed to get cover image stats for "${coverResponse.cover}"`) + } else { + // Add libraryFile to libraryItem and coverPath to podcast + const newLibraryFile = { + ino: coverImageFileStats.ino, + fileType: 'image', + addedAt: Date.now(), + updatedAt: Date.now(), + metadata: { + filename: Path.basename(coverResponse.cover), + ext: Path.extname(coverResponse.cover).slice(1), + path: coverResponse.cover, + relPath: Path.basename(coverResponse.cover), + size: coverImageFileStats.size, + mtimeMs: coverImageFileStats.mtimeMs || 0, + ctimeMs: coverImageFileStats.ctimeMs || 0, + birthtimeMs: coverImageFileStats.birthtimeMs || 0 + } + } + newLibraryItem.libraryFiles.push(newLibraryFile) + newLibraryItem.changed('libraryFiles', true) + await newLibraryItem.save() + + newLibraryItem.media.coverPath = coverResponse.cover + await newLibraryItem.media.save() } } } - await Database.createLibraryItem(libraryItem) - SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded()) + SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded()) - res.json(libraryItem.toJSONExpanded()) + res.json(newLibraryItem.toOldJSONExpanded()) // Turn on podcast auto download cron if not already on - if (libraryItem.media.autoDownloadEpisodes) { - this.cronManager.checkUpdatePodcastCron(libraryItem) + if (newLibraryItem.media.autoDownloadEpisodes) { + this.cronManager.checkUpdatePodcastCron(newLibraryItem) } } diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js index c995a446d6..945c69abcc 100644 --- a/server/managers/CoverManager.js +++ b/server/managers/CoverManager.js @@ -338,13 +338,14 @@ class CoverManager { * * @param {string} url * @param {string} libraryItemId - * @param {string} [libraryItemPath] null if library item isFile or is from adding new podcast + * @param {string} [libraryItemPath] - null if library item isFile + * @param {boolean} [forceLibraryItemFolder=false] - force save cover with library item (used for adding new podcasts) * @returns {Promise<{error:string}|{cover:string}>} */ - async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath) { + async downloadCoverFromUrlNew(url, libraryItemId, libraryItemPath, forceLibraryItemFolder = false) { try { let coverDirPath = null - if (global.ServerSettings.storeCoverWithItem && libraryItemPath) { + if ((global.ServerSettings.storeCoverWithItem || forceLibraryItemFolder) && libraryItemPath) { coverDirPath = libraryItemPath } else { coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId) diff --git a/server/managers/CronManager.js b/server/managers/CronManager.js index c61fb0497c..3f94858381 100644 --- a/server/managers/CronManager.js +++ b/server/managers/CronManager.js @@ -217,7 +217,7 @@ class CronManager { /** * - * @param {import('../models/LibraryItem')} libraryItem - this can be the old model + * @param {import('../models/LibraryItem')} libraryItem */ checkUpdatePodcastCron(libraryItem) { // Remove from old cron by library item id diff --git a/server/managers/NotificationManager.js b/server/managers/NotificationManager.js index c48e878c2c..8edcf42802 100644 --- a/server/managers/NotificationManager.js +++ b/server/managers/NotificationManager.js @@ -14,6 +14,11 @@ class NotificationManager { return notificationData } + /** + * + * @param {import('../models/LibraryItem')} libraryItem + * @param {import('../models/PodcastEpisode')} episode + */ async onPodcastEpisodeDownloaded(libraryItem, episode) { if (!Database.notificationSettings.isUseable) return @@ -22,17 +27,17 @@ class NotificationManager { return } - Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.metadata.title}`) + Logger.debug(`[NotificationManager] onPodcastEpisodeDownloaded: Episode "${episode.title}" for podcast ${libraryItem.media.title}`) const library = await Database.libraryModel.findByPk(libraryItem.libraryId) const eventData = { libraryItemId: libraryItem.id, libraryId: libraryItem.libraryId, libraryName: library?.name || 'Unknown', mediaTags: (libraryItem.media.tags || []).join(', '), - podcastTitle: libraryItem.media.metadata.title, - podcastAuthor: libraryItem.media.metadata.author || '', - podcastDescription: libraryItem.media.metadata.description || '', - podcastGenres: (libraryItem.media.metadata.genres || []).join(', '), + podcastTitle: libraryItem.media.title, + podcastAuthor: libraryItem.media.author || '', + podcastDescription: libraryItem.media.description || '', + podcastGenres: (libraryItem.media.genres || []).join(', '), episodeId: episode.id, episodeTitle: episode.title, episodeSubtitle: episode.subtitle || '', diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 92053707e7..bd42e74bb5 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -1,3 +1,4 @@ +const Path = require('path') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') @@ -19,9 +20,7 @@ const NotificationManager = require('../managers/NotificationManager') const LibraryFile = require('../objects/files/LibraryFile') const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload') -const PodcastEpisode = require('../objects/entities/PodcastEpisode') const AudioFile = require('../objects/files/AudioFile') -const LibraryItem = require('../objects/LibraryItem') class PodcastManager { constructor() { @@ -55,17 +54,13 @@ class PodcastManager { /** * * @param {import('../models/LibraryItem')} libraryItem - * @param {*} episodesToDownload - * @param {*} isAutoDownload + * @param {import('../utils/podcastUtils').RssPodcastEpisode[]} episodesToDownload + * @param {boolean} isAutoDownload - If this download was triggered by auto download */ async downloadPodcastEpisodes(libraryItem, episodesToDownload, isAutoDownload) { for (const ep of episodesToDownload) { - const newPe = new PodcastEpisode() - newPe.setData(ep, null) - newPe.libraryItemId = libraryItem.id - newPe.podcastId = libraryItem.media.id const newPeDl = new PodcastEpisodeDownload() - newPeDl.setData(newPe, libraryItem, isAutoDownload, libraryItem.libraryId) + newPeDl.setData(ep, libraryItem, isAutoDownload, libraryItem.libraryId) this.startPodcastEpisodeDownload(newPeDl) } } @@ -91,20 +86,20 @@ class PodcastManager { key: 'MessageDownloadingEpisode' } const taskDescriptionString = { - text: `Downloading episode "${podcastEpisodeDownload.podcastEpisode.title}".`, + text: `Downloading episode "${podcastEpisodeDownload.episodeTitle}".`, key: 'MessageTaskDownloadingEpisodeDescription', - subs: [podcastEpisodeDownload.podcastEpisode.title] + subs: [podcastEpisodeDownload.episodeTitle] } const task = TaskManager.createAndAddTask('download-podcast-episode', taskTitleString, taskDescriptionString, false, taskData) SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient()) this.currentDownload = podcastEpisodeDownload - // If this file already exists then append the episode id to the filename + // If this file already exists then append a uuid to the filename // e.g. "/tagesschau 20 Uhr.mp3" becomes "/tagesschau 20 Uhr (ep_asdfasdf).mp3" // this handles podcasts where every title is the same (ref https://github.com/advplyr/audiobookshelf/issues/1802) if (await fs.pathExists(this.currentDownload.targetPath)) { - this.currentDownload.appendEpisodeId = true + this.currentDownload.appendRandomId = true } // Ignores all added files to this dir @@ -145,7 +140,7 @@ class PodcastManager { } task.setFailed(taskFailedString) } else { - Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.podcastEpisode.title}"`) + Logger.info(`[PodcastManager] Successfully downloaded podcast episode "${this.currentDownload.episodeTitle}"`) this.currentDownload.setFinished(true) task.setFinished() } @@ -171,47 +166,61 @@ class PodcastManager { } } + /** + * Scans the downloaded audio file, create the podcast episode, remove oldest episode if necessary + * @returns {Promise} - Returns true if added + */ async scanAddPodcastEpisodeAudioFile() { - const libraryFile = await this.getLibraryFile(this.currentDownload.targetPath, this.currentDownload.targetRelPath) + const libraryFile = new LibraryFile() + await libraryFile.setDataFromPath(this.currentDownload.targetPath, this.currentDownload.targetRelPath) const audioFile = await this.probeAudioFile(libraryFile) if (!audioFile) { return false } - const libraryItem = await Database.libraryItemModel.getOldById(this.currentDownload.libraryItem.id) + const libraryItem = await Database.libraryItemModel.getExpandedById(this.currentDownload.libraryItem.id) if (!libraryItem) { Logger.error(`[PodcastManager] Podcast Episode finished but library item was not found ${this.currentDownload.libraryItem.id}`) return false } - const podcastEpisode = this.currentDownload.podcastEpisode - podcastEpisode.audioFile = audioFile + const podcastEpisode = await Database.podcastEpisodeModel.createFromRssPodcastEpisode(this.currentDownload.rssPodcastEpisode, libraryItem.media.id, audioFile) - if (audioFile.chapters?.length) { - podcastEpisode.chapters = audioFile.chapters.map((ch) => ({ ...ch })) - } + libraryItem.libraryFiles.push(libraryFile.toJSON()) + libraryItem.changed('libraryFiles', true) - libraryItem.media.addPodcastEpisode(podcastEpisode) - if (libraryItem.isInvalid) { - // First episode added to an empty podcast - libraryItem.isInvalid = false - } - libraryItem.libraryFiles.push(libraryFile) + libraryItem.media.podcastEpisodes.push(podcastEpisode) if (this.currentDownload.isAutoDownload) { // Check setting maxEpisodesToKeep and remove episode if necessary - if (libraryItem.media.maxEpisodesToKeep && libraryItem.media.episodesWithPubDate.length > libraryItem.media.maxEpisodesToKeep) { - Logger.info(`[PodcastManager] # of episodes (${libraryItem.media.episodesWithPubDate.length}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`) - await this.removeOldestEpisode(libraryItem, podcastEpisode.id) + const numEpisodesWithPubDate = libraryItem.media.podcastEpisodes.filter((ep) => !!ep.publishedAt).length + if (libraryItem.media.maxEpisodesToKeep && numEpisodesWithPubDate > libraryItem.media.maxEpisodesToKeep) { + Logger.info(`[PodcastManager] # of episodes (${numEpisodesWithPubDate}) exceeds max episodes to keep (${libraryItem.media.maxEpisodesToKeep})`) + const episodeToRemove = await this.getRemoveOldestEpisode(libraryItem, podcastEpisode.id) + if (episodeToRemove) { + // Remove episode from playlists + await Database.playlistModel.removeMediaItemsFromPlaylists([episodeToRemove.id]) + // Remove media progress for this episode + await Database.mediaProgressModel.destroy({ + where: { + mediaItemId: episodeToRemove.id + } + }) + await episodeToRemove.destroy() + libraryItem.media.podcastEpisodes = libraryItem.media.podcastEpisodes.filter((ep) => ep.id !== episodeToRemove.id) + + // Remove library file + libraryItem.libraryFiles = libraryItem.libraryFiles.filter((lf) => lf.ino !== episodeToRemove.audioFile.ino) + } } } - libraryItem.updatedAt = Date.now() - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) - const podcastEpisodeExpanded = podcastEpisode.toJSONExpanded() - podcastEpisodeExpanded.libraryItem = libraryItem.toJSONExpanded() + await libraryItem.save() + + SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) + const podcastEpisodeExpanded = podcastEpisode.toOldJSONExpanded(libraryItem.id) + podcastEpisodeExpanded.libraryItem = libraryItem.toOldJSONExpanded() SocketAuthority.emitter('episode_added', podcastEpisodeExpanded) if (this.currentDownload.isAutoDownload) { @@ -222,45 +231,53 @@ class PodcastManager { return true } - async removeOldestEpisode(libraryItem, episodeIdJustDownloaded) { - var smallestPublishedAt = 0 - var oldestEpisode = null - libraryItem.media.episodesWithPubDate - .filter((ep) => ep.id !== episodeIdJustDownloaded) - .forEach((ep) => { - if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) { - smallestPublishedAt = ep.publishedAt - oldestEpisode = ep - } - }) - // TODO: Should we check for open playback sessions for this episode? - // TODO: remove all user progress for this episode + /** + * Find oldest episode publishedAt and delete the audio file + * + * @param {import('../models/LibraryItem').LibraryItemExpanded} libraryItem + * @param {string} episodeIdJustDownloaded + * @returns {Promise} - Returns the episode to remove + */ + async getRemoveOldestEpisode(libraryItem, episodeIdJustDownloaded) { + let smallestPublishedAt = 0 + /** @type {import('../models/PodcastEpisode')} */ + let oldestEpisode = null + + /** @type {import('../models/PodcastEpisode')[]} */ + const podcastEpisodes = libraryItem.media.podcastEpisodes + + for (const ep of podcastEpisodes) { + if (ep.id === episodeIdJustDownloaded || !ep.publishedAt) continue + + if (!smallestPublishedAt || ep.publishedAt < smallestPublishedAt) { + smallestPublishedAt = ep.publishedAt + oldestEpisode = ep + } + } + if (oldestEpisode?.audioFile) { Logger.info(`[PodcastManager] Deleting oldest episode "${oldestEpisode.title}"`) const successfullyDeleted = await removeFile(oldestEpisode.audioFile.metadata.path) if (successfullyDeleted) { - libraryItem.media.removeEpisode(oldestEpisode.id) - libraryItem.removeLibraryFile(oldestEpisode.audioFile.ino) - return true + return oldestEpisode } else { Logger.warn(`[PodcastManager] Failed to remove oldest episode "${oldestEpisode.title}"`) } } - return false - } - - async getLibraryFile(path, relPath) { - var newLibFile = new LibraryFile() - await newLibFile.setDataFromPath(path, relPath) - return newLibFile + return null } + /** + * + * @param {LibraryFile} libraryFile + * @returns {Promise} + */ async probeAudioFile(libraryFile) { const path = libraryFile.metadata.path const mediaProbeData = await prober.probe(path) if (mediaProbeData.error) { Logger.error(`[PodcastManager] Podcast Episode downloaded but failed to probe "${path}"`, mediaProbeData.error) - return false + return null } const newAudioFile = new AudioFile() newAudioFile.setDataFromProbe(libraryFile, mediaProbeData) @@ -284,7 +301,7 @@ class PodcastManager { const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`) - var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload) + const newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload) Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes?.length || 'N/A'} episodes found`) if (!newEpisodes) { @@ -324,17 +341,17 @@ class PodcastManager { * @param {import('../models/LibraryItem')} podcastLibraryItem * @param {number} dateToCheckForEpisodesAfter - Unix timestamp * @param {number} maxNewEpisodes - * @returns + * @returns {Promise} */ async checkPodcastForNewEpisodes(podcastLibraryItem, dateToCheckForEpisodesAfter, maxNewEpisodes = 3) { if (!podcastLibraryItem.media.feedURL) { Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`) - return false + return null } const feed = await getPodcastFeed(podcastLibraryItem.media.feedURL) if (!feed?.episodes) { Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed) - return false + return null } // Filter new and not already has @@ -351,15 +368,15 @@ class PodcastManager { * * @param {import('../models/LibraryItem')} libraryItem * @param {*} maxEpisodesToDownload - * @returns + * @returns {Promise} */ async checkAndDownloadNewEpisodes(libraryItem, maxEpisodesToDownload) { const lastEpisodeCheck = libraryItem.media.lastEpisodeCheck?.valueOf() || 0 const lastEpisodeCheckDate = lastEpisodeCheck > 0 ? libraryItem.media.lastEpisodeCheck : 'Never' Logger.info(`[PodcastManager] checkAndDownloadNewEpisodes for "${libraryItem.media.title}" - Last episode check: ${lastEpisodeCheckDate}`) - var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, lastEpisodeCheck, maxEpisodesToDownload) - if (newEpisodes.length) { + const newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, lastEpisodeCheck, maxEpisodesToDownload) + if (newEpisodes?.length) { Logger.info(`[PodcastManager] Found ${newEpisodes.length} new episodes for podcast "${libraryItem.media.title}" - starting download`) this.downloadPodcastEpisodes(libraryItem, newEpisodes, false) } else { @@ -374,7 +391,7 @@ class PodcastManager { SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) - return newEpisodes + return newEpisodes || [] } async findEpisode(rssFeedUrl, searchTitle) { @@ -550,64 +567,123 @@ class PodcastManager { continue } - const newPodcastMetadata = { - title: feed.metadata.title, - author: feed.metadata.author, - description: feed.metadata.description, - releaseDate: '', - genres: [...feed.metadata.categories], - feedUrl: feed.metadata.feedUrl, - imageUrl: feed.metadata.image, - itunesPageUrl: '', - itunesId: '', - itunesArtistId: '', - language: '', - numEpisodes: feed.numEpisodes - } - - const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath) - const libraryItemPayload = { - path: podcastPath, - relPath: podcastFilename, - folderId: folder.id, - libraryId: folder.libraryId, - ino: libraryItemFolderStats.ino, - mtimeMs: libraryItemFolderStats.mtimeMs || 0, - ctimeMs: libraryItemFolderStats.ctimeMs || 0, - birthtimeMs: libraryItemFolderStats.birthtimeMs || 0, - media: { - metadata: newPodcastMetadata, - autoDownloadEpisodes + let newLibraryItem = null + const transaction = await Database.sequelize.transaction() + try { + const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath) + + const podcastPayload = { + autoDownloadEpisodes, + metadata: { + title: feed.metadata.title, + author: feed.metadata.author, + description: feed.metadata.description, + releaseDate: '', + genres: [...feed.metadata.categories], + feedUrl: feed.metadata.feedUrl, + imageUrl: feed.metadata.image, + itunesPageUrl: '', + itunesId: '', + itunesArtistId: '', + language: '', + numEpisodes: feed.numEpisodes + } + } + const podcast = await Database.podcastModel.createFromRequest(podcastPayload, transaction) + + newLibraryItem = await Database.libraryItemModel.create( + { + ino: libraryItemFolderStats.ino, + path: podcastPath, + relPath: podcastFilename, + mediaId: podcast.id, + mediaType: 'podcast', + isFile: false, + isMissing: false, + isInvalid: false, + mtime: libraryItemFolderStats.mtimeMs || 0, + ctime: libraryItemFolderStats.ctimeMs || 0, + birthtime: libraryItemFolderStats.birthtimeMs || 0, + size: 0, + libraryFiles: [], + extraData: {}, + libraryId: folder.libraryId, + libraryFolderId: folder.id + }, + { transaction } + ) + + await transaction.commit() + } catch (error) { + await transaction.rollback() + Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Failed to create podcast library item for "${feed.metadata.title}"`, error) + const taskTitleStringFeed = { + text: 'OPML import feed', + key: 'MessageTaskOpmlImportFeed' + } + const taskDescriptionStringPodcast = { + text: `Creating podcast "${feed.metadata.title}"`, + key: 'MessageTaskOpmlImportFeedPodcastDescription', + subs: [feed.metadata.title] } + const taskErrorString = { + text: 'Failed to create podcast library item', + key: 'MessageTaskOpmlImportFeedPodcastFailed' + } + TaskManager.createAndEmitFailedTask('opml-import-feed', taskTitleStringFeed, taskDescriptionStringPodcast, taskErrorString) + continue } - const libraryItem = new LibraryItem() - libraryItem.setData('podcast', libraryItemPayload) + newLibraryItem.media = await newLibraryItem.getMediaExpanded() // Download and save cover image - if (newPodcastMetadata.imageUrl) { - // TODO: Scan cover image to library files + if (typeof feed.metadata.image === 'string' && feed.metadata.image.startsWith('http')) { // Podcast cover will always go into library item folder - const coverResponse = await CoverManager.downloadCoverFromUrl(libraryItem, newPodcastMetadata.imageUrl, true) - if (coverResponse) { - if (coverResponse.error) { - Logger.error(`[PodcastManager] createPodcastsFromFeedUrls: Download cover error from "${newPodcastMetadata.imageUrl}": ${coverResponse.error}`) - } else if (coverResponse.cover) { - libraryItem.media.coverPath = coverResponse.cover + const coverResponse = await CoverManager.downloadCoverFromUrlNew(feed.metadata.image, newLibraryItem.id, newLibraryItem.path, true) + if (coverResponse.error) { + Logger.error(`[PodcastManager] Download cover error from "${feed.metadata.image}": ${coverResponse.error}`) + } else if (coverResponse.cover) { + const coverImageFileStats = await getFileTimestampsWithIno(coverResponse.cover) + if (!coverImageFileStats) { + Logger.error(`[PodcastManager] Failed to get cover image stats for "${coverResponse.cover}"`) + } else { + // Add libraryFile to libraryItem and coverPath to podcast + const newLibraryFile = { + ino: coverImageFileStats.ino, + fileType: 'image', + addedAt: Date.now(), + updatedAt: Date.now(), + metadata: { + filename: Path.basename(coverResponse.cover), + ext: Path.extname(coverResponse.cover).slice(1), + path: coverResponse.cover, + relPath: Path.basename(coverResponse.cover), + size: coverImageFileStats.size, + mtimeMs: coverImageFileStats.mtimeMs || 0, + ctimeMs: coverImageFileStats.ctimeMs || 0, + birthtimeMs: coverImageFileStats.birthtimeMs || 0 + } + } + newLibraryItem.libraryFiles.push(newLibraryFile) + newLibraryItem.changed('libraryFiles', true) + await newLibraryItem.save() + + newLibraryItem.media.coverPath = coverResponse.cover + await newLibraryItem.media.save() } } } - await Database.createLibraryItem(libraryItem) - SocketAuthority.emitter('item_added', libraryItem.toJSONExpanded()) + SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded()) // Turn on podcast auto download cron if not already on - if (libraryItem.media.autoDownloadEpisodes) { - cronManager.checkUpdatePodcastCron(libraryItem) + if (newLibraryItem.media.autoDownloadEpisodes) { + cronManager.checkUpdatePodcastCron(newLibraryItem) } numPodcastsAdded++ } + const taskFinishedString = { text: `Added ${numPodcastsAdded} podcasts`, key: 'MessageTaskOpmlImportFinished', diff --git a/server/models/Podcast.js b/server/models/Podcast.js index fd4713052a..aa7afbacb3 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -126,6 +126,45 @@ class Podcast extends Model { } } + /** + * Payload from the /api/podcasts POST endpoint + * + * @param {Object} payload + * @param {import('sequelize').Transaction} transaction + */ + static async createFromRequest(payload, transaction) { + const title = typeof payload.metadata.title === 'string' ? payload.metadata.title : null + const autoDownloadSchedule = typeof payload.autoDownloadSchedule === 'string' ? payload.autoDownloadSchedule : null + const genres = Array.isArray(payload.metadata.genres) && payload.metadata.genres.every((g) => typeof g === 'string' && g.length) ? payload.metadata.genres : [] + const tags = Array.isArray(payload.tags) && payload.tags.every((t) => typeof t === 'string' && t.length) ? payload.tags : [] + + return this.create( + { + title, + titleIgnorePrefix: getTitleIgnorePrefix(title), + author: typeof payload.metadata.author === 'string' ? payload.metadata.author : null, + releaseDate: typeof payload.metadata.releaseDate === 'string' ? payload.metadata.releaseDate : null, + feedURL: typeof payload.metadata.feedUrl === 'string' ? payload.metadata.feedUrl : null, + imageURL: typeof payload.metadata.imageUrl === 'string' ? payload.metadata.imageUrl : null, + description: typeof payload.metadata.description === 'string' ? payload.metadata.description : null, + itunesPageURL: typeof payload.metadata.itunesPageUrl === 'string' ? payload.metadata.itunesPageUrl : null, + itunesId: typeof payload.metadata.itunesId === 'string' ? payload.metadata.itunesId : null, + itunesArtistId: typeof payload.metadata.itunesArtistId === 'string' ? payload.metadata.itunesArtistId : null, + language: typeof payload.metadata.language === 'string' ? payload.metadata.language : null, + podcastType: typeof payload.metadata.type === 'string' ? payload.metadata.type : null, + explicit: !!payload.metadata.explicit, + autoDownloadEpisodes: !!payload.autoDownloadEpisodes, + autoDownloadSchedule: autoDownloadSchedule || global.ServerSettings.podcastEpisodeSchedule, + lastEpisodeCheck: new Date(), + maxEpisodesToKeep: 0, + maxNewEpisodesToDownload: 3, + tags, + genres + }, + { transaction } + ) + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize @@ -368,7 +407,7 @@ class Podcast extends Model { /** * Used for checking if an rss feed episode is already in the podcast * - * @param {Object} feedEpisode - object from rss feed + * @param {import('../utils/podcastUtils').RssPodcastEpisode} feedEpisode - object from rss feed * @returns {boolean} */ checkHasEpisodeByFeedEpisode(feedEpisode) { diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 4c9967f898..c1e66fdfa3 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -87,6 +87,40 @@ class PodcastEpisode extends Model { } } + /** + * + * @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode + * @param {string} podcastId + * @param {import('../objects/files/AudioFile')} audioFile + */ + static async createFromRssPodcastEpisode(rssPodcastEpisode, podcastId, audioFile) { + const podcastEpisode = { + index: null, + season: rssPodcastEpisode.season, + episode: rssPodcastEpisode.episode, + episodeType: rssPodcastEpisode.episodeType, + title: rssPodcastEpisode.title, + subtitle: rssPodcastEpisode.subtitle, + description: rssPodcastEpisode.description, + pubDate: rssPodcastEpisode.pubDate, + enclosureURL: rssPodcastEpisode.enclosure?.url || null, + enclosureSize: rssPodcastEpisode.enclosure?.length || null, + enclosureType: rssPodcastEpisode.enclosure?.type || null, + publishedAt: rssPodcastEpisode.publishedAt, + podcastId, + audioFile: audioFile.toJSON(), + chapters: [], + extraData: {} + } + if (rssPodcastEpisode.guid) { + podcastEpisode.extraData.guid = rssPodcastEpisode.guid + } + if (audioFile.chapters?.length) { + podcastEpisode.chapters = audioFile.chapters.map((ch) => ({ ...ch })) + } + return this.create(podcastEpisode) + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index b1cdf43b4c..17d7484ce6 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -1,4 +1,3 @@ -const uuidv4 = require('uuid').v4 const fs = require('../libs/fsExtra') const Path = require('path') const Logger = require('../Logger') @@ -178,45 +177,6 @@ class LibraryItem { return this.libraryFiles.some((lf) => lf.fileType === 'audio') } - // Data comes from scandir library item data - // TODO: Remove this function. Only used when creating a new podcast now - setData(libraryMediaType, payload) { - this.id = uuidv4() - this.mediaType = libraryMediaType - if (libraryMediaType === 'podcast') { - this.media = new Podcast() - } else { - Logger.error(`[LibraryItem] setData called with unsupported media type "${libraryMediaType}"`) - return - } - this.media.id = uuidv4() - this.media.libraryItemId = this.id - - for (const key in payload) { - if (key === 'libraryFiles') { - this.libraryFiles = payload.libraryFiles.map((lf) => lf.clone()) - - // Set cover image - const imageFiles = this.libraryFiles.filter((lf) => lf.fileType === 'image') - const coverMatch = imageFiles.find((iFile) => /\/cover\.[^.\/]*$/.test(iFile.metadata.path)) - if (coverMatch) { - this.media.coverPath = coverMatch.metadata.path - } else if (imageFiles.length) { - this.media.coverPath = imageFiles[0].metadata.path - } - } else if (this[key] !== undefined && key !== 'media') { - this[key] = payload[key] - } - } - - if (payload.media) { - this.media.setData(payload.media) - } - - this.addedAt = Date.now() - this.updatedAt = Date.now() - } - update(payload) { const json = this.toJSON() let hasUpdates = false diff --git a/server/objects/PodcastEpisodeDownload.js b/server/objects/PodcastEpisodeDownload.js index ecda4a472c..ffdad9f0ab 100644 --- a/server/objects/PodcastEpisodeDownload.js +++ b/server/objects/PodcastEpisodeDownload.js @@ -6,8 +6,9 @@ const globals = require('../utils/globals') class PodcastEpisodeDownload { constructor() { this.id = null - /** @type {import('../objects/entities/PodcastEpisode')} */ - this.podcastEpisode = null + /** @type {import('../utils/podcastUtils').RssPodcastEpisode} */ + this.rssPodcastEpisode = null + this.url = null /** @type {import('../models/LibraryItem')} */ this.libraryItem = null @@ -17,7 +18,7 @@ class PodcastEpisodeDownload { this.isFinished = false this.failed = false - this.appendEpisodeId = false + this.appendRandomId = false this.startedAt = null this.createdAt = null @@ -27,22 +28,22 @@ class PodcastEpisodeDownload { toJSONForClient() { return { id: this.id, - episodeDisplayTitle: this.podcastEpisode?.title ?? null, + episodeDisplayTitle: this.rssPodcastEpisode?.title ?? null, url: this.url, libraryItemId: this.libraryItemId, libraryId: this.libraryId || null, isFinished: this.isFinished, failed: this.failed, - appendEpisodeId: this.appendEpisodeId, + appendRandomId: this.appendRandomId, startedAt: this.startedAt, createdAt: this.createdAt, finishedAt: this.finishedAt, podcastTitle: this.libraryItem?.media.title ?? null, podcastExplicit: !!this.libraryItem?.media.explicit, - season: this.podcastEpisode?.season ?? null, - episode: this.podcastEpisode?.episode ?? null, - episodeType: this.podcastEpisode?.episodeType ?? 'full', - publishedAt: this.podcastEpisode?.publishedAt ?? null + season: this.rssPodcastEpisode?.season ?? null, + episode: this.rssPodcastEpisode?.episode ?? null, + episodeType: this.rssPodcastEpisode?.episodeType ?? 'full', + publishedAt: this.rssPodcastEpisode?.publishedAt ?? null } } @@ -56,7 +57,7 @@ class PodcastEpisodeDownload { return 'mp3' } get enclosureType() { - const enclosureType = this.podcastEpisode?.enclosure?.type + const enclosureType = this.rssPodcastEpisode.enclosure.type return typeof enclosureType === 'string' ? enclosureType : null } /** @@ -69,10 +70,12 @@ class PodcastEpisodeDownload { if (this.enclosureType && !this.enclosureType.includes('mpeg')) return false return this.fileExtension === 'mp3' } - + get episodeTitle() { + return this.rssPodcastEpisode.title + } get targetFilename() { - const appendage = this.appendEpisodeId ? ` (${this.podcastEpisode.id})` : '' - const filename = `${this.podcastEpisode.title}${appendage}.${this.fileExtension}` + const appendage = this.appendRandomId ? ` (${uuidv4()})` : '' + const filename = `${this.rssPodcastEpisode.title}${appendage}.${this.fileExtension}` return sanitizeFilename(filename) } get targetPath() { @@ -84,19 +87,23 @@ class PodcastEpisodeDownload { get libraryItemId() { return this.libraryItem?.id || null } + get pubYear() { + if (!this.rssPodcastEpisode.publishedAt) return null + return new Date(this.rssPodcastEpisode.publishedAt).getFullYear() + } /** * - * @param {import('../objects/entities/PodcastEpisode')} podcastEpisode - old model + * @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode - from rss feed * @param {import('../models/LibraryItem')} libraryItem * @param {*} isAutoDownload * @param {*} libraryId */ - setData(podcastEpisode, libraryItem, isAutoDownload, libraryId) { + setData(rssPodcastEpisode, libraryItem, isAutoDownload, libraryId) { this.id = uuidv4() - this.podcastEpisode = podcastEpisode + this.rssPodcastEpisode = rssPodcastEpisode - const url = podcastEpisode.enclosure.url + const url = rssPodcastEpisode.enclosure.url if (decodeURIComponent(url) !== url) { // Already encoded this.url = url diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js index e759a0ebc2..6a3f4cf6d0 100644 --- a/server/objects/entities/PodcastEpisode.js +++ b/server/objects/entities/PodcastEpisode.js @@ -1,4 +1,3 @@ -const uuidv4 = require('uuid').v4 const { areEquivalent, copyValue } = require('../../utils/index') const AudioFile = require('../files/AudioFile') const AudioTrack = require('../files/AudioTrack') @@ -127,27 +126,6 @@ class PodcastEpisode { get enclosureUrl() { return this.enclosure?.url || null } - get pubYear() { - if (!this.publishedAt) return null - return new Date(this.publishedAt).getFullYear() - } - - setData(data, index = 1) { - this.id = uuidv4() - this.index = index - this.title = data.title - this.subtitle = data.subtitle || '' - this.pubDate = data.pubDate || '' - this.description = data.description || '' - this.enclosure = data.enclosure ? { ...data.enclosure } : null - this.guid = data.guid || null - this.season = data.season || '' - this.episode = data.episode || '' - this.episodeType = data.episodeType || 'full' - this.publishedAt = data.publishedAt || 0 - this.addedAt = Date.now() - this.updatedAt = Date.now() - } update(payload) { let hasUpdates = false diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index 8d6b541d9b..5f43ebc83b 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -132,18 +132,6 @@ class Podcast { get numTracks() { return this.episodes.length } - get latestEpisodePublished() { - var largestPublishedAt = 0 - this.episodes.forEach((ep) => { - if (ep.publishedAt && ep.publishedAt > largestPublishedAt) { - largestPublishedAt = ep.publishedAt - } - }) - return largestPublishedAt - } - get episodesWithPubDate() { - return this.episodes.filter((ep) => !!ep.publishedAt) - } update(payload) { var json = this.toJSON() @@ -178,34 +166,10 @@ class Podcast { return true } - setData(mediaData) { - this.metadata = new PodcastMetadata() - if (mediaData.metadata) { - this.metadata.setData(mediaData.metadata) - } - - this.coverPath = mediaData.coverPath || null - this.autoDownloadEpisodes = !!mediaData.autoDownloadEpisodes - this.autoDownloadSchedule = mediaData.autoDownloadSchedule || global.ServerSettings.podcastEpisodeSchedule - this.lastEpisodeCheck = Date.now() // Makes sure new episodes are after this - } - checkHasEpisode(episodeId) { return this.episodes.some((ep) => ep.id === episodeId) } - addPodcastEpisode(podcastEpisode) { - this.episodes.push(podcastEpisode) - } - - removeEpisode(episodeId) { - const episode = this.episodes.find((ep) => ep.id === episodeId) - if (episode) { - this.episodes = this.episodes.filter((ep) => ep.id !== episodeId) - } - return episode - } - getEpisode(episodeId) { if (!episodeId) return null diff --git a/server/objects/metadata/PodcastMetadata.js b/server/objects/metadata/PodcastMetadata.js index 8300e93a62..0df40df09c 100644 --- a/server/objects/metadata/PodcastMetadata.js +++ b/server/objects/metadata/PodcastMetadata.js @@ -91,24 +91,6 @@ class PodcastMetadata { return getTitlePrefixAtEnd(this.title) } - setData(mediaMetadata = {}) { - this.title = mediaMetadata.title || null - this.author = mediaMetadata.author || null - this.description = mediaMetadata.description || null - this.releaseDate = mediaMetadata.releaseDate || null - this.feedUrl = mediaMetadata.feedUrl || null - this.imageUrl = mediaMetadata.imageUrl || null - this.itunesPageUrl = mediaMetadata.itunesPageUrl || null - this.itunesId = mediaMetadata.itunesId || null - this.itunesArtistId = mediaMetadata.itunesArtistId || null - this.explicit = !!mediaMetadata.explicit - this.language = mediaMetadata.language || null - this.type = mediaMetadata.type || null - if (mediaMetadata.genres && mediaMetadata.genres.length) { - this.genres = [...mediaMetadata.genres] - } - } - update(payload) { const json = this.toJSON() let hasUpdates = false diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index 06e20f1dd4..f86df9eb12 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -125,7 +125,7 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { /** @type {import('../models/Podcast')} */ const podcast = podcastEpisodeDownload.libraryItem.media - const podcastEpisode = podcastEpisodeDownload.podcastEpisode + const podcastEpisode = podcastEpisodeDownload.rssPodcastEpisode const finalSizeInBytes = Number(podcastEpisode.enclosure?.length || 0) const taggings = { @@ -144,7 +144,7 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { 'series-part': podcastEpisode.episode, title: podcastEpisode.title, 'title-sort': podcastEpisode.title, - year: podcastEpisode.pubYear, + year: podcastEpisodeDownload.pubYear, date: podcastEpisode.pubDate, releasedate: podcastEpisode.pubDate, 'itunes-id': podcast.itunesId, diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 26bd173398..d28c3b9d18 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -4,6 +4,49 @@ const Logger = require('../Logger') const { xmlToJSON, levenshteinDistance } = require('./index') const htmlSanitizer = require('../utils/htmlSanitizer') +/** + * @typedef RssPodcastEpisode + * @property {string} title + * @property {string} subtitle + * @property {string} description + * @property {string} descriptionPlain + * @property {string} pubDate + * @property {string} episodeType + * @property {string} season + * @property {string} episode + * @property {string} author + * @property {string} duration + * @property {string} explicit + * @property {number} publishedAt - Unix timestamp + * @property {{ url: string, type?: string, length?: string }} enclosure + * @property {string} guid + * @property {string} chaptersUrl + * @property {string} chaptersType + */ + +/** + * @typedef RssPodcastMetadata + * @property {string} title + * @property {string} language + * @property {string} explicit + * @property {string} author + * @property {string} pubDate + * @property {string} link + * @property {string} image + * @property {string[]} categories + * @property {string} feedUrl + * @property {string} description + * @property {string} descriptionPlain + * @property {string} type + */ + +/** + * @typedef RssPodcast + * @property {RssPodcastMetadata} metadata + * @property {RssPodcastEpisode[]} episodes + * @property {number} numEpisodes + */ + function extractFirstArrayItem(json, key) { if (!json[key]?.length) return null return json[key][0] @@ -223,7 +266,7 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal * * @param {string} feedUrl * @param {boolean} [excludeEpisodeMetadata=false] - * @returns {Promise} + * @returns {Promise} */ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`) From 6d52f88a96ddf8f158c2e999dfe51a1ef43abc9b Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 4 Jan 2025 15:20:41 -0600 Subject: [PATCH 018/118] Update controllers to use toOldJSON functions --- server/Database.js | 24 ++++- server/controllers/AuthorController.js | 21 ++-- server/controllers/CollectionController.js | 16 ++-- server/controllers/LibraryController.js | 12 +-- server/controllers/LibraryItemController.js | 41 ++++---- server/controllers/MeController.js | 14 +-- server/controllers/MiscController.js | 16 ++-- server/controllers/PlaylistController.js | 26 ++--- server/managers/PodcastManager.js | 6 +- server/models/Collection.js | 2 +- server/models/LibraryItem.js | 82 ++++++++-------- server/models/Playlist.js | 4 +- server/objects/mediaTypes/Podcast.js | 4 - server/scanner/LibraryItemScanner.js | 3 +- server/scanner/LibraryScanner.js | 47 +++++---- server/utils/migrations/dbMigration.js | 2 +- server/utils/queries/libraryFilters.js | 26 ++--- .../utils/queries/libraryItemsBookFilters.js | 96 +++++++++++++------ .../queries/libraryItemsPodcastFilters.js | 27 +++--- server/utils/queries/seriesFilters.js | 10 +- 20 files changed, 277 insertions(+), 202 deletions(-) diff --git a/server/Database.js b/server/Database.js index 2137b3c1f2..45af5248f3 100644 --- a/server/Database.js +++ b/server/Database.js @@ -665,7 +665,7 @@ class Database { /** * Clean invalid records in database * Series should have atleast one Book - * Book and Podcast must have an associated LibraryItem + * Book and Podcast must have an associated LibraryItem (and vice versa) * Remove playback sessions that are 3 seconds or less */ async cleanDatabase() { @@ -695,6 +695,28 @@ class Database { await book.destroy() } + // Remove invalid LibraryItem records + const libraryItemsWithNoMedia = await this.libraryItemModel.findAll({ + include: [ + { + model: this.bookModel, + attributes: ['id'] + }, + { + model: this.podcastModel, + attributes: ['id'] + } + ], + where: { + '$book.id$': null, + '$podcast.id$': null + } + }) + for (const libraryItem of libraryItemsWithNoMedia) { + Logger.warn(`Found libraryItem "${libraryItem.id}" with no media - removing it`) + await libraryItem.destroy() + } + const playlistMediaItemsWithNoMediaItem = await this.playlistMediaItemModel.findAll({ include: [ { diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js index 45bbdf84bd..a6b5a2f419 100644 --- a/server/controllers/AuthorController.js +++ b/server/controllers/AuthorController.js @@ -125,7 +125,7 @@ class AuthorController { const bookAuthorsToCreate = [] const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id) - const oldLibraryItems = [] + const libraryItems = [] allItemsWithAuthor.forEach((libraryItem) => { // Replace old author with merging author for each book libraryItem.media.authors = libraryItem.media.authors.filter((au) => au.id !== req.author.id) @@ -134,23 +134,22 @@ class AuthorController { name: existingAuthor.name }) - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) - oldLibraryItems.push(oldLibraryItem) + libraryItems.push(libraryItem) bookAuthorsToCreate.push({ bookId: libraryItem.media.id, authorId: existingAuthor.id }) }) - if (oldLibraryItems.length) { + if (libraryItems.length) { await Database.removeBulkBookAuthors(req.author.id) // Remove all old BookAuthor await Database.createBulkBookAuthors(bookAuthorsToCreate) // Create all new BookAuthor - for (const libraryItem of allItemsWithAuthor) { + for (const libraryItem of libraryItems) { await libraryItem.saveMetadataFile() } SocketAuthority.emitter( 'items_updated', - oldLibraryItems.map((li) => li.toJSONExpanded()) + libraryItems.map((li) => li.toOldJSONExpanded()) ) } @@ -190,7 +189,7 @@ class AuthorController { const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id) numBooksForAuthor = allItemsWithAuthor.length - const oldLibraryItems = [] + const libraryItems = [] // Update author name on all books for (const libraryItem of allItemsWithAuthor) { libraryItem.media.authors = libraryItem.media.authors.map((au) => { @@ -199,16 +198,16 @@ class AuthorController { } return au }) - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) - oldLibraryItems.push(oldLibraryItem) + + libraryItems.push(libraryItem) await libraryItem.saveMetadataFile() } - if (oldLibraryItems.length) { + if (libraryItems.length) { SocketAuthority.emitter( 'items_updated', - oldLibraryItems.map((li) => li.toJSONExpanded()) + libraryItems.map((li) => li.toOldJSONExpanded()) ) } } else { diff --git a/server/controllers/CollectionController.js b/server/controllers/CollectionController.js index 6986f2b790..00b82ce9c6 100644 --- a/server/controllers/CollectionController.js +++ b/server/controllers/CollectionController.js @@ -221,7 +221,9 @@ class CollectionController { * @param {Response} res */ async addBook(req, res) { - const libraryItem = await Database.libraryItemModel.getOldById(req.body.id) + const libraryItem = await Database.libraryItemModel.findByPk(req.body.id, { + attributes: ['libraryId', 'mediaId'] + }) if (!libraryItem) { return res.status(404).send('Book not found') } @@ -231,14 +233,14 @@ class CollectionController { // Check if book is already in collection const collectionBooks = await req.collection.getCollectionBooks() - if (collectionBooks.some((cb) => cb.bookId === libraryItem.media.id)) { + if (collectionBooks.some((cb) => cb.bookId === libraryItem.mediaId)) { return res.status(400).send('Book already in collection') } // Create collectionBook record await Database.collectionBookModel.create({ collectionId: req.collection.id, - bookId: libraryItem.media.id, + bookId: libraryItem.mediaId, order: collectionBooks.length + 1 }) const jsonExpanded = await req.collection.getOldJsonExpanded() @@ -255,7 +257,9 @@ class CollectionController { * @param {Response} res */ async removeBook(req, res) { - const libraryItem = await Database.libraryItemModel.getOldById(req.params.bookId) + const libraryItem = await Database.libraryItemModel.findByPk(req.params.bookId, { + attributes: ['mediaId'] + }) if (!libraryItem) { return res.sendStatus(404) } @@ -266,7 +270,7 @@ class CollectionController { }) let jsonExpanded = null - const collectionBookToRemove = collectionBooks.find((cb) => cb.bookId === libraryItem.media.id) + const collectionBookToRemove = collectionBooks.find((cb) => cb.bookId === libraryItem.mediaId) if (collectionBookToRemove) { // Remove collection book record await collectionBookToRemove.destroy() @@ -274,7 +278,7 @@ class CollectionController { // Update order on collection books let order = 1 for (const collectionBook of collectionBooks) { - if (collectionBook.bookId === libraryItem.media.id) continue + if (collectionBook.bookId === libraryItem.mediaId) continue if (collectionBook.order !== order) { await collectionBook.update({ order diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index f42a023d4c..216f7595da 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -1145,14 +1145,14 @@ class LibraryController { await libraryItem.media.update({ narrators: libraryItem.media.narrators }) - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) - itemsUpdated.push(oldLibraryItem) + + itemsUpdated.push(libraryItem) } if (itemsUpdated.length) { SocketAuthority.emitter( 'items_updated', - itemsUpdated.map((li) => li.toJSONExpanded()) + itemsUpdated.map((li) => li.toOldJSONExpanded()) ) } @@ -1189,14 +1189,14 @@ class LibraryController { await libraryItem.media.update({ narrators: libraryItem.media.narrators }) - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) - itemsUpdated.push(oldLibraryItem) + + itemsUpdated.push(libraryItem) } if (itemsUpdated.length) { SocketAuthority.emitter( 'items_updated', - itemsUpdated.map((li) => li.toJSONExpanded()) + itemsUpdated.map((li) => li.toOldJSONExpanded()) ) } diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 74b8bdfc4a..14a85f6e3a 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -551,11 +551,11 @@ class LibraryItemController { const hardDelete = req.query.hard == 1 // Delete files from filesystem const { libraryItemIds } = req.body - if (!libraryItemIds?.length) { + if (!libraryItemIds?.length || !Array.isArray(libraryItemIds)) { return res.status(400).send('Invalid request body') } - const itemsToDelete = await Database.libraryItemModel.getAllOldLibraryItems({ + const itemsToDelete = await Database.libraryItemModel.findAllExpandedWhere({ id: libraryItemIds }) @@ -566,19 +566,19 @@ class LibraryItemController { const libraryId = itemsToDelete[0].libraryId for (const libraryItem of itemsToDelete) { const libraryItemPath = libraryItem.path - Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`) + Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.title}" with id "${libraryItem.id}"`) const mediaItemIds = [] const seriesIds = [] const authorIds = [] if (libraryItem.isPodcast) { - mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id)) + mediaItemIds.push(...libraryItem.media.podcastEpisodes.map((ep) => ep.id)) } else { mediaItemIds.push(libraryItem.media.id) - if (libraryItem.media.metadata.series?.length) { - seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id)) + if (libraryItem.media.series?.length) { + seriesIds.push(...libraryItem.media.series.map((se) => se.id)) } - if (libraryItem.media.metadata.authors?.length) { - authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id)) + if (libraryItem.media.authors?.length) { + authorIds.push(...libraryItem.media.authors.map((au) => au.id)) } } await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) @@ -623,7 +623,7 @@ class LibraryItemController { } // Get all library items to update - const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ + const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: libraryItemIds }) if (updatePayloads.length !== libraryItems.length) { @@ -645,21 +645,23 @@ class LibraryItemController { if (libraryItem.isBook) { if (Array.isArray(mediaPayload.metadata?.series)) { const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id) - const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) + const seriesRemoved = libraryItem.media.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id)) } if (Array.isArray(mediaPayload.metadata?.authors)) { const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id) - const authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) + const authorsRemoved = libraryItem.media.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) authorIdsRemoved.push(...authorsRemoved.map((au) => au.id)) } } - if (libraryItem.media.update(mediaPayload)) { - Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`) + const hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload) + if (hasUpdates) { + libraryItem.changed('updatedAt', true) + await libraryItem.save() - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) + Logger.debug(`[LibraryItemController] Updated library item media "${libraryItem.media.title}"`) + SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) itemsUpdated++ } } @@ -688,11 +690,11 @@ class LibraryItemController { if (!libraryItemIds.length) { return res.status(403).send('Invalid payload') } - const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ + const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: libraryItemIds }) res.json({ - libraryItems: libraryItems.map((li) => li.toJSONExpanded()) + libraryItems: libraryItems.map((li) => li.toOldJSONExpanded()) }) } @@ -715,7 +717,7 @@ class LibraryItemController { return res.sendStatus(400) } - const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ + const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: req.body.libraryItemIds }) if (!libraryItems?.length) { @@ -737,7 +739,8 @@ class LibraryItemController { } for (const libraryItem of libraryItems) { - const matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options) + const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) + const matchResult = await Scanner.quickMatchLibraryItem(this, oldLibraryItem, options) if (matchResult.updated) { itemsUpdated++ } else if (matchResult.warning) { diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index cc67b320d7..87acd22118 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -66,7 +66,7 @@ class MeController { const libraryItem = await Database.libraryItemModel.findByPk(req.params.libraryItemId) const episode = await Database.podcastEpisodeModel.findByPk(req.params.episodeId) - if (!libraryItem || (libraryItem.mediaType === 'podcast' && !episode)) { + if (!libraryItem || (libraryItem.isPodcast && !episode)) { Logger.error(`[MeController] Media item not found for library item id "${req.params.libraryItemId}"`) return res.sendStatus(404) } @@ -296,7 +296,7 @@ class MeController { const mediaProgressesInProgress = req.user.mediaProgresses.filter((mp) => !mp.isFinished && (mp.currentTime > 0 || mp.ebookProgress > 0)) const libraryItemsIds = [...new Set(mediaProgressesInProgress.map((mp) => mp.extraData?.libraryItemId).filter((id) => id))] - const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ id: libraryItemsIds }) + const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: libraryItemsIds }) let itemsInProgress = [] @@ -304,19 +304,19 @@ class MeController { const oldMediaProgress = mediaProgress.getOldMediaProgress() const libraryItem = libraryItems.find((li) => li.id === oldMediaProgress.libraryItemId) if (libraryItem) { - if (oldMediaProgress.episodeId && libraryItem.mediaType === 'podcast') { - const episode = libraryItem.media.episodes.find((ep) => ep.id === oldMediaProgress.episodeId) + if (oldMediaProgress.episodeId && libraryItem.isPodcast) { + const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === oldMediaProgress.episodeId) if (episode) { const libraryItemWithEpisode = { - ...libraryItem.toJSONMinified(), - recentEpisode: episode.toJSON(), + ...libraryItem.toOldJSONMinified(), + recentEpisode: episode.toOldJSON(libraryItem.id), progressLastUpdate: oldMediaProgress.lastUpdate } itemsInProgress.push(libraryItemWithEpisode) } } else if (!oldMediaProgress.episodeId) { itemsInProgress.push({ - ...libraryItem.toJSONMinified(), + ...libraryItem.toOldJSONMinified(), progressLastUpdate: oldMediaProgress.lastUpdate }) } diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index b35619b70b..48eca3f872 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -342,8 +342,8 @@ class MiscController { tags: libraryItem.media.tags }) await libraryItem.saveMetadataFile() - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) + + SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) numItemsUpdated++ } } @@ -385,8 +385,8 @@ class MiscController { tags: libraryItem.media.tags }) await libraryItem.saveMetadataFile() - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) + + SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) numItemsUpdated++ } @@ -480,8 +480,8 @@ class MiscController { genres: libraryItem.media.genres }) await libraryItem.saveMetadataFile() - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) + + SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) numItemsUpdated++ } } @@ -523,8 +523,8 @@ class MiscController { genres: libraryItem.media.genres }) await libraryItem.saveMetadataFile() - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) + + SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) numItemsUpdated++ } diff --git a/server/controllers/PlaylistController.js b/server/controllers/PlaylistController.js index 8c13ecb2f9..972c352a4b 100644 --- a/server/controllers/PlaylistController.js +++ b/server/controllers/PlaylistController.js @@ -276,7 +276,7 @@ class PlaylistController { return res.status(400).send('Request body has no libraryItemId') } - const libraryItem = await Database.libraryItemModel.getOldById(itemToAdd.libraryItemId) + const libraryItem = await Database.libraryItemModel.getExpandedById(itemToAdd.libraryItemId) if (!libraryItem) { return res.status(400).send('Library item not found') } @@ -286,7 +286,7 @@ class PlaylistController { if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) { return res.status(400).send('Invalid item to add for this library type') } - if (itemToAdd.episodeId && !libraryItem.media.checkHasEpisode(itemToAdd.episodeId)) { + if (itemToAdd.episodeId && !libraryItem.media.podcastEpisodes.some((pe) => pe.id === itemToAdd.episodeId)) { return res.status(400).send('Episode not found in library item') } @@ -308,17 +308,17 @@ class PlaylistController { // Add the new item to to the old json expanded to prevent having to fully reload the playlist media items if (itemToAdd.episodeId) { - const episode = libraryItem.media.episodes.find((ep) => ep.id === itemToAdd.episodeId) + const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === itemToAdd.episodeId) jsonExpanded.items.push({ episodeId: itemToAdd.episodeId, - episode: episode.toJSONExpanded(), + episode: episode.toOldJSONExpanded(libraryItem.id), libraryItemId: libraryItem.id, - libraryItem: libraryItem.toJSONMinified() + libraryItem: libraryItem.toOldJSONMinified() }) } else { jsonExpanded.items.push({ libraryItemId: libraryItem.id, - libraryItem: libraryItem.toJSONExpanded() + libraryItem: libraryItem.toOldJSONExpanded() }) } @@ -388,8 +388,8 @@ class PlaylistController { // Find all library items const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i)) - const oldLibraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ id: Array.from(libraryItemIds) }) - if (oldLibraryItems.length !== libraryItemIds.size) { + const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: Array.from(libraryItemIds) }) + if (libraryItems.length !== libraryItemIds.size) { return res.status(400).send('Invalid request body items') } @@ -401,7 +401,7 @@ class PlaylistController { // Setup array of playlistMediaItem records to add let order = req.playlist.playlistMediaItems.length + 1 for (const item of req.body.items) { - const libraryItem = oldLibraryItems.find((li) => li.id === item.libraryItemId) + const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId) const mediaItemId = item.episodeId || libraryItem.media.id if (req.playlist.playlistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) { @@ -417,17 +417,17 @@ class PlaylistController { // Add the new item to to the old json expanded to prevent having to fully reload the playlist media items if (item.episodeId) { - const episode = libraryItem.media.episodes.find((ep) => ep.id === item.episodeId) + const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === item.episodeId) jsonExpanded.items.push({ episodeId: item.episodeId, - episode: episode.toJSONExpanded(), + episode: episode.toOldJSONExpanded(libraryItem.id), libraryItemId: libraryItem.id, - libraryItem: libraryItem.toJSONMinified() + libraryItem: libraryItem.toOldJSONMinified() }) } else { jsonExpanded.items.push({ libraryItemId: libraryItem.id, - libraryItem: libraryItem.toJSONExpanded() + libraryItem: libraryItem.toOldJSONExpanded() }) } } diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index bd42e74bb5..0b45dfc4c6 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -296,9 +296,9 @@ class PodcastManager { Logger.info(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" | Last check: ${new Date(lastEpisodeCheck)} | ${latestEpisodePublishedAt ? `Latest episode pubDate: ${new Date(latestEpisodePublishedAt)}` : 'No latest episode'}`) - // Use latest episode pubDate if exists OR fallback to using lastEpisodeCheckDate - // lastEpisodeCheckDate will be the current time when adding a new podcast - const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheckDate + // Use latest episode pubDate if exists OR fallback to using lastEpisodeCheck + // lastEpisodeCheck will be the current time when adding a new podcast + const dateToCheckForEpisodesAfter = latestEpisodePublishedAt || lastEpisodeCheck Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`) const newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload) diff --git a/server/models/Collection.js b/server/models/Collection.js index c8f62e699d..d5f36ba9ce 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -282,7 +282,7 @@ class Collection extends Model { const libraryItem = book.libraryItem delete book.libraryItem libraryItem.media = book - return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONExpanded() + return libraryItem.toOldJSONExpanded() }) return json diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 3381b94a3a..0d87a328b3 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -122,45 +122,6 @@ class LibraryItem extends Model { }) } - /** - * - * @param {import('sequelize').WhereOptions} [where] - * @returns {Array} old library items - */ - static async getAllOldLibraryItems(where = null) { - let libraryItems = await this.findAll({ - where, - include: [ - { - model: this.sequelize.models.book, - include: [ - { - model: this.sequelize.models.author, - through: { - attributes: [] - } - }, - { - model: this.sequelize.models.series, - through: { - attributes: ['sequence'] - } - } - ] - }, - { - model: this.sequelize.models.podcast, - include: [ - { - model: this.sequelize.models.podcastEpisode - } - ] - } - ] - }) - return libraryItems.map((ti) => this.getOldLibraryItem(ti)) - } - /** * Convert an expanded LibraryItem into an old library item * @@ -448,6 +409,47 @@ class LibraryItem extends Model { }) } + /** + * + * @param {import('sequelize').WhereOptions} where + * @returns {Promise} + */ + static async findAllExpandedWhere(where = null) { + return this.findAll({ + where, + include: [ + { + model: this.sequelize.models.book, + include: [ + { + model: this.sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: this.sequelize.models.series, + through: { + attributes: ['sequence'] + } + } + ] + }, + { + model: this.sequelize.models.podcast, + include: { + model: this.sequelize.models.podcastEpisode + } + } + ], + order: [ + // Ensure author & series stay in the same order + [this.sequelize.models.book, this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'], + [this.sequelize.models.book, this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC'] + ] + }) + } + /** * * @param {string} libraryItemId @@ -611,7 +613,7 @@ class LibraryItem extends Model { return { libraryItems: libraryItems.map((li) => { - const oldLibraryItem = this.getOldLibraryItem(li).toJSONMinified() + const oldLibraryItem = li.toOldJSONMinified() if (li.collapsedSeries) { oldLibraryItem.collapsedSeries = li.collapsedSeries } diff --git a/server/models/Playlist.js b/server/models/Playlist.js index ec56248db8..35bd6c99e4 100644 --- a/server/models/Playlist.js +++ b/server/models/Playlist.js @@ -357,7 +357,7 @@ class Playlist extends Model { libraryItem.media = pmi.mediaItem return { libraryItemId: libraryItem.id, - libraryItem: this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONExpanded() + libraryItem: libraryItem.toOldJSONExpanded() } } @@ -368,7 +368,7 @@ class Playlist extends Model { episodeId: pmi.mediaItemId, episode: pmi.mediaItem.toOldJSONExpanded(libraryItem.id), libraryItemId: libraryItem.id, - libraryItem: this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONMinified() + libraryItem: libraryItem.toOldJSONMinified() } }) diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index 5f43ebc83b..f27f3fa2a1 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -166,10 +166,6 @@ class Podcast { return true } - checkHasEpisode(episodeId) { - return this.episodes.some((ep) => ep.id === episodeId) - } - getEpisode(episodeId) { if (!episodeId) return null diff --git a/server/scanner/LibraryItemScanner.js b/server/scanner/LibraryItemScanner.js index 5edfc2e2b6..bd99060c00 100644 --- a/server/scanner/LibraryItemScanner.js +++ b/server/scanner/LibraryItemScanner.js @@ -64,8 +64,7 @@ class LibraryItemScanner { const { libraryItem: expandedLibraryItem, wasUpdated } = await this.rescanLibraryItemMedia(libraryItem, libraryItemScanData, library.settings, scanLogger) if (libraryItemDataUpdated || wasUpdated) { - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(expandedLibraryItem) - SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) + SocketAuthority.emitter('item_updated', expandedLibraryItem.toOldJSONExpanded()) await this.checkAuthorsAndSeriesRemovedFromBooks(library.id, scanLogger) diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index a52350f654..c4f6410dea 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -167,7 +167,7 @@ class LibraryScanner { if (this.shouldCancelScan(libraryScan)) return true const libraryItemIdsMissing = [] - let oldLibraryItemsUpdated = [] + let libraryItemsUpdated = [] for (const existingLibraryItem of existingLibraryItems) { // First try to find matching library item with exact file path let libraryItemData = libraryItemDataFound.find((lid) => lid.path === existingLibraryItem.path) @@ -190,11 +190,11 @@ class LibraryScanner { libraryItemIdsMissing.push(existingLibraryItem.id) // TODO: Temporary while using old model to socket emit - const oldLibraryItem = await Database.libraryItemModel.getOldById(existingLibraryItem.id) - if (oldLibraryItem) { - oldLibraryItem.isMissing = true - oldLibraryItem.updatedAt = Date.now() - oldLibraryItemsUpdated.push(oldLibraryItem) + const libraryItem = await Database.libraryItemModel.getExpandedById(existingLibraryItem.id) + if (libraryItem) { + libraryItem.isMissing = true + await libraryItem.save() + libraryItemsUpdated.push(libraryItem) } } } @@ -206,16 +206,15 @@ class LibraryScanner { const { libraryItem, wasUpdated } = await LibraryItemScanner.rescanLibraryItemMedia(existingLibraryItem, libraryItemData, libraryScan.library.settings, libraryScan) if (!forceRescan || wasUpdated) { libraryScan.resultsUpdated++ - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) - oldLibraryItemsUpdated.push(oldLibraryItem) + libraryItemsUpdated.push(libraryItem) } else { libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" is up-to-date`) } } else { libraryScan.resultsUpdated++ // TODO: Temporary while using old model to socket emit - const oldLibraryItem = await Database.libraryItemModel.getOldById(existingLibraryItem.id) - oldLibraryItemsUpdated.push(oldLibraryItem) + const libraryItem = await Database.libraryItemModel.getExpandedById(existingLibraryItem.id) + libraryItemsUpdated.push(libraryItem) } } else { libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" is up-to-date`) @@ -223,23 +222,23 @@ class LibraryScanner { } // Emit item updates in chunks of 10 to client - if (oldLibraryItemsUpdated.length === 10) { + if (libraryItemsUpdated.length === 10) { // TODO: Should only emit to clients where library item is accessible SocketAuthority.emitter( 'items_updated', - oldLibraryItemsUpdated.map((li) => li.toJSONExpanded()) + libraryItemsUpdated.map((li) => li.toOldJSONExpanded()) ) - oldLibraryItemsUpdated = [] + libraryItemsUpdated = [] } if (this.shouldCancelScan(libraryScan)) return true } // Emit item updates to client - if (oldLibraryItemsUpdated.length) { + if (libraryItemsUpdated.length) { // TODO: Should only emit to clients where library item is accessible SocketAuthority.emitter( 'items_updated', - oldLibraryItemsUpdated.map((li) => li.toJSONExpanded()) + libraryItemsUpdated.map((li) => li.toOldJSONExpanded()) ) } @@ -267,34 +266,33 @@ class LibraryScanner { // Add new library items if (libraryItemDataFound.length) { - let newOldLibraryItems = [] + let newLibraryItems = [] for (const libraryItemData of libraryItemDataFound) { const newLibraryItem = await LibraryItemScanner.scanNewLibraryItem(libraryItemData, libraryScan.library.settings, libraryScan) if (newLibraryItem) { - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(newLibraryItem) - newOldLibraryItems.push(oldLibraryItem) + newLibraryItems.push(newLibraryItem) libraryScan.resultsAdded++ } // Emit new items in chunks of 10 to client - if (newOldLibraryItems.length === 10) { + if (newLibraryItems.length === 10) { // TODO: Should only emit to clients where library item is accessible SocketAuthority.emitter( 'items_added', - newOldLibraryItems.map((li) => li.toJSONExpanded()) + newLibraryItems.map((li) => li.toOldJSONExpanded()) ) - newOldLibraryItems = [] + newLibraryItems = [] } if (this.shouldCancelScan(libraryScan)) return true } // Emit new items to client - if (newOldLibraryItems.length) { + if (newLibraryItems.length) { // TODO: Should only emit to clients where library item is accessible SocketAuthority.emitter( 'items_added', - newOldLibraryItems.map((li) => li.toJSONExpanded()) + newLibraryItems.map((li) => li.toOldJSONExpanded()) ) } } @@ -645,8 +643,7 @@ class LibraryScanner { const isSingleMediaItem = isSingleMediaFile(fileUpdateGroup, itemDir) const newLibraryItem = await LibraryItemScanner.scanPotentialNewLibraryItem(fullPath, library, folder, isSingleMediaItem) if (newLibraryItem) { - const oldNewLibraryItem = Database.libraryItemModel.getOldLibraryItem(newLibraryItem) - SocketAuthority.emitter('item_added', oldNewLibraryItem.toJSONExpanded()) + SocketAuthority.emitter('item_added', newLibraryItem.toOldJSONExpanded()) } itemGroupingResults[itemDir] = newLibraryItem ? ScanResult.ADDED : ScanResult.NOTHING } diff --git a/server/utils/migrations/dbMigration.js b/server/utils/migrations/dbMigration.js index 8337f5aab1..eb42c81cef 100644 --- a/server/utils/migrations/dbMigration.js +++ b/server/utils/migrations/dbMigration.js @@ -1200,7 +1200,7 @@ async function migrationPatchNewColumns(queryInterface) { */ async function handleOldLibraryItems(ctx) { const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems') - const libraryItems = await ctx.models.libraryItem.getAllOldLibraryItems() + const libraryItems = (await ctx.models.libraryItem.findAllExpandedWhere()).map((li) => ctx.models.libraryItem.getOldLibraryItem(li)) const bulkUpdateItems = [] const bulkUpdateEpisodes = [] diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 57ca48ba8e..60c0780586 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -18,7 +18,7 @@ module.exports = { * @param {string} libraryId * @param {import('../../models/User')} user * @param {object} options - * @returns {object} { libraryItems:LibraryItem[], count:number } + * @returns {Promise<{ libraryItems:import('../../models/LibraryItem')[], count:number }>} */ async getFilteredLibraryItems(libraryId, user, options) { const { filterBy, sortBy, sortDesc, limit, offset, collapseseries, include, mediaType } = options @@ -52,7 +52,7 @@ module.exports = { const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'in-progress', 'progress', true, false, include, limit, 0, true) return { items: libraryItems.map((li) => { - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() + const oldLibraryItem = li.toOldJSONMinified() if (li.rssFeed) { oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified() } @@ -68,7 +68,7 @@ module.exports = { return { count, items: libraryItems.map((li) => { - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() + const oldLibraryItem = li.toOldJSONMinified() oldLibraryItem.recentEpisode = li.recentEpisode return oldLibraryItem }) @@ -89,7 +89,7 @@ module.exports = { const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'recent', null, 'addedAt', true, false, include, limit, 0) return { libraryItems: libraryItems.map((li) => { - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() + const oldLibraryItem = li.toOldJSONMinified() if (li.rssFeed) { oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified() } @@ -107,7 +107,7 @@ module.exports = { const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredLibraryItems(library.id, user, 'recent', null, 'addedAt', true, include, limit, 0) return { libraryItems: libraryItems.map((li) => { - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() + const oldLibraryItem = li.toOldJSONMinified() if (li.rssFeed) { oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified() } @@ -136,7 +136,7 @@ module.exports = { const { libraryItems, count } = await libraryItemsBookFilters.getContinueSeriesLibraryItems(library, user, include, limit, 0) return { libraryItems: libraryItems.map((li) => { - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() + const oldLibraryItem = li.toOldJSONMinified() if (li.rssFeed) { oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified() } @@ -166,7 +166,7 @@ module.exports = { const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, user, 'progress', 'finished', 'progress', true, false, include, limit, 0) return { items: libraryItems.map((li) => { - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() + const oldLibraryItem = li.toOldJSONMinified() if (li.rssFeed) { oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified() } @@ -182,7 +182,7 @@ module.exports = { return { count, items: libraryItems.map((li) => { - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() + const oldLibraryItem = li.toOldJSONMinified() oldLibraryItem.recentEpisode = li.recentEpisode return oldLibraryItem }) @@ -293,15 +293,17 @@ module.exports = { }) oldSeries.books = s.bookSeries .map((bs) => { - const libraryItem = bs.book.libraryItem?.toJSON() + const libraryItem = bs.book.libraryItem if (!libraryItem) { Logger.warn(`Book series book has no libraryItem`, bs, bs.book, 'series=', series) return null } delete bs.book.libraryItem + bs.book.authors = [] // Not needed + bs.book.series = [] // Not needed libraryItem.media = bs.book - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONMinified() + const oldLibraryItem = libraryItem.toOldJSONMinified() return oldLibraryItem }) .filter((b) => b) @@ -373,7 +375,7 @@ module.exports = { const { libraryItems, count } = await libraryItemsBookFilters.getDiscoverLibraryItems(library.id, user, include, limit) return { libraryItems: libraryItems.map((li) => { - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() + const oldLibraryItem = li.toOldJSONMinified() if (li.rssFeed) { oldLibraryItem.rssFeed = li.rssFeed.toOldJSONMinified() } @@ -400,7 +402,7 @@ module.exports = { return { count, libraryItems: libraryItems.map((li) => { - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(li).toJSONMinified() + const oldLibraryItem = li.toOldJSONMinified() oldLibraryItem.recentEpisode = li.recentEpisode return oldLibraryItem }) diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index ccce530452..9e74276ad1 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -349,7 +349,7 @@ module.exports = { * @param {number} limit * @param {number} offset * @param {boolean} isHomePage for home page shelves - * @returns {object} { libraryItems:LibraryItem[], count:number } + * @returns {{ libraryItems: import('../../models/LibraryItem')[], count: number }} */ async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset, isHomePage = false) { // TODO: Handle collapse sub-series @@ -583,8 +583,8 @@ module.exports = { }) const libraryItems = books.map((bookExpanded) => { - const libraryItem = bookExpanded.libraryItem.toJSON() - const book = bookExpanded.toJSON() + const libraryItem = bookExpanded.libraryItem + const book = bookExpanded if (filterGroup === 'series' && book.series?.length) { // For showing sequence on book cover when filtering for series @@ -596,27 +596,37 @@ module.exports = { } delete book.libraryItem - delete book.authors - delete book.series + + book.series = + book.bookSeries?.map((bs) => { + const series = bs.series + delete bs.series + series.bookSeries = bs + return series + }) || [] + delete book.bookSeries + + book.authors = book.bookAuthors?.map((ba) => ba.author) || [] + delete book.bookAuthors // For showing details of collapsed series - if (collapseseries && book.bookSeries?.length) { - const collapsedSeries = book.bookSeries.find((bs) => collapseSeriesBookSeries.some((cbs) => cbs.id === bs.id)) + if (collapseseries && book.series?.length) { + const collapsedSeries = book.series.find((bs) => collapseSeriesBookSeries.some((cbs) => cbs.id === bs.bookSeries.id)) if (collapsedSeries) { - const collapseSeriesObj = collapseSeriesBookSeries.find((csbs) => csbs.id === collapsedSeries.id) + const collapseSeriesObj = collapseSeriesBookSeries.find((csbs) => csbs.id === collapsedSeries.bookSeries.id) libraryItem.collapsedSeries = { - id: collapsedSeries.series.id, - name: collapsedSeries.series.name, - nameIgnorePrefix: collapsedSeries.series.nameIgnorePrefix, - sequence: collapsedSeries.sequence, + id: collapsedSeries.id, + name: collapsedSeries.name, + nameIgnorePrefix: collapsedSeries.nameIgnorePrefix, + sequence: collapsedSeries.bookSeries.sequence, numBooks: collapseSeriesObj?.numBooks || 0, libraryItemIds: collapseSeriesObj?.libraryItemIds || [] } } } - if (bookExpanded.libraryItem.feeds?.length) { - libraryItem.rssFeed = bookExpanded.libraryItem.feeds[0] + if (libraryItem.feeds?.length) { + libraryItem.rssFeed = libraryItem.feeds[0] } if (includeMediaItemShare) { @@ -646,7 +656,7 @@ module.exports = { * @param {string[]} include * @param {number} limit * @param {number} offset - * @returns {{ libraryItems:import('../../models/LibraryItem')[], count:number }} + * @returns {Promise<{ libraryItems:import('../../models/LibraryItem')[], count:number }>} */ async getContinueSeriesLibraryItems(library, user, include, limit, offset) { const libraryId = library.id @@ -758,16 +768,19 @@ module.exports = { } } - const libraryItem = s.bookSeries[bookIndex].book.libraryItem.toJSON() - const book = s.bookSeries[bookIndex].book.toJSON() + const libraryItem = s.bookSeries[bookIndex].book.libraryItem + const book = s.bookSeries[bookIndex].book delete book.libraryItem + + book.series = [] + libraryItem.series = { id: s.id, name: s.name, sequence: s.bookSeries[bookIndex].sequence } - if (s.bookSeries[bookIndex].book.libraryItem.feeds?.length) { - libraryItem.rssFeed = s.bookSeries[bookIndex].book.libraryItem.feeds[0] + if (libraryItem.feeds?.length) { + libraryItem.rssFeed = libraryItem.feeds[0] } libraryItem.media = book return libraryItem @@ -788,7 +801,7 @@ module.exports = { * @param {import('../../models/User')} user * @param {string[]} include * @param {number} limit - * @returns {object} {libraryItems:LibraryItem, count:number} + * @returns {Promise<{ libraryItems: import('../../models/LibraryItem')[], count: number }>} */ async getDiscoverLibraryItems(libraryId, user, include, limit) { const userPermissionBookWhere = this.getUserPermissionBookWhereQuery(user) @@ -895,13 +908,26 @@ module.exports = { // Step 3: Map books to library items const libraryItems = books.map((bookExpanded) => { - const libraryItem = bookExpanded.libraryItem.toJSON() - const book = bookExpanded.toJSON() + const libraryItem = bookExpanded.libraryItem + const book = bookExpanded delete book.libraryItem + + book.series = + book.bookSeries?.map((bs) => { + const series = bs.series + delete bs.series + series.bookSeries = bs + return series + }) || [] + delete book.bookSeries + + book.authors = book.bookAuthors?.map((ba) => ba.author) || [] + delete book.bookAuthors + libraryItem.media = book - if (bookExpanded.libraryItem.feeds?.length) { - libraryItem.rssFeed = bookExpanded.libraryItem.feeds[0] + if (libraryItem.feeds?.length) { + libraryItem.rssFeed = libraryItem.feeds[0] } return libraryItem @@ -961,11 +987,11 @@ module.exports = { * Get library items for series * @param {import('../../models/Series')} series * @param {import('../../models/User')} [user] - * @returns {Promise} + * @returns {Promise} */ async getLibraryItemsForSeries(series, user) { const { libraryItems } = await this.getFilteredLibraryItems(series.libraryId, user, 'series', series.id, null, null, false, [], null, null) - return libraryItems.map((li) => Database.libraryItemModel.getOldLibraryItem(li)) + return libraryItems }, /** @@ -1040,9 +1066,21 @@ module.exports = { for (const book of books) { const libraryItem = book.libraryItem delete book.libraryItem + + book.series = book.bookSeries.map((bs) => { + const series = bs.series + delete bs.series + series.bookSeries = bs + return series + }) + delete book.bookSeries + + book.authors = book.bookAuthors.map((ba) => ba.author) + delete book.bookAuthors + libraryItem.media = book itemMatches.push({ - libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded() + libraryItem: libraryItem.toOldJSONExpanded() }) } @@ -1132,7 +1170,9 @@ module.exports = { const books = series.bookSeries.map((bs) => { const libraryItem = bs.book.libraryItem libraryItem.media = bs.book - return Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSON() + libraryItem.media.authors = [] + libraryItem.media.series = [] + return libraryItem.toOldJSON() }) seriesMatches.push({ series: series.toOldJSON(), diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index c7c0914be7..0aaf6f4bc6 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -107,7 +107,7 @@ module.exports = { * @param {string[]} include * @param {number} limit * @param {number} offset - * @returns {object} { libraryItems:LibraryItem[], count:number } + * @returns {Promise<{ libraryItems: import('../../models/LibraryItem')[], count: number }>} */ async getFilteredLibraryItems(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset) { const includeRSSFeed = include.includes('rssfeed') @@ -175,16 +175,19 @@ module.exports = { }) const libraryItems = podcasts.map((podcastExpanded) => { - const libraryItem = podcastExpanded.libraryItem.toJSON() - const podcast = podcastExpanded.toJSON() + const libraryItem = podcastExpanded.libraryItem + const podcast = podcastExpanded delete podcast.libraryItem - if (podcastExpanded.libraryItem.feeds?.length) { - libraryItem.rssFeed = podcastExpanded.libraryItem.feeds[0] + if (libraryItem.feeds?.length) { + libraryItem.rssFeed = libraryItem.feeds[0] } - if (podcast.numEpisodesIncomplete) { - libraryItem.numEpisodesIncomplete = podcast.numEpisodesIncomplete + if (podcast.dataValues.numEpisodesIncomplete) { + libraryItem.numEpisodesIncomplete = podcast.dataValues.numEpisodesIncomplete + } + if (podcast.dataValues.numEpisodes) { + podcast.numEpisodes = podcast.dataValues.numEpisodes } libraryItem.media = podcast @@ -209,7 +212,7 @@ module.exports = { * @param {number} limit * @param {number} offset * @param {boolean} isHomePage for home page shelves - * @returns {object} {libraryItems:LibraryItem[], count:number} + * @returns {Promise<{ libraryItems: import('../../models/LibraryItem')[], count: number }>} */ async getFilteredPodcastEpisodes(libraryId, user, filterGroup, filterValue, sortBy, sortDesc, limit, offset, isHomePage = false) { if (sortBy === 'progress' && filterGroup !== 'progress') { @@ -289,10 +292,11 @@ module.exports = { }) const libraryItems = podcastEpisodes.map((ep) => { - const libraryItem = ep.podcast.libraryItem.toJSON() - const podcast = ep.podcast.toJSON() + const libraryItem = ep.podcast.libraryItem + const podcast = ep.podcast delete podcast.libraryItem libraryItem.media = podcast + libraryItem.recentEpisode = ep.getOldPodcastEpisode(libraryItem.id).toJSON() return libraryItem }) @@ -362,8 +366,9 @@ module.exports = { const libraryItem = podcast.libraryItem delete podcast.libraryItem libraryItem.media = podcast + libraryItem.media.podcastEpisodes = [] itemMatches.push({ - libraryItem: Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONExpanded() + libraryItem: libraryItem.toOldJSONExpanded() }) } diff --git a/server/utils/queries/seriesFilters.js b/server/utils/queries/seriesFilters.js index 2e0e23469b..ed71e5b3fb 100644 --- a/server/utils/queries/seriesFilters.js +++ b/server/utils/queries/seriesFilters.js @@ -162,6 +162,12 @@ module.exports = { include: [ { model: Database.libraryItemModel + }, + { + model: Database.authorModel + }, + { + model: Database.seriesModel } ] }, @@ -195,10 +201,10 @@ module.exports = { }) }) oldSeries.books = s.bookSeries.map((bs) => { - const libraryItem = bs.book.libraryItem.toJSON() + const libraryItem = bs.book.libraryItem delete bs.book.libraryItem libraryItem.media = bs.book - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem).toJSONMinified() + const oldLibraryItem = libraryItem.toOldJSONMinified() return oldLibraryItem }) allOldSeries.push(oldSeries) From 726a9eaea5c41bc8e6cd3221fb4096f1ad7e3d32 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 4 Jan 2025 15:35:05 -0600 Subject: [PATCH 019/118] Fix local playback sync --- server/controllers/EmailController.js | 2 +- server/managers/PlaybackSessionManager.js | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/server/controllers/EmailController.js b/server/controllers/EmailController.js index 916b4268ef..5d433e0ac6 100644 --- a/server/controllers/EmailController.js +++ b/server/controllers/EmailController.js @@ -106,7 +106,7 @@ class EmailController { return res.sendStatus(403) } - const libraryItem = await Database.libraryItemModel.getOldById(req.body.libraryItemId) + const libraryItem = await Database.libraryItemModel.getExpandedById(req.body.libraryItemId) if (!libraryItem) { return res.status(404).send('Library item not found') } diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index 97c87bbefa..25992f0ae1 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -120,8 +120,8 @@ class PlaybackSessionManager { */ async syncLocalSession(user, sessionJson, deviceInfo) { // TODO: Combine libraryItem query with library query - const libraryItem = await Database.libraryItemModel.getOldById(sessionJson.libraryItemId) - const episode = sessionJson.episodeId && libraryItem && libraryItem.isPodcast ? libraryItem.media.getEpisode(sessionJson.episodeId) : null + const libraryItem = await Database.libraryItemModel.getExpandedById(sessionJson.libraryItemId) + const episode = sessionJson.episodeId && libraryItem && libraryItem.isPodcast ? libraryItem.media.podcastEpisodes.find((pe) => pe.id === sessionJson.episodeId) : null if (!libraryItem || (libraryItem.isPodcast && !episode)) { Logger.error(`[PlaybackSessionManager] syncLocalSession: Media item not found for session "${sessionJson.displayTitle}" (${sessionJson.id})`) return { @@ -175,7 +175,8 @@ class PlaybackSessionManager { // New session from local session = new PlaybackSession(sessionJson) session.deviceInfo = deviceInfo - session.setDuration(libraryItem, sessionJson.episodeId) + session.duration = libraryItem.media.getPlaybackDuration(sessionJson.episodeId) + Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`) await Database.createPlaybackSession(session) } else { @@ -346,7 +347,7 @@ class PlaybackSessionManager { */ async syncSession(user, session, syncData) { // TODO: Combine libraryItem query with library query - const libraryItem = await Database.libraryItemModel.getOldById(session.libraryItemId) + const libraryItem = await Database.libraryItemModel.getExpandedById(session.libraryItemId) if (!libraryItem) { Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`) return null @@ -381,9 +382,6 @@ class PlaybackSessionManager { }) } this.saveSession(session) - return { - libraryItem - } } /** From 1e9470b8402099d3c05e46fb83b2579a50890971 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 4 Jan 2025 15:59:40 -0600 Subject: [PATCH 020/118] Update AuthorController library item usage and remove unused --- server/Database.js | 22 ------------ server/controllers/AuthorController.js | 23 +++++++----- server/models/LibraryItem.js | 48 ++------------------------ server/objects/LibraryItem.js | 11 ------ 4 files changed, 16 insertions(+), 88 deletions(-) diff --git a/server/Database.js b/server/Database.js index 45af5248f3..61385981b8 100644 --- a/server/Database.js +++ b/server/Database.js @@ -401,17 +401,6 @@ class Database { return this.models.setting.updateSettingObj(settings.toJSON()) } - updateBulkBooks(oldBooks) { - if (!this.sequelize) return false - return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook))) - } - - async createLibraryItem(oldLibraryItem) { - if (!this.sequelize) return false - await oldLibraryItem.saveMetadata() - await this.models.libraryItem.fullCreateFromOld(oldLibraryItem) - } - /** * Save metadata file and update library item * @@ -429,17 +418,6 @@ class Database { return updated } - async createBulkBookAuthors(bookAuthors) { - if (!this.sequelize) return false - await this.models.bookAuthor.bulkCreate(bookAuthors) - } - - async removeBulkBookAuthors(authorId = null, bookId = null) { - if (!this.sequelize) return false - if (!authorId && !bookId) return - await this.models.bookAuthor.removeByIds(authorId, bookId) - } - getPlaybackSessions(where = null) { if (!this.sequelize) return false return this.models.playbackSession.getOldPlaybackSessions(where) diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js index a6b5a2f419..31c94307b1 100644 --- a/server/controllers/AuthorController.js +++ b/server/controllers/AuthorController.js @@ -44,16 +44,21 @@ class AuthorController { // Used on author landing page to include library items and items grouped in series if (include.includes('items')) { - authorJson.libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user) + const libraryItems = await Database.libraryItemModel.getForAuthor(req.author, req.user) if (include.includes('series')) { const seriesMap = {} // Group items into series - authorJson.libraryItems.forEach((li) => { - if (li.media.metadata.series) { - li.media.metadata.series.forEach((series) => { - const itemWithSeries = li.toJSONMinified() - itemWithSeries.media.metadata.series = series + libraryItems.forEach((li) => { + if (li.media.series?.length) { + li.media.series.forEach((series) => { + const itemWithSeries = li.toOldJSONMinified() + itemWithSeries.media.metadata.series = { + id: series.id, + name: series.name, + nameIgnorePrefix: series.nameIgnorePrefix, + sequence: series.bookSeries.sequence + } if (seriesMap[series.id]) { seriesMap[series.id].items.push(itemWithSeries) @@ -76,7 +81,7 @@ class AuthorController { } // Minify library items - authorJson.libraryItems = authorJson.libraryItems.map((li) => li.toJSONMinified()) + authorJson.libraryItems = libraryItems.map((li) => li.toOldJSONMinified()) } return res.json(authorJson) @@ -142,8 +147,8 @@ class AuthorController { }) }) if (libraryItems.length) { - await Database.removeBulkBookAuthors(req.author.id) // Remove all old BookAuthor - await Database.createBulkBookAuthors(bookAuthorsToCreate) // Create all new BookAuthor + await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor + await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate) // Create all new BookAuthor for (const libraryItem of libraryItems) { await libraryItem.saveMetadataFile() } diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 0d87a328b3..31a6a0b4da 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -160,40 +160,6 @@ class LibraryItem extends Model { }) } - static async fullCreateFromOld(oldLibraryItem) { - const newLibraryItem = await this.create(this.getFromOld(oldLibraryItem)) - - if (oldLibraryItem.mediaType === 'book') { - const bookObj = this.sequelize.models.book.getFromOld(oldLibraryItem.media) - bookObj.libraryItemId = newLibraryItem.id - const newBook = await this.sequelize.models.book.create(bookObj) - - const oldBookAuthors = oldLibraryItem.media.metadata.authors || [] - const oldBookSeriesAll = oldLibraryItem.media.metadata.series || [] - - for (const oldBookAuthor of oldBookAuthors) { - await this.sequelize.models.bookAuthor.create({ authorId: oldBookAuthor.id, bookId: newBook.id }) - } - for (const oldSeries of oldBookSeriesAll) { - await this.sequelize.models.bookSeries.create({ seriesId: oldSeries.id, bookId: newBook.id, sequence: oldSeries.sequence }) - } - } else if (oldLibraryItem.mediaType === 'podcast') { - const podcastObj = this.sequelize.models.podcast.getFromOld(oldLibraryItem.media) - podcastObj.libraryItemId = newLibraryItem.id - const newPodcast = await this.sequelize.models.podcast.create(podcastObj) - - const oldEpisodes = oldLibraryItem.media.episodes || [] - for (const oldEpisode of oldEpisodes) { - const episodeObj = this.sequelize.models.podcastEpisode.getFromOld(oldEpisode) - episodeObj.libraryItemId = newLibraryItem.id - episodeObj.podcastId = newPodcast.id - await this.sequelize.models.podcastEpisode.create(episodeObj) - } - } - - return newLibraryItem - } - /** * Updates libraryItem, book, authors and series from old library item * @@ -819,21 +785,11 @@ class LibraryItem extends Model { * Get book library items for author, optional use user permissions * @param {import('./Author')} author * @param {import('./User')} user - * @returns {Promise} + * @returns {Promise} */ static async getForAuthor(author, user = null) { const { libraryItems } = await libraryFilters.getLibraryItemsForAuthor(author, user, undefined, undefined) - return libraryItems.map((li) => this.getOldLibraryItem(li)) - } - - /** - * Get book library items in a collection - * @param {oldCollection} collection - * @returns {Promise} - */ - static async getForCollection(collection) { - const libraryItems = await libraryFilters.getLibraryItemsForCollection(collection) - return libraryItems.map((li) => this.getOldLibraryItem(li)) + return libraryItems } /** diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index 17d7484ce6..d955356ecd 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -269,16 +269,5 @@ class LibraryItem { this.isSavingMetadata = false }) } - - removeLibraryFile(ino) { - if (!ino) return false - const libraryFile = this.libraryFiles.find((lf) => lf.ino === ino) - if (libraryFile) { - this.libraryFiles = this.libraryFiles.filter((lf) => lf.ino !== ino) - this.updatedAt = Date.now() - return true - } - return false - } } module.exports = LibraryItem From d5ce7b4939ca31a8c8d0456c230c1f3d10525362 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 5 Jan 2025 12:05:01 -0600 Subject: [PATCH 021/118] Migrate to new library item in scanner --- server/Database.js | 17 -- server/controllers/AuthorController.js | 10 + server/controllers/LibraryItemController.js | 135 ++++----- server/controllers/PodcastController.js | 6 +- server/controllers/SearchController.js | 2 +- server/finders/BookFinder.js | 2 +- server/managers/CoverManager.js | 55 ---- server/models/Author.js | 16 ++ server/models/Book.js | 128 ++++++--- server/models/LibraryItem.js | 252 +--------------- server/models/MediaItemShare.js | 11 +- server/models/Series.js | 18 +- server/objects/LibraryItem.js | 120 -------- server/objects/mediaTypes/Book.js | 16 -- server/objects/mediaTypes/Podcast.js | 17 -- server/objects/metadata/BookMetadata.js | 25 -- server/objects/metadata/PodcastMetadata.js | 4 - server/routers/ApiRouter.js | 105 ------- server/scanner/LibraryScanner.js | 18 +- server/scanner/Scanner.js | 301 +++++++++++++------- server/utils/podcastUtils.js | 6 + 21 files changed, 427 insertions(+), 837 deletions(-) diff --git a/server/Database.js b/server/Database.js index 61385981b8..82a8fbd119 100644 --- a/server/Database.js +++ b/server/Database.js @@ -401,23 +401,6 @@ class Database { return this.models.setting.updateSettingObj(settings.toJSON()) } - /** - * Save metadata file and update library item - * - * @param {import('./objects/LibraryItem')} oldLibraryItem - * @returns {Promise} - */ - async updateLibraryItem(oldLibraryItem) { - if (!this.sequelize) return false - await oldLibraryItem.saveMetadata() - const updated = await this.models.libraryItem.fullUpdateFromOld(oldLibraryItem) - // Clear library filter data cache - if (updated) { - delete this.libraryFilterData[oldLibraryItem.libraryId] - } - return updated - } - getPlaybackSessions(where = null) { if (!this.sequelize) return false return this.models.playbackSession.getOldPlaybackSessions(where) diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js index 31c94307b1..4715088372 100644 --- a/server/controllers/AuthorController.js +++ b/server/controllers/AuthorController.js @@ -242,8 +242,18 @@ class AuthorController { await CacheManager.purgeImageCache(req.author.id) // Purge cache } + // Load library items so that metadata file can be updated + const allItemsWithAuthor = await Database.authorModel.getAllLibraryItemsForAuthor(req.author.id) + allItemsWithAuthor.forEach((libraryItem) => { + libraryItem.media.authors = libraryItem.media.authors.filter((au) => au.id !== req.author.id) + }) + await req.author.destroy() + for (const libraryItem of allItemsWithAuthor) { + await libraryItem.saveMetadataFile() + } + SocketAuthority.emitter('author_removed', req.author.toOldJSON()) // Update filter data diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 14a85f6e3a..3a4fb159f5 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -81,31 +81,6 @@ class LibraryItemController { res.json(req.libraryItem.toOldJSON()) } - /** - * PATCH: /api/items/:id - * - * @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) { - // Item has cover and update is removing cover so purge it from cache - if (req.libraryItem.media.coverPath && req.body.media && (req.body.media.coverPath === '' || req.body.media.coverPath === null)) { - await CacheManager.purgeCoverCache(req.libraryItem.id) - } - - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem) - const hasUpdates = oldLibraryItem.update(req.body) - if (hasUpdates) { - Logger.debug(`[LibraryItemController] Updated now saving`) - await Database.updateLibraryItem(oldLibraryItem) - SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) - } - res.json(oldLibraryItem.toJSON()) - } - /** * DELETE: /api/items/:id * Delete library item. Will delete from database and file system if hard delete is requested. @@ -219,11 +194,6 @@ class LibraryItemController { if (res.writableEnded || res.headersSent) return } - // Book specific - if (req.libraryItem.isBook) { - await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, req.libraryItem.libraryId) - } - // Podcast specific let isPodcastAutoDownloadUpdated = false if (req.libraryItem.isPodcast) { @@ -234,41 +204,56 @@ class LibraryItemController { } } - // Book specific - Get all series being removed from this item - let seriesRemoved = [] - if (req.libraryItem.isBook && mediaPayload.metadata?.series) { - const seriesIdsInUpdate = mediaPayload.metadata.series?.map((se) => se.id) || [] - seriesRemoved = req.libraryItem.media.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) + let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url + + if (req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) { + const seriesUpdateData = await req.libraryItem.media.updateSeriesFromRequest(mediaPayload.metadata.series, req.libraryItem.libraryId) + if (seriesUpdateData?.seriesRemoved.length) { + // Check remove empty series + Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`) + await this.checkRemoveEmptySeries(seriesUpdateData.seriesRemoved.map((se) => se.id)) + } + if (seriesUpdateData?.seriesAdded.length) { + // Add series to filter data + seriesUpdateData.seriesAdded.forEach((se) => { + Database.addSeriesToFilterData(req.libraryItem.libraryId, se.name, se.id) + }) + } + if (seriesUpdateData?.hasUpdates) { + hasUpdates = true + } } - let authorsRemoved = [] - if (req.libraryItem.isBook && mediaPayload.metadata?.authors) { - const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id) - authorsRemoved = req.libraryItem.media.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) + if (req.libraryItem.isBook && Array.isArray(mediaPayload.metadata?.authors)) { + const authorNames = mediaPayload.metadata.authors.map((au) => (typeof au.name === 'string' ? au.name.trim() : null)).filter((au) => au) + const authorUpdateData = await req.libraryItem.media.updateAuthorsFromRequest(authorNames, req.libraryItem.libraryId) + if (authorUpdateData?.authorsRemoved.length) { + // Check remove empty authors + Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`) + await this.checkRemoveAuthorsWithNoBooks(authorUpdateData.authorsRemoved.map((au) => au.id)) + hasUpdates = true + } + if (authorUpdateData?.authorsAdded.length) { + // Add authors to filter data + authorUpdateData.authorsAdded.forEach((au) => { + Database.addAuthorToFilterData(req.libraryItem.libraryId, au.name, au.id) + }) + hasUpdates = true + } } - const hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url if (hasUpdates) { req.libraryItem.changed('updatedAt', true) await req.libraryItem.save() + await req.libraryItem.saveMetadataFile() + if (isPodcastAutoDownloadUpdated) { this.cronManager.checkUpdatePodcastCron(req.libraryItem) } 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 - Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`) - await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id)) - } - if (seriesRemoved.length) { - // Check remove empty series - Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`) - await this.checkRemoveEmptySeries(seriesRemoved.map((se) => se.id)) - } } res.json({ updated: hasUpdates, @@ -527,8 +512,7 @@ class LibraryItemController { options.overrideDetails = !!reqBody.overrideDetails } - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem) - var matchResult = await Scanner.quickMatchLibraryItem(this, oldLibraryItem, options) + const matchResult = await Scanner.quickMatchLibraryItem(this, req.libraryItem, options) res.json(matchResult) } @@ -640,26 +624,44 @@ class LibraryItemController { const mediaPayload = updatePayload.mediaPayload const libraryItem = libraryItems.find((li) => li.id === updatePayload.id) - await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId) + let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload) - if (libraryItem.isBook) { - if (Array.isArray(mediaPayload.metadata?.series)) { - const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id) - const seriesRemoved = libraryItem.media.series.filter((se) => !seriesIdsInUpdate.includes(se.id)) - seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id)) + if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) { + const seriesUpdateData = await libraryItem.media.updateSeriesFromRequest(mediaPayload.metadata.series, libraryItem.libraryId) + if (seriesUpdateData?.seriesRemoved.length) { + seriesIdsRemoved.push(...seriesUpdateData.seriesRemoved.map((se) => se.id)) + } + if (seriesUpdateData?.seriesAdded.length) { + seriesUpdateData.seriesAdded.forEach((se) => { + Database.addSeriesToFilterData(libraryItem.libraryId, se.name, se.id) + }) } - if (Array.isArray(mediaPayload.metadata?.authors)) { - const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id) - const authorsRemoved = libraryItem.media.authors.filter((au) => !authorIdsInUpdate.includes(au.id)) - authorIdsRemoved.push(...authorsRemoved.map((au) => au.id)) + if (seriesUpdateData?.hasUpdates) { + hasUpdates = true + } + } + + if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.authors)) { + const authorNames = mediaPayload.metadata.authors.map((au) => (typeof au.name === 'string' ? au.name.trim() : null)).filter((au) => au) + const authorUpdateData = await libraryItem.media.updateAuthorsFromRequest(authorNames, libraryItem.libraryId) + if (authorUpdateData?.authorsRemoved.length) { + authorIdsRemoved.push(...authorUpdateData.authorsRemoved.map((au) => au.id)) + hasUpdates = true + } + if (authorUpdateData?.authorsAdded.length) { + authorUpdateData.authorsAdded.forEach((au) => { + Database.addAuthorToFilterData(libraryItem.libraryId, au.name, au.id) + }) + hasUpdates = true } } - const hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload) if (hasUpdates) { libraryItem.changed('updatedAt', true) await libraryItem.save() + await libraryItem.saveMetadataFile() + Logger.debug(`[LibraryItemController] Updated library item media "${libraryItem.media.title}"`) SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) itemsUpdated++ @@ -739,8 +741,7 @@ class LibraryItemController { } for (const libraryItem of libraryItems) { - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(libraryItem) - const matchResult = await Scanner.quickMatchLibraryItem(this, oldLibraryItem, options) + const matchResult = await Scanner.quickMatchLibraryItem(this, libraryItem, options) if (matchResult.updated) { itemsUpdated++ } else if (matchResult.warning) { @@ -891,6 +892,8 @@ class LibraryItemController { req.libraryItem.media.changed('chapters', true) await req.libraryItem.media.save() + await req.libraryItem.saveMetadataFile() + SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) } diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index 3d8ff240a4..1d1c106d39 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -375,11 +375,9 @@ class PodcastController { } const overrideDetails = req.query.override === '1' - const oldLibraryItem = Database.libraryItemModel.getOldLibraryItem(req.libraryItem) - const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(oldLibraryItem, { overrideDetails }) + const episodesUpdated = await Scanner.quickMatchPodcastEpisodes(req.libraryItem, { overrideDetails }) if (episodesUpdated) { - await Database.updateLibraryItem(oldLibraryItem) - SocketAuthority.emitter('item_updated', oldLibraryItem.toJSONExpanded()) + SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) } res.json({ diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index a19ff87667..51aaa910c2 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -24,7 +24,7 @@ class SearchController { */ async findBooks(req, res) { const id = req.query.id - const libraryItem = await Database.libraryItemModel.getOldById(id) + const libraryItem = await Database.libraryItemModel.getExpandedById(id) const provider = req.query.provider || 'google' const title = req.query.title || '' const author = req.query.author || '' diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 47d1118c0f..f43230946a 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -361,7 +361,7 @@ class BookFinder { /** * Search for books including fuzzy searches * - * @param {Object} libraryItem + * @param {import('../models/LibraryItem')} libraryItem * @param {string} provider * @param {string} title * @param {string} author diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js index 945c69abcc..c8f8891081 100644 --- a/server/managers/CoverManager.js +++ b/server/managers/CoverManager.js @@ -123,61 +123,6 @@ class CoverManager { } } - /** - * - * @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 - var coverDirPath = forceLibraryItemFolder ? libraryItem.path : this.getCoverDirectory(libraryItem) - await fs.ensureDir(coverDirPath) - - var temppath = Path.posix.join(coverDirPath, 'cover') - - let errorMsg = '' - let success = await downloadImageFile(url, temppath) - .then(() => true) - .catch((err) => { - errorMsg = err.message || 'Unknown error' - Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg) - return false - }) - if (!success) { - return { - error: 'Failed to download image from url: ' + errorMsg - } - } - - var imgtype = await this.checkFileIsValidImage(temppath, true) - - if (imgtype.error) { - return imgtype - } - - var coverFilename = `cover.${imgtype.ext}` - var coverFullPath = Path.posix.join(coverDirPath, coverFilename) - await fs.rename(temppath, coverFullPath) - - await this.removeOldCovers(coverDirPath, '.' + imgtype.ext) - await CacheManager.purgeCoverCache(libraryItem.id) - - Logger.info(`[CoverManager] Downloaded libraryItem cover "${coverFullPath}" from url "${url}" for "${libraryItem.media.metadata.title}"`) - libraryItem.updateMediaCover(coverFullPath) - return { - cover: coverFullPath - } - } catch (error) { - Logger.error(`[CoverManager] Fetch cover image from url "${url}" failed`, error) - return { - error: 'Failed to fetch image from url' - } - } - } - /** * * @param {string} coverPath diff --git a/server/models/Author.js b/server/models/Author.js index f3bbba5740..287b669767 100644 --- a/server/models/Author.js +++ b/server/models/Author.js @@ -107,6 +107,22 @@ class Author extends Model { return libraryItems } + /** + * + * @param {string} name + * @param {string} libraryId + * @returns {Promise} + */ + static async findOrCreateByNameAndLibrary(name, libraryId) { + const author = await this.getByNameAndLibrary(name, libraryId) + if (author) return author + return this.create({ + name, + lastFirst: this.getLastFirst(name), + libraryId + }) + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize diff --git a/server/models/Book.js b/server/models/Book.js index 4c2006a102..dff79da242 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -542,47 +542,111 @@ class Book extends Model { 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)) + return hasUpdates + } - 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 - } + /** + * Creates or removes authors from the book using the author names from the request + * + * @param {string[]} authors + * @param {string} libraryId + * @returns {Promise<{authorsRemoved: import('./Author')[], authorsAdded: import('./Author')[]}>} + */ + async updateAuthorsFromRequest(authors, libraryId) { + if (!Array.isArray(authors)) return null + + if (!this.authors) { + throw new Error(`[Book] Cannot update authors because authors are not loaded for book ${this.id}`) } - 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)) + /** @type {typeof import('./Author')} */ + const authorModel = this.sequelize.models.author - 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}`) + /** @type {typeof import('./BookAuthor')} */ + const bookAuthorModel = this.sequelize.models.bookAuthor + + const authorsCleaned = authors.map((a) => a.toLowerCase()).filter((a) => a) + const authorsRemoved = this.authors.filter((au) => !authorsCleaned.includes(au.name.toLowerCase())) + const newAuthorNames = authors.filter((a) => !this.authors.some((au) => au.name.toLowerCase() === a.toLowerCase())) + + for (const author of authorsRemoved) { + await bookAuthorModel.removeByIds(author.id, this.id) + Logger.debug(`[Book] "${this.title}" Removed author "${author.name}"`) + this.authors = this.authors.filter((au) => au.id !== author.id) + } + const authorsAdded = [] + for (const authorName of newAuthorNames) { + const author = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId) + await bookAuthorModel.create({ bookId: this.id, authorId: author.id }) + Logger.debug(`[Book] "${this.title}" Added author "${author.name}"`) + this.authors.push(author) + authorsAdded.push(author) + } + + return { + authorsRemoved, + authorsAdded + } + } + + /** + * Creates or removes series from the book using the series names from the request. + * Updates series sequence if it has changed. + * + * @param {{ name: string, sequence: string }[]} seriesObjects + * @param {string} libraryId + * @returns {Promise<{seriesRemoved: import('./Series')[], seriesAdded: import('./Series')[], hasUpdates: boolean}>} + */ + async updateSeriesFromRequest(seriesObjects, libraryId) { + if (!Array.isArray(seriesObjects) || seriesObjects.some((se) => !se.name || typeof se.name !== 'string')) return null + + if (!this.series) { + throw new Error(`[Book] Cannot update series because series are not loaded for book ${this.id}`) + } + + /** @type {typeof import('./Series')} */ + const seriesModel = this.sequelize.models.series + + /** @type {typeof import('./BookSeries')} */ + const bookSeriesModel = this.sequelize.models.bookSeries + + const seriesNamesCleaned = seriesObjects.map((se) => se.name.toLowerCase()) + const seriesRemoved = this.series.filter((se) => !seriesNamesCleaned.includes(se.name.toLowerCase())) + const seriesAdded = [] + let hasUpdates = false + for (const seriesObj of seriesObjects) { + const seriesObjSequence = typeof seriesObj.sequence === 'string' ? seriesObj.sequence : null + + const existingSeries = this.series.find((se) => se.name.toLowerCase() === seriesObj.name.toLowerCase()) + if (existingSeries) { + if (existingSeries.bookSeries.sequence !== seriesObjSequence) { + existingSeries.bookSeries.sequence = seriesObjSequence + await existingSeries.bookSeries.save() hasUpdates = true + Logger.debug(`[Book] "${this.title}" Updated series "${existingSeries.name}" sequence ${seriesObjSequence}`) } + } else { + const series = await seriesModel.findOrCreateByNameAndLibrary(seriesObj.name, libraryId) + series.bookSeries = await bookSeriesModel.create({ bookId: this.id, seriesId: series.id, sequence: seriesObjSequence }) + this.series.push(series) + seriesAdded.push(series) + hasUpdates = true + Logger.debug(`[Book] "${this.title}" Added series "${series.name}"`) } } - return hasUpdates + for (const series of seriesRemoved) { + await bookSeriesModel.removeByIds(series.id, this.id) + this.series = this.series.filter((se) => se.id !== series.id) + Logger.debug(`[Book] "${this.title}" Removed series ${series.id}`) + hasUpdates = true + } + + return { + seriesRemoved, + seriesAdded, + hasUpdates + } } /** diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 31a6a0b4da..d19816a3d8 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -160,206 +160,6 @@ class LibraryItem extends Model { }) } - /** - * Updates libraryItem, book, authors and series from old library item - * - * @param {oldLibraryItem} oldLibraryItem - * @returns {Promise} true if updates were made - */ - static async fullUpdateFromOld(oldLibraryItem) { - const libraryItemExpanded = await this.getExpandedById(oldLibraryItem.id) - if (!libraryItemExpanded) return false - - let hasUpdates = false - - // Check update Book/Podcast - if (libraryItemExpanded.media) { - let updatedMedia = null - if (libraryItemExpanded.mediaType === 'podcast') { - updatedMedia = this.sequelize.models.podcast.getFromOld(oldLibraryItem.media) - - const existingPodcastEpisodes = libraryItemExpanded.media.podcastEpisodes || [] - const updatedPodcastEpisodes = oldLibraryItem.media.episodes || [] - - for (const existingPodcastEpisode of existingPodcastEpisodes) { - // Episode was removed - if (!updatedPodcastEpisodes.some((ep) => ep.id === existingPodcastEpisode.id)) { - Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingPodcastEpisode.title}" was removed`) - await existingPodcastEpisode.destroy() - hasUpdates = true - } - } - for (const updatedPodcastEpisode of updatedPodcastEpisodes) { - const existingEpisodeMatch = existingPodcastEpisodes.find((ep) => ep.id === updatedPodcastEpisode.id) - if (!existingEpisodeMatch) { - Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`) - await this.sequelize.models.podcastEpisode.createFromOld(updatedPodcastEpisode) - hasUpdates = true - } else { - const updatedEpisodeCleaned = this.sequelize.models.podcastEpisode.getFromOld(updatedPodcastEpisode) - let episodeHasUpdates = false - for (const key in updatedEpisodeCleaned) { - let existingValue = existingEpisodeMatch[key] - if (existingValue instanceof Date) existingValue = existingValue.valueOf() - - if (!areEquivalent(updatedEpisodeCleaned[key], existingValue, true)) { - Logger.debug(util.format(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingEpisodeMatch.title}" ${key} was updated from %j to %j`, existingValue, updatedEpisodeCleaned[key])) - episodeHasUpdates = true - } - } - if (episodeHasUpdates) { - await existingEpisodeMatch.update(updatedEpisodeCleaned) - hasUpdates = true - } - } - } - } else if (libraryItemExpanded.mediaType === 'book') { - updatedMedia = this.sequelize.models.book.getFromOld(oldLibraryItem.media) - - const existingAuthors = libraryItemExpanded.media.authors || [] - const existingSeriesAll = libraryItemExpanded.media.series || [] - const updatedAuthors = oldLibraryItem.media.metadata.authors || [] - const uniqueUpdatedAuthors = updatedAuthors.filter((au, idx) => updatedAuthors.findIndex((a) => a.id === au.id) === idx) - const updatedSeriesAll = oldLibraryItem.media.metadata.series || [] - - for (const existingAuthor of existingAuthors) { - // Author was removed from Book - if (!uniqueUpdatedAuthors.some((au) => au.id === existingAuthor.id)) { - Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`) - await this.sequelize.models.bookAuthor.removeByIds(existingAuthor.id, libraryItemExpanded.media.id) - hasUpdates = true - } - } - for (const updatedAuthor of uniqueUpdatedAuthors) { - // Author was added - if (!existingAuthors.some((au) => au.id === updatedAuthor.id)) { - Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`) - await this.sequelize.models.bookAuthor.create({ authorId: updatedAuthor.id, bookId: libraryItemExpanded.media.id }) - hasUpdates = true - } - } - for (const existingSeries of existingSeriesAll) { - // Series was removed - if (!updatedSeriesAll.some((se) => se.id === existingSeries.id)) { - Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`) - await this.sequelize.models.bookSeries.removeByIds(existingSeries.id, libraryItemExpanded.media.id) - hasUpdates = true - } - } - for (const updatedSeries of updatedSeriesAll) { - // Series was added/updated - const existingSeriesMatch = existingSeriesAll.find((se) => se.id === updatedSeries.id) - if (!existingSeriesMatch) { - Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`) - await this.sequelize.models.bookSeries.create({ seriesId: updatedSeries.id, bookId: libraryItemExpanded.media.id, sequence: updatedSeries.sequence }) - hasUpdates = true - } else if (existingSeriesMatch.bookSeries.sequence !== updatedSeries.sequence) { - Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`) - await existingSeriesMatch.bookSeries.update({ id: updatedSeries.id, sequence: updatedSeries.sequence }) - hasUpdates = true - } - } - } - - let hasMediaUpdates = false - for (const key in updatedMedia) { - let existingValue = libraryItemExpanded.media[key] - if (existingValue instanceof Date) existingValue = existingValue.valueOf() - - if (!areEquivalent(updatedMedia[key], existingValue, true)) { - if (key === 'chapters') { - // Handle logging of chapters separately because the object is large - const chaptersRemoved = libraryItemExpanded.media.chapters.filter((ch) => !updatedMedia.chapters.some((uch) => uch.id === ch.id)) - if (chaptersRemoved.length) { - Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" chapters removed: ${chaptersRemoved.map((ch) => ch.title).join(', ')}`) - } - const chaptersAdded = updatedMedia.chapters.filter((uch) => !libraryItemExpanded.media.chapters.some((ch) => ch.id === uch.id)) - if (chaptersAdded.length) { - Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" chapters added: ${chaptersAdded.map((ch) => ch.title).join(', ')}`) - } - if (!chaptersRemoved.length && !chaptersAdded.length) { - Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" chapters updated`) - } - } else { - Logger.debug(util.format(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from %j to %j`, existingValue, updatedMedia[key])) - } - - hasMediaUpdates = true - } - } - if (hasMediaUpdates && updatedMedia) { - await libraryItemExpanded.media.update(updatedMedia) - hasUpdates = true - } - } - - const updatedLibraryItem = this.getFromOld(oldLibraryItem) - let hasLibraryItemUpdates = false - for (const key in updatedLibraryItem) { - let existingValue = libraryItemExpanded[key] - if (existingValue instanceof Date) existingValue = existingValue.valueOf() - - if (!areEquivalent(updatedLibraryItem[key], existingValue, true)) { - if (key === 'libraryFiles') { - // Handle logging of libraryFiles separately because the object is large (should be addressed when migrating off the old library item model) - const libraryFilesRemoved = libraryItemExpanded.libraryFiles.filter((lf) => !updatedLibraryItem.libraryFiles.some((ulf) => ulf.ino === lf.ino)) - if (libraryFilesRemoved.length) { - Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" library files removed: ${libraryFilesRemoved.map((lf) => lf.metadata.path).join(', ')}`) - } - const libraryFilesAdded = updatedLibraryItem.libraryFiles.filter((ulf) => !libraryItemExpanded.libraryFiles.some((lf) => lf.ino === ulf.ino)) - if (libraryFilesAdded.length) { - Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" library files added: ${libraryFilesAdded.map((lf) => lf.metadata.path).join(', ')}`) - } - if (!libraryFilesRemoved.length && !libraryFilesAdded.length) { - Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" library files updated`) - } - } else { - Logger.debug(util.format(`[LibraryItem] "${libraryItemExpanded.media.title}" ${key} updated from %j to %j`, existingValue, updatedLibraryItem[key])) - } - - hasLibraryItemUpdates = true - if (key === 'updatedAt') { - libraryItemExpanded.changed('updatedAt', true) - } - } - } - if (hasLibraryItemUpdates) { - await libraryItemExpanded.update(updatedLibraryItem) - Logger.info(`[LibraryItem] Library item "${libraryItemExpanded.id}" updated`) - hasUpdates = true - } - return hasUpdates - } - - static getFromOld(oldLibraryItem) { - const extraData = {} - if (oldLibraryItem.oldLibraryItemId) { - extraData.oldLibraryItemId = oldLibraryItem.oldLibraryItemId - } - return { - id: oldLibraryItem.id, - ino: oldLibraryItem.ino, - path: oldLibraryItem.path, - relPath: oldLibraryItem.relPath, - mediaId: oldLibraryItem.media.id, - mediaType: oldLibraryItem.mediaType, - isFile: !!oldLibraryItem.isFile, - isMissing: !!oldLibraryItem.isMissing, - isInvalid: !!oldLibraryItem.isInvalid, - mtime: oldLibraryItem.mtimeMs, - ctime: oldLibraryItem.ctimeMs, - updatedAt: oldLibraryItem.updatedAt, - birthtime: oldLibraryItem.birthtimeMs, - size: oldLibraryItem.size, - lastScan: oldLibraryItem.lastScan, - lastScanVersion: oldLibraryItem.scanVersion, - libraryId: oldLibraryItem.libraryId, - libraryFolderId: oldLibraryItem.folderId, - libraryFiles: oldLibraryItem.libraryFiles?.map((lf) => lf.toJSON()) || [], - extraData - } - } - /** * Remove library item by id * @@ -468,12 +268,14 @@ class LibraryItem extends Model { /** * * @param {import('sequelize').WhereOptions} where + * @param {import('sequelize').BindOrReplacements} [replacements] * @param {import('sequelize').IncludeOptions} [include] * @returns {Promise} */ - static async findOneExpanded(where, include = null) { + static async findOneExpanded(where, replacements = null, include = null) { const libraryItem = await this.findOne({ where, + replacements, include }) if (!libraryItem) { @@ -801,52 +603,6 @@ class LibraryItem extends Model { return (await this.count({ where: { id: libraryItemId } })) > 0 } - /** - * - * @param {import('sequelize').WhereOptions} where - * @param {import('sequelize').BindOrReplacements} replacements - * @returns {Object} oldLibraryItem - */ - static async findOneOld(where, replacements = {}) { - const libraryItem = await this.findOne({ - where, - replacements, - include: [ - { - model: this.sequelize.models.book, - include: [ - { - model: this.sequelize.models.author, - through: { - attributes: [] - } - }, - { - model: this.sequelize.models.series, - through: { - attributes: ['sequence'] - } - } - ] - }, - { - model: this.sequelize.models.podcast, - include: [ - { - model: this.sequelize.models.podcastEpisode - } - ] - } - ], - order: [ - [this.sequelize.models.book, this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'], - [this.sequelize.models.book, this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC'] - ] - }) - if (!libraryItem) return null - return this.getOldLibraryItem(libraryItem) - } - /** * * @param {string} libraryItemId @@ -970,7 +726,7 @@ class LibraryItem extends Model { } } - Logger.debug(`Success saving abmetadata to "${metadataFilePath}"`) + Logger.debug(`[LibraryItem] Saved metadata for "${this.media.title}" file to "${metadataFilePath}"`) return metadataLibraryFile }) diff --git a/server/models/MediaItemShare.js b/server/models/MediaItemShare.js index 2d5be8f69c..6bff17b8f7 100644 --- a/server/models/MediaItemShare.js +++ b/server/models/MediaItemShare.js @@ -87,13 +87,10 @@ class MediaItemShare extends Model { const libraryItemModel = this.sequelize.models.libraryItem if (mediaItemType === 'book') { - const libraryItem = await libraryItemModel.findOneExpanded( - { mediaId: mediaItemId }, - { - model: this.sequelize.models.library, - attributes: ['settings'] - } - ) + const libraryItem = await libraryItemModel.findOneExpanded({ mediaId: mediaItemId }, null, { + model: this.sequelize.models.library, + attributes: ['settings'] + }) return libraryItem } diff --git a/server/models/Series.js b/server/models/Series.js index c4bc1594ce..6ca288464f 100644 --- a/server/models/Series.js +++ b/server/models/Series.js @@ -1,6 +1,6 @@ const { DataTypes, Model, where, fn, col, literal } = require('sequelize') -const { getTitlePrefixAtEnd } = require('../utils/index') +const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils/index') class Series extends Model { constructor(values, options) { @@ -66,6 +66,22 @@ class Series extends Model { return series } + /** + * + * @param {string} seriesName + * @param {string} libraryId + * @returns {Promise} + */ + static async findOrCreateByNameAndLibrary(seriesName, libraryId) { + const series = await this.getByNameAndLibrary(seriesName, libraryId) + if (series) return series + return this.create({ + name: seriesName, + nameIgnorePrefix: getTitleIgnorePrefix(seriesName), + libraryId + }) + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index d955356ecd..3cf89b104f 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -135,33 +135,6 @@ class LibraryItem { } } - // Adds additional helpful fields like media duration, tracks, etc. - toJSONExpanded() { - return { - id: this.id, - ino: this.ino, - oldLibraryItemId: this.oldLibraryItemId, - libraryId: this.libraryId, - folderId: this.folderId, - path: this.path, - relPath: this.relPath, - isFile: this.isFile, - mtimeMs: this.mtimeMs, - ctimeMs: this.ctimeMs, - birthtimeMs: this.birthtimeMs, - addedAt: this.addedAt, - updatedAt: this.updatedAt, - lastScan: this.lastScan, - scanVersion: this.scanVersion, - isMissing: !!this.isMissing, - isInvalid: !!this.isInvalid, - mediaType: this.mediaType, - media: this.media.toJSONExpanded(), - libraryFiles: this.libraryFiles.map((f) => f.toJSON()), - size: this.size - } - } - get isPodcast() { return this.mediaType === 'podcast' } @@ -176,98 +149,5 @@ class LibraryItem { get hasAudioFiles() { return this.libraryFiles.some((lf) => lf.fileType === 'audio') } - - update(payload) { - const json = this.toJSON() - let hasUpdates = false - for (const key in json) { - if (payload[key] !== undefined) { - if (key === 'media') { - if (this.media.update(payload[key])) { - hasUpdates = true - } - } else if (!areEquivalent(payload[key], json[key])) { - this[key] = copyValue(payload[key]) - hasUpdates = true - } - } - } - if (hasUpdates) { - this.updatedAt = Date.now() - } - return hasUpdates - } - - updateMediaCover(coverPath) { - this.media.updateCover(coverPath) - this.updatedAt = Date.now() - return true - } - - setMissing() { - this.isMissing = true - this.updatedAt = Date.now() - } - - /** - * Save metadata.json file - * TODO: Move to new LibraryItem model - * @returns {Promise} null if not saved - */ - async saveMetadata() { - if (this.isSavingMetadata || !global.MetadataPath) return null - - this.isSavingMetadata = true - - let metadataPath = Path.join(global.MetadataPath, 'items', this.id) - let storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem - if (storeMetadataWithItem && !this.isFile) { - metadataPath = this.path - } else { - // Make sure metadata book dir exists - storeMetadataWithItem = false - await fs.ensureDir(metadataPath) - } - - const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) - - return fs - .writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)) - .then(async () => { - // Add metadata.json to libraryFiles array if it is new - let metadataLibraryFile = this.libraryFiles.find((lf) => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - metadataLibraryFile = new LibraryFile() - await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) - this.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path) - if (libraryItemDirTimestamps) { - this.mtimeMs = libraryItemDirTimestamps.mtimeMs - this.ctimeMs = libraryItemDirTimestamps.ctimeMs - } - } - - Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`) - - return metadataLibraryFile - }) - .catch((error) => { - Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error) - return null - }) - .finally(() => { - this.isSavingMetadata = false - }) - } } module.exports = LibraryItem diff --git a/server/objects/mediaTypes/Book.js b/server/objects/mediaTypes/Book.js index 488c3aac70..b270e0e710 100644 --- a/server/objects/mediaTypes/Book.js +++ b/server/objects/mediaTypes/Book.js @@ -68,22 +68,6 @@ class Book { } } - toJSONExpanded() { - return { - id: this.id, - libraryItemId: this.libraryItemId, - metadata: this.metadata.toJSONExpanded(), - coverPath: this.coverPath, - tags: [...this.tags], - 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()), - ebookFile: this.ebookFile?.toJSON() || null - } - } - toJSONForMetadataFile() { return { tags: [...this.tags], diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index f27f3fa2a1..2ec4a8735f 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -83,23 +83,6 @@ class Podcast { } } - toJSONExpanded() { - return { - id: this.id, - libraryItemId: this.libraryItemId, - metadata: this.metadata.toJSONExpanded(), - coverPath: this.coverPath, - tags: [...this.tags], - episodes: this.episodes.map((e) => e.toJSONExpanded()), - autoDownloadEpisodes: this.autoDownloadEpisodes, - autoDownloadSchedule: this.autoDownloadSchedule, - lastEpisodeCheck: this.lastEpisodeCheck, - maxEpisodesToKeep: this.maxEpisodesToKeep, - maxNewEpisodesToDownload: this.maxNewEpisodesToDownload, - size: this.size - } - } - toJSONForMetadataFile() { return { tags: [...this.tags], diff --git a/server/objects/metadata/BookMetadata.js b/server/objects/metadata/BookMetadata.js index 0dfe1dbf3c..5116f2f4a0 100644 --- a/server/objects/metadata/BookMetadata.js +++ b/server/objects/metadata/BookMetadata.js @@ -89,31 +89,6 @@ class BookMetadata { } } - toJSONExpanded() { - return { - title: this.title, - titleIgnorePrefix: this.titlePrefixAtEnd, - subtitle: this.subtitle, - authors: this.authors.map((a) => ({ ...a })), // Author JSONMinimal with name and id - narrators: [...this.narrators], - series: this.series.map((s) => ({ ...s })), - 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, - authorName: this.authorName, - authorNameLF: this.authorNameLF, - narratorName: this.narratorName, - seriesName: this.seriesName, - abridged: this.abridged - } - } - toJSONForMetadataFile() { const json = this.toJSON() json.authors = json.authors.map((au) => au.name) diff --git a/server/objects/metadata/PodcastMetadata.js b/server/objects/metadata/PodcastMetadata.js index 0df40df09c..ccc94ce072 100644 --- a/server/objects/metadata/PodcastMetadata.js +++ b/server/objects/metadata/PodcastMetadata.js @@ -75,10 +75,6 @@ class PodcastMetadata { } } - toJSONExpanded() { - return this.toJSONMinified() - } - clone() { return new PodcastMetadata(this.toJSON()) } diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 56f43dbf0f..db9e66c5fb 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -105,7 +105,6 @@ class ApiRouter { this.router.post('/items/batch/scan', LibraryItemController.batchScan.bind(this)) this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this)) - this.router.patch('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.update.bind(this)) this.router.delete('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.delete.bind(this)) this.router.get('/items/:id/download', LibraryItemController.middleware.bind(this), LibraryItemController.download.bind(this)) this.router.patch('/items/:id/media', LibraryItemController.middleware.bind(this), LibraryItemController.updateMedia.bind(this)) @@ -531,109 +530,5 @@ class ApiRouter { }) return listeningStats } - - async createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryId) { - if (mediaPayload.metadata) { - const mediaMetadata = mediaPayload.metadata - - // Create new authors if in payload - if (mediaMetadata.authors?.length) { - const newAuthors = [] - for (let i = 0; i < mediaMetadata.authors.length; i++) { - const authorName = (mediaMetadata.authors[i].name || '').trim() - if (!authorName) { - Logger.error(`[ApiRouter] Invalid author object, no name`, mediaMetadata.authors[i]) - mediaMetadata.authors[i].id = null - continue - } - - if (mediaMetadata.authors[i].id?.startsWith('new')) { - mediaMetadata.authors[i].id = null - } - - // Ensure the ID for the author exists - if (mediaMetadata.authors[i].id && !(await Database.checkAuthorExists(libraryId, mediaMetadata.authors[i].id))) { - Logger.warn(`[ApiRouter] Author id "${mediaMetadata.authors[i].id}" does not exist`) - mediaMetadata.authors[i].id = null - } - - if (!mediaMetadata.authors[i].id) { - let author = await Database.authorModel.getByNameAndLibrary(authorName, libraryId) - if (!author) { - author = await Database.authorModel.create({ - name: authorName, - lastFirst: Database.authorModel.getLastFirst(authorName), - libraryId - }) - Logger.debug(`[ApiRouter] Creating new author "${author.name}"`) - newAuthors.push(author) - // Update filter data - Database.addAuthorToFilterData(libraryId, author.name, author.id) - } - - // Update ID in original payload - mediaMetadata.authors[i].id = author.id - } - } - // Remove authors without an id - mediaMetadata.authors = mediaMetadata.authors.filter((au) => !!au.id) - if (newAuthors.length) { - SocketAuthority.emitter( - 'authors_added', - newAuthors.map((au) => au.toOldJSON()) - ) - } - } - - // Create new series if in payload - if (mediaMetadata.series && mediaMetadata.series.length) { - const newSeries = [] - for (let i = 0; i < mediaMetadata.series.length; i++) { - const seriesName = (mediaMetadata.series[i].name || '').trim() - if (!seriesName) { - Logger.error(`[ApiRouter] Invalid series object, no name`, mediaMetadata.series[i]) - mediaMetadata.series[i].id = null - continue - } - - if (mediaMetadata.series[i].id?.startsWith('new')) { - mediaMetadata.series[i].id = null - } - - // Ensure the ID for the series exists - if (mediaMetadata.series[i].id && !(await Database.checkSeriesExists(libraryId, mediaMetadata.series[i].id))) { - Logger.warn(`[ApiRouter] Series id "${mediaMetadata.series[i].id}" does not exist`) - mediaMetadata.series[i].id = null - } - - if (!mediaMetadata.series[i].id) { - let seriesItem = await Database.seriesModel.getByNameAndLibrary(seriesName, libraryId) - if (!seriesItem) { - seriesItem = await Database.seriesModel.create({ - name: seriesName, - nameIgnorePrefix: getTitleIgnorePrefix(seriesName), - libraryId - }) - Logger.debug(`[ApiRouter] Creating new series "${seriesItem.name}"`) - newSeries.push(seriesItem) - // Update filter data - Database.addSeriesToFilterData(libraryId, seriesItem.name, seriesItem.id) - } - - // Update ID in original payload - mediaMetadata.series[i].id = seriesItem.id - } - } - // Remove series without an id - mediaMetadata.series = mediaMetadata.series.filter((se) => se.id) - if (newSeries.length) { - SocketAuthority.emitter( - 'multiple_series_added', - newSeries.map((se) => se.toOldJSON()) - ) - } - } - } - } } module.exports = ApiRouter diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index c4f6410dea..1e92efdeca 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -582,7 +582,7 @@ class LibraryScanner { } // Check if book dir group is already an item - let existingLibraryItem = await Database.libraryItemModel.findOneOld({ + let existingLibraryItem = await Database.libraryItemModel.findOneExpanded({ libraryId: library.id, path: potentialChildDirs }) @@ -606,17 +606,17 @@ class LibraryScanner { if (existingLibraryItem.path === fullPath) { const exists = await fs.pathExists(fullPath) if (!exists) { - Logger.info(`[LibraryScanner] Scanning file update group and library item was deleted "${existingLibraryItem.media.metadata.title}" - marking as missing`) - existingLibraryItem.setMissing() - await Database.updateLibraryItem(existingLibraryItem) - SocketAuthority.emitter('item_updated', existingLibraryItem.toJSONExpanded()) + Logger.info(`[LibraryScanner] Scanning file update group and library item was deleted "${existingLibraryItem.media.title}" - marking as missing`) + existingLibraryItem.isMissing = true + await existingLibraryItem.save() + SocketAuthority.emitter('item_updated', existingLibraryItem.toOldJSONExpanded()) itemGroupingResults[itemDir] = ScanResult.REMOVED continue } } // Scan library item for updates - Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.metadata.title}" with id "${existingLibraryItem.id}" - scan for updates`) + Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.title}" with id "${existingLibraryItem.id}" - scan for updates`) itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id, updatedLibraryItemDetails) continue } else if (library.settings.audiobooksOnly && !hasAudioFiles(fileUpdateGroup, itemDir)) { @@ -672,7 +672,7 @@ function isSingleMediaFile(fileUpdateGroup, itemDir) { async function findLibraryItemByItemToItemInoMatch(libraryId, fullPath) { const ino = await fileUtils.getIno(fullPath) if (!ino) return null - const existingLibraryItem = await Database.libraryItemModel.findOneOld({ + const existingLibraryItem = await Database.libraryItemModel.findOneExpanded({ libraryId: libraryId, ino: ino }) @@ -685,7 +685,7 @@ async function findLibraryItemByItemToFileInoMatch(libraryId, fullPath, isSingle // check if it was moved from another folder by comparing the ino to the library files const ino = await fileUtils.getIno(fullPath) if (!ino) return null - const existingLibraryItem = await Database.libraryItemModel.findOneOld( + const existingLibraryItem = await Database.libraryItemModel.findOneExpanded( [ { libraryId: libraryId @@ -711,7 +711,7 @@ async function findLibraryItemByFileToItemInoMatch(libraryId, fullPath, isSingle if (ino) itemFileInos.push(ino) } if (!itemFileInos.length) return null - const existingLibraryItem = await Database.libraryItemModel.findOneOld({ + const existingLibraryItem = await Database.libraryItemModel.findOneExpanded({ libraryId: libraryId, ino: { [sequelize.Op.in]: itemFileInos diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 942c4d0298..5d4e1cc594 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -30,14 +30,14 @@ class Scanner { /** * * @param {import('../routers/ApiRouter')} apiRouterCtx - * @param {import('../objects/LibraryItem')} libraryItem + * @param {import('../models/LibraryItem')} libraryItem * @param {QuickMatchOptions} options * @returns {Promise<{updated: boolean, libraryItem: import('../objects/LibraryItem')}>} */ async quickMatchLibraryItem(apiRouterCtx, libraryItem, options = {}) { const provider = options.provider || 'google' - const searchTitle = options.title || libraryItem.media.metadata.title - const searchAuthor = options.author || libraryItem.media.metadata.authorName + const searchTitle = options.title || libraryItem.media.title + const searchAuthor = options.author || libraryItem.media.authorName // If overrideCover and overrideDetails is not sent in options than use the server setting to determine if we should override if (options.overrideCover === undefined && options.overrideDetails === undefined && Database.serverSettings.scannerPreferMatchedMetadata) { @@ -52,11 +52,11 @@ class Scanner { let existingSeries = [] if (libraryItem.isBook) { - existingAuthors = libraryItem.media.metadata.authors.map((a) => a.id) - existingSeries = libraryItem.media.metadata.series.map((s) => s.id) + existingAuthors = libraryItem.media.authors.map((a) => a.id) + existingSeries = libraryItem.media.series.map((s) => s.id) - const searchISBN = options.isbn || libraryItem.media.metadata.isbn - const searchASIN = options.asin || libraryItem.media.metadata.asin + const searchISBN = options.isbn || libraryItem.media.isbn + const searchASIN = options.asin || libraryItem.media.asin const results = await BookFinder.search(libraryItem, provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 }) if (!results.length) { @@ -69,15 +69,21 @@ class Scanner { // Update cover if not set OR overrideCover flag if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) { Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`) - var coverResult = await CoverManager.downloadCoverFromUrl(libraryItem, matchData.cover) - if (!coverResult || coverResult.error || !coverResult.cover) { - Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult ? coverResult.error : 'Unknown Error'}`) + const coverResult = await CoverManager.downloadCoverFromUrlNew(matchData.cover, libraryItem.id, libraryItem.isFile ? null : libraryItem.path) + if (coverResult.error) { + Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult.error}`) } else { + libraryItem.media.coverPath = coverResult.cover + libraryItem.media.changed('coverPath', true) // Cover path may be the same but this forces the update hasUpdated = true } } - updatePayload = await this.quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) + const bookBuildUpdateData = await this.quickMatchBookBuildUpdatePayload(apiRouterCtx, libraryItem, matchData, options) + updatePayload = bookBuildUpdateData.updatePayload + if (bookBuildUpdateData.hasSeriesUpdates || bookBuildUpdateData.hasAuthorUpdates) { + hasUpdated = true + } } else if (libraryItem.isPodcast) { // Podcast quick match const results = await PodcastFinder.search(searchTitle) @@ -91,10 +97,12 @@ class Scanner { // Update cover if not set OR overrideCover flag if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) { Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`) - var coverResult = await CoverManager.downloadCoverFromUrl(libraryItem, matchData.cover) - if (!coverResult || coverResult.error || !coverResult.cover) { - Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult ? coverResult.error : 'Unknown Error'}`) + const coverResult = await CoverManager.downloadCoverFromUrlNew(matchData.cover, libraryItem.id, libraryItem.path) + if (coverResult.error) { + Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult.error}`) } else { + libraryItem.media.coverPath = coverResult.cover + libraryItem.media.changed('coverPath', true) // Cover path may be the same but this forces the update hasUpdated = true } } @@ -103,44 +111,45 @@ class Scanner { } if (Object.keys(updatePayload).length) { - Logger.debug('[Scanner] Updating details', updatePayload) - if (libraryItem.media.update(updatePayload)) { + Logger.debug('[Scanner] Updating details with payload', updatePayload) + libraryItem.media.set(updatePayload) + if (libraryItem.media.changed()) { + Logger.debug(`[Scanner] Updating library item "${libraryItem.media.title}" keys`, libraryItem.media.changed()) hasUpdated = true } } if (hasUpdated) { - if (libraryItem.isPodcast && libraryItem.media.metadata.feedUrl) { + if (libraryItem.isPodcast && libraryItem.media.feedURL) { // Quick match all unmatched podcast episodes await this.quickMatchPodcastEpisodes(libraryItem, options) } - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) + await libraryItem.media.save() - // Check if any authors or series are now empty and should be removed - if (libraryItem.isBook) { - const authorsRemoved = existingAuthors.filter((aid) => !libraryItem.media.metadata.authors.find((au) => au.id === aid)) - const seriesRemoved = existingSeries.filter((sid) => !libraryItem.media.metadata.series.find((se) => se.id === sid)) + libraryItem.changed('updatedAt', true) + await libraryItem.save() - if (authorsRemoved.length) { - await apiRouterCtx.checkRemoveAuthorsWithNoBooks(authorsRemoved) - } - if (seriesRemoved.length) { - await apiRouterCtx.checkRemoveEmptySeries(seriesRemoved) - } - } + await libraryItem.saveMetadataFile() + + SocketAuthority.emitter('item_updated', libraryItem.toOldJSONExpanded()) } return { updated: hasUpdated, - libraryItem: libraryItem.toJSONExpanded() + libraryItem: libraryItem.toOldJSONExpanded() } } + /** + * + * @param {import('../models/LibraryItem')} libraryItem + * @param {*} matchData + * @param {QuickMatchOptions} options + * @returns {Map} - Update payload + */ quickMatchPodcastBuildUpdatePayload(libraryItem, matchData, options) { const updatePayload = {} - updatePayload.metadata = {} const matchDataTransformed = { title: matchData.title || null, @@ -158,7 +167,7 @@ class Scanner { for (const key in matchDataTransformed) { if (matchDataTransformed[key]) { if (key === 'genres') { - if (!libraryItem.media.metadata.genres.length || options.overrideDetails) { + if (!libraryItem.media.genres.length || options.overrideDetails) { var genresArray = [] if (Array.isArray(matchDataTransformed[key])) genresArray = [...matchDataTransformed[key]] else { @@ -169,46 +178,42 @@ class Scanner { .map((v) => v.trim()) .filter((v) => !!v) } - updatePayload.metadata[key] = genresArray + updatePayload[key] = genresArray } - } else if (libraryItem.media.metadata[key] !== matchDataTransformed[key] && (!libraryItem.media.metadata[key] || options.overrideDetails)) { - updatePayload.metadata[key] = matchDataTransformed[key] + } else if (libraryItem.media[key] !== matchDataTransformed[key] && (!libraryItem.media[key] || options.overrideDetails)) { + updatePayload[key] = matchDataTransformed[key] } } } - if (!Object.keys(updatePayload.metadata).length) { - delete updatePayload.metadata - } - return updatePayload } /** * - * @param {import('../objects/LibraryItem')} libraryItem + * @param {import('../routers/ApiRouter')} apiRouterCtx + * @param {import('../models/LibraryItem')} libraryItem * @param {*} matchData * @param {QuickMatchOptions} options - * @returns + * @returns {Promise<{updatePayload: Map, seriesIdsRemoved: string[], hasSeriesUpdates: boolean, authorIdsRemoved: string[], hasAuthorUpdates: boolean}>} */ - async quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) { + async quickMatchBookBuildUpdatePayload(apiRouterCtx, libraryItem, matchData, options) { // Update media metadata if not set OR overrideDetails flag const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'abridged', 'asin', 'isbn'] const updatePayload = {} - updatePayload.metadata = {} for (const key in matchData) { if (matchData[key] && detailKeysToUpdate.includes(key)) { if (key === 'narrator') { - if (!libraryItem.media.metadata.narratorName || options.overrideDetails) { - updatePayload.metadata.narrators = matchData[key] + if (!libraryItem.media.narrators?.length || options.overrideDetails) { + updatePayload.narrators = matchData[key] .split(',') .map((v) => v.trim()) .filter((v) => !!v) } } else if (key === 'genres') { - if (!libraryItem.media.metadata.genres.length || options.overrideDetails) { - var genresArray = [] + if (!libraryItem.media.genres.length || options.overrideDetails) { + let genresArray = [] if (Array.isArray(matchData[key])) genresArray = [...matchData[key]] else { // Genres should always be passed in as an array but just incase handle a string @@ -218,11 +223,11 @@ class Scanner { .map((v) => v.trim()) .filter((v) => !!v) } - updatePayload.metadata[key] = genresArray + updatePayload[key] = genresArray } } else if (key === 'tags') { if (!libraryItem.media.tags.length || options.overrideDetails) { - var tagsArray = [] + let tagsArray = [] if (Array.isArray(matchData[key])) tagsArray = [...matchData[key]] else tagsArray = matchData[key] @@ -231,94 +236,174 @@ class Scanner { .filter((v) => !!v) updatePayload[key] = tagsArray } - } else if (!libraryItem.media.metadata[key] || options.overrideDetails) { - updatePayload.metadata[key] = matchData[key] + } else if (!libraryItem.media[key] || options.overrideDetails) { + updatePayload[key] = matchData[key] } } } // Add or set author if not set - if (matchData.author && (!libraryItem.media.metadata.authorName || options.overrideDetails)) { + let hasAuthorUpdates = false + if (matchData.author && (!libraryItem.media.authorName || options.overrideDetails)) { if (!Array.isArray(matchData.author)) { matchData.author = matchData.author .split(',') .map((au) => au.trim()) .filter((au) => !!au) } - const authorPayload = [] + const authorIdsRemoved = [] for (const authorName of matchData.author) { - let author = await Database.authorModel.getByNameAndLibrary(authorName, libraryItem.libraryId) - if (!author) { - author = await Database.authorModel.create({ - name: authorName, - lastFirst: Database.authorModel.getLastFirst(authorName), - libraryId: libraryItem.libraryId - }) - SocketAuthority.emitter('author_added', author.toOldJSON()) - // Update filter data - Database.addAuthorToFilterData(libraryItem.libraryId, author.name, author.id) + const existingAuthor = libraryItem.media.authors.find((a) => a.name.toLowerCase() === authorName.toLowerCase()) + if (!existingAuthor) { + let author = await Database.authorModel.getByNameAndLibrary(authorName, libraryItem.libraryId) + if (!author) { + author = await Database.authorModel.create({ + name: authorName, + lastFirst: Database.authorModel.getLastFirst(authorName), + libraryId: libraryItem.libraryId + }) + SocketAuthority.emitter('author_added', author.toOldJSON()) + // Update filter data + Database.addAuthorToFilterData(libraryItem.libraryId, author.name, author.id) + + await Database.bookAuthorModel + .create({ + authorId: author.id, + bookId: libraryItem.media.id + }) + .then(() => { + Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Added author "${author.name}" to "${libraryItem.media.title}"`) + libraryItem.media.authors.push(author) + hasAuthorUpdates = true + }) + } + } + const authorsRemoved = libraryItem.media.authors.filter((a) => !matchData.author.find((ma) => ma.toLowerCase() === a.name.toLowerCase())) + if (authorsRemoved.length) { + for (const author of authorsRemoved) { + await Database.bookAuthorModel.destroy({ where: { authorId: author.id, bookId: libraryItem.media.id } }) + libraryItem.media.authors = libraryItem.media.authors.filter((a) => a.id !== author.id) + authorIdsRemoved.push(author.id) + Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Removed author "${author.name}" from "${libraryItem.media.title}"`) + } + hasAuthorUpdates = true } - authorPayload.push(author.toJSONMinimal()) } - updatePayload.metadata.authors = authorPayload + + // For all authors removed from book, check if they are empty now and should be removed + if (authorIdsRemoved.length) { + await apiRouterCtx.checkRemoveAuthorsWithNoBooks(authorIdsRemoved) + } } // Add or set series if not set - if (matchData.series && (!libraryItem.media.metadata.seriesName || options.overrideDetails)) { + let hasSeriesUpdates = false + if (matchData.series && (!libraryItem.media.seriesName || options.overrideDetails)) { if (!Array.isArray(matchData.series)) matchData.series = [{ series: matchData.series, sequence: matchData.sequence }] - const seriesPayload = [] + const seriesIdsRemoved = [] for (const seriesMatchItem of matchData.series) { - let seriesItem = await Database.seriesModel.getByNameAndLibrary(seriesMatchItem.series, libraryItem.libraryId) - if (!seriesItem) { - seriesItem = await Database.seriesModel.create({ - name: seriesMatchItem.series, - nameIgnorePrefix: getTitleIgnorePrefix(seriesMatchItem.series), - libraryId: libraryItem.libraryId + const existingSeries = libraryItem.media.series.find((s) => s.name.toLowerCase() === seriesMatchItem.series.toLowerCase()) + if (existingSeries) { + if (existingSeries.bookSeries.sequence !== seriesMatchItem.sequence) { + existingSeries.bookSeries.sequence = seriesMatchItem.sequence + await existingSeries.bookSeries.save() + Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Updated series sequence for "${existingSeries.name}" to ${seriesMatchItem.sequence} in "${libraryItem.media.title}"`) + hasSeriesUpdates = true + } + } else { + let seriesItem = await Database.seriesModel.getByNameAndLibrary(seriesMatchItem.series, libraryItem.libraryId) + if (!seriesItem) { + seriesItem = await Database.seriesModel.create({ + name: seriesMatchItem.series, + nameIgnorePrefix: getTitleIgnorePrefix(seriesMatchItem.series), + libraryId: libraryItem.libraryId + }) + // Update filter data + Database.addSeriesToFilterData(libraryItem.libraryId, seriesItem.name, seriesItem.id) + SocketAuthority.emitter('series_added', seriesItem.toOldJSON()) + } + const bookSeries = await Database.bookSeriesModel.create({ + seriesId: seriesItem.id, + bookId: libraryItem.media.id, + sequence: seriesMatchItem.sequence }) - // Update filter data - Database.addSeriesToFilterData(libraryItem.libraryId, seriesItem.name, seriesItem.id) - SocketAuthority.emitter('series_added', seriesItem.toOldJSON()) + seriesItem.bookSeries = bookSeries + libraryItem.media.series.push(seriesItem) + Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Added series "${seriesItem.name}" to "${libraryItem.media.title}"`) + hasSeriesUpdates = true + } + const seriesRemoved = libraryItem.media.series.filter((s) => !matchData.series.find((ms) => ms.series.toLowerCase() === s.name.toLowerCase())) + if (seriesRemoved.length) { + for (const series of seriesRemoved) { + await series.bookSeries.destroy() + libraryItem.media.series = libraryItem.media.series.filter((s) => s.id !== series.id) + seriesIdsRemoved.push(series.id) + Logger.info(`[Scanner] quickMatchBookBuildUpdatePayload: Removed series "${series.name}" from "${libraryItem.media.title}"`) + } + hasSeriesUpdates = true } - seriesPayload.push(seriesItem.toJSONMinimal(seriesMatchItem.sequence)) } - updatePayload.metadata.series = seriesPayload - } - if (!Object.keys(updatePayload.metadata).length) { - delete updatePayload.metadata + // For all series removed from book, check if it is empty now and should be removed + if (seriesIdsRemoved.length) { + await apiRouterCtx.checkRemoveEmptySeries(seriesIdsRemoved) + } } - return updatePayload + return { + updatePayload, + hasSeriesUpdates, + hasAuthorUpdates + } } + /** + * + * @param {import('../models/LibraryItem')} libraryItem + * @param {QuickMatchOptions} options + * @returns {Promise} - Number of episodes updated + */ async quickMatchPodcastEpisodes(libraryItem, options = {}) { - const episodesToQuickMatch = libraryItem.media.episodes.filter((ep) => !ep.enclosureUrl) // Only quick match episodes without enclosure - if (!episodesToQuickMatch.length) return false + /** @type {import('../models/PodcastEpisode')[]} */ + const episodesToQuickMatch = libraryItem.media.podcastEpisodes.filter((ep) => !ep.enclosureURL) // Only quick match episodes that are not already matched + if (!episodesToQuickMatch.length) return 0 - const feed = await getPodcastFeed(libraryItem.media.metadata.feedUrl) + const feed = await getPodcastFeed(libraryItem.media.feedURL) if (!feed) { - Logger.error(`[Scanner] quickMatchPodcastEpisodes: Unable to quick match episodes feed not found for "${libraryItem.media.metadata.feedUrl}"`) - return false + Logger.error(`[Scanner] quickMatchPodcastEpisodes: Unable to quick match episodes feed not found for "${libraryItem.media.feedURL}"`) + return 0 } let numEpisodesUpdated = 0 for (const episode of episodesToQuickMatch) { const episodeMatches = findMatchingEpisodesInFeed(feed, episode.title) - if (episodeMatches && episodeMatches.length) { - const wasUpdated = this.updateEpisodeWithMatch(libraryItem, episode, episodeMatches[0].episode, options) + if (episodeMatches?.length) { + const wasUpdated = await this.updateEpisodeWithMatch(episode, episodeMatches[0].episode, options) if (wasUpdated) numEpisodesUpdated++ } } + if (numEpisodesUpdated) { + Logger.info(`[Scanner] quickMatchPodcastEpisodes: Updated ${numEpisodesUpdated} episodes for "${libraryItem.media.title}"`) + } return numEpisodesUpdated } - updateEpisodeWithMatch(libraryItem, episode, episodeToMatch, options = {}) { + /** + * + * @param {import('../models/PodcastEpisode')} episode + * @param {import('../utils/podcastUtils').RssPodcastEpisode} episodeToMatch + * @param {QuickMatchOptions} options + * @returns {Promise} - true if episode was updated + */ + async updateEpisodeWithMatch(episode, episodeToMatch, options = {}) { Logger.debug(`[Scanner] quickMatchPodcastEpisodes: Found episode match for "${episode.title}" => ${episodeToMatch.title}`) const matchDataTransformed = { title: episodeToMatch.title || '', subtitle: episodeToMatch.subtitle || '', description: episodeToMatch.description || '', - enclosure: episodeToMatch.enclosure || null, + enclosureURL: episodeToMatch.enclosure?.url || null, + enclosureSize: episodeToMatch.enclosure?.length || null, + enclosureType: episodeToMatch.enclosure?.type || null, episode: episodeToMatch.episode || '', episodeType: episodeToMatch.episodeType || 'full', season: episodeToMatch.season || '', @@ -328,20 +413,19 @@ class Scanner { const updatePayload = {} for (const key in matchDataTransformed) { if (matchDataTransformed[key]) { - if (key === 'enclosure') { - if (!episode.enclosure || JSON.stringify(episode.enclosure) !== JSON.stringify(matchDataTransformed.enclosure)) { - updatePayload[key] = { - ...matchDataTransformed.enclosure - } - } - } else if (episode[key] !== matchDataTransformed[key] && (!episode[key] || options.overrideDetails)) { + if (episode[key] !== matchDataTransformed[key] && (!episode[key] || options.overrideDetails)) { updatePayload[key] = matchDataTransformed[key] } } } if (Object.keys(updatePayload).length) { - return libraryItem.media.updateEpisode(episode.id, updatePayload) + episode.set(updatePayload) + if (episode.changed()) { + Logger.debug(`[Scanner] quickMatchPodcastEpisodes: Updating episode "${episode.title}" keys`, episode.changed()) + await episode.save() + return true + } } return false } @@ -351,7 +435,7 @@ class Scanner { * * @param {import('../routers/ApiRouter')} apiRouterCtx * @param {import('../models/Library')} library - * @param {import('../objects/LibraryItem')[]} libraryItems + * @param {import('../models/LibraryItem')[]} libraryItems * @param {LibraryScan} libraryScan * @returns {Promise} false if scan canceled */ @@ -359,20 +443,20 @@ class Scanner { for (let i = 0; i < libraryItems.length; i++) { const libraryItem = libraryItems[i] - if (libraryItem.media.metadata.asin && library.settings.skipMatchingMediaWithAsin) { - Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title}" because it already has an ASIN (${i + 1} of ${libraryItems.length})`) + if (libraryItem.media.asin && library.settings.skipMatchingMediaWithAsin) { + Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.title}" because it already has an ASIN (${i + 1} of ${libraryItems.length})`) continue } - if (libraryItem.media.metadata.isbn && library.settings.skipMatchingMediaWithIsbn) { - Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title}" because it already has an ISBN (${i + 1} of ${libraryItems.length})`) + if (libraryItem.media.isbn && library.settings.skipMatchingMediaWithIsbn) { + Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.title}" because it already has an ISBN (${i + 1} of ${libraryItems.length})`) continue } - Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.metadata.title}" (${i + 1} of ${libraryItems.length})`) + Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.title}" (${i + 1} of ${libraryItems.length})`) const result = await this.quickMatchLibraryItem(apiRouterCtx, libraryItem, { provider: library.provider }) if (result.warning) { - Logger.warn(`[Scanner] matchLibraryItems: Match warning ${result.warning} for library item "${libraryItem.media.metadata.title}"`) + Logger.warn(`[Scanner] matchLibraryItems: Match warning ${result.warning} for library item "${libraryItem.media.title}"`) } else if (result.updated) { libraryScan.resultsUpdated++ } @@ -430,9 +514,8 @@ class Scanner { offset += limit hasMoreChunks = libraryItems.length === limit - let oldLibraryItems = libraryItems.map((li) => Database.libraryItemModel.getOldLibraryItem(li)) - const shouldContinue = await this.matchLibraryItemsChunk(apiRouterCtx, library, oldLibraryItems, libraryScan) + const shouldContinue = await this.matchLibraryItemsChunk(apiRouterCtx, library, libraryItems, libraryScan) if (!shouldContinue) { isCanceled = true break diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index d28c3b9d18..bc9892b2db 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -330,6 +330,12 @@ module.exports.findMatchingEpisodes = async (feedUrl, searchTitle) => { return this.findMatchingEpisodesInFeed(feed, searchTitle) } +/** + * + * @param {RssPodcast} feed + * @param {string} searchTitle + * @returns {Array<{ episode: RssPodcastEpisode, levenshtein: number }>} + */ module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => { searchTitle = searchTitle.toLowerCase().trim() if (!feed?.episodes) { From ac159bea72732855d54bda122f5e3563bb5445a8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 5 Jan 2025 12:12:20 -0600 Subject: [PATCH 022/118] Update unit test stub function --- test/server/controllers/LibraryItemController.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js index fb65cc4bcf..9972bd90fd 100644 --- a/test/server/controllers/LibraryItemController.test.js +++ b/test/server/controllers/LibraryItemController.test.js @@ -157,7 +157,7 @@ describe('LibraryItemController', () => { it('should remove authors and series with no books on library item update media', async () => { const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItem1Id) - + libraryItem.saveMetadataFile = sinon.stub() // Update library item 1 remove all authors and series const fakeReq = { query: {}, From 108eaba022e35a0f950d4acc33b77081883520e9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 5 Jan 2025 14:09:03 -0600 Subject: [PATCH 023/118] Migrate tools and collapse series. fix continue shelves. remove old objects --- server/controllers/ToolsController.js | 21 ++- server/managers/AbMergeManager.js | 14 +- server/managers/AudioMetadataManager.js | 14 +- server/models/Book.js | 124 -------------- server/models/LibraryItem.js | 136 +++------------ server/models/MediaProgress.js | 39 +---- server/models/Podcast.js | 60 ------- server/models/PodcastEpisode.js | 71 -------- server/models/User.js | 3 +- server/objects/LibraryItem.js | 153 ----------------- server/objects/entities/PodcastEpisode.js | 149 ---------------- server/objects/mediaTypes/Book.js | 138 --------------- server/objects/mediaTypes/Podcast.js | 161 ------------------ server/objects/metadata/BookMetadata.js | 154 ----------------- server/objects/metadata/PodcastMetadata.js | 105 ------------ server/scanner/Scanner.js | 2 +- server/utils/ffmpegHelpers.js | 33 ++-- server/utils/libraryHelpers.js | 71 +++++--- server/utils/migrations/dbMigration.js | 6 +- server/utils/queries/libraryFilters.js | 2 +- .../queries/libraryItemsPodcastFilters.js | 17 +- 21 files changed, 132 insertions(+), 1341 deletions(-) delete mode 100644 server/objects/LibraryItem.js delete mode 100644 server/objects/entities/PodcastEpisode.js delete mode 100644 server/objects/mediaTypes/Book.js delete mode 100644 server/objects/mediaTypes/Podcast.js delete mode 100644 server/objects/metadata/BookMetadata.js delete mode 100644 server/objects/metadata/PodcastMetadata.js diff --git a/server/controllers/ToolsController.js b/server/controllers/ToolsController.js index 8aa9f83267..94122b4611 100644 --- a/server/controllers/ToolsController.js +++ b/server/controllers/ToolsController.js @@ -7,6 +7,11 @@ const Database = require('../Database') * @property {import('../models/User')} user * * @typedef {Request & RequestUserObject} RequestWithUser + * + * @typedef RequestEntityObject + * @property {import('../models/LibraryItem')} libraryItem + * + * @typedef {RequestWithUser & RequestEntityObject} RequestWithLibraryItem */ class ToolsController { @@ -18,7 +23,7 @@ class ToolsController { * * @this import('../routers/ApiRouter') * - * @param {RequestWithUser} req + * @param {RequestWithLibraryItem} req * @param {Response} res */ async encodeM4b(req, res) { @@ -27,12 +32,12 @@ class ToolsController { return res.status(404).send('Audiobook not found') } - if (req.libraryItem.mediaType !== 'book') { + if (!req.libraryItem.isBook) { Logger.error(`[MiscController] encodeM4b: Invalid library item ${req.params.id}: not a book`) return res.status(400).send('Invalid library item: not a book') } - if (req.libraryItem.media.tracks.length <= 0) { + if (!req.libraryItem.hasAudioTracks) { Logger.error(`[MiscController] encodeM4b: Invalid audiobook ${req.params.id}: no audio tracks`) return res.status(400).send('Invalid audiobook: no audio tracks') } @@ -72,11 +77,11 @@ class ToolsController { * * @this import('../routers/ApiRouter') * - * @param {RequestWithUser} req + * @param {RequestWithLibraryItem} req * @param {Response} res */ async embedAudioFileMetadata(req, res) { - if (req.libraryItem.isMissing || !req.libraryItem.hasAudioFiles || !req.libraryItem.isBook) { + if (req.libraryItem.isMissing || !req.libraryItem.hasAudioTracks || !req.libraryItem.isBook) { Logger.error(`[ToolsController] Invalid library item`) return res.sendStatus(400) } @@ -111,7 +116,7 @@ class ToolsController { const libraryItems = [] for (const libraryItemId of libraryItemIds) { - const libraryItem = await Database.libraryItemModel.getOldById(libraryItemId) + const libraryItem = await Database.libraryItemModel.getExpandedById(libraryItemId) if (!libraryItem) { Logger.error(`[ToolsController] Batch embed metadata library item (${libraryItemId}) not found`) return res.sendStatus(404) @@ -123,7 +128,7 @@ class ToolsController { return res.sendStatus(403) } - if (libraryItem.isMissing || !libraryItem.hasAudioFiles || !libraryItem.isBook) { + if (libraryItem.isMissing || !libraryItem.hasAudioTracks || !libraryItem.isBook) { Logger.error(`[ToolsController] Batch embed invalid library item (${libraryItemId})`) return res.sendStatus(400) } @@ -157,7 +162,7 @@ class ToolsController { } if (req.params.id) { - const item = await Database.libraryItemModel.getOldById(req.params.id) + const item = await Database.libraryItemModel.getExpandedById(req.params.id) if (!item?.media) return res.sendStatus(404) // Check user can access this library item diff --git a/server/managers/AbMergeManager.js b/server/managers/AbMergeManager.js index ea70d73c7e..f6a561607d 100644 --- a/server/managers/AbMergeManager.js +++ b/server/managers/AbMergeManager.js @@ -51,7 +51,7 @@ class AbMergeManager { /** * * @param {string} userId - * @param {import('../objects/LibraryItem')} libraryItem + * @param {import('../models/LibraryItem')} libraryItem * @param {AbMergeEncodeOptions} [options={}] */ async startAudiobookMerge(userId, libraryItem, options = {}) { @@ -67,7 +67,7 @@ class AbMergeManager { libraryItemId: libraryItem.id, libraryItemDir, userId, - originalTrackPaths: libraryItem.media.tracks.map((t) => t.metadata.path), + originalTrackPaths: libraryItem.media.includedAudioFiles.map((t) => t.metadata.path), inos: libraryItem.media.includedAudioFiles.map((f) => f.ino), tempFilepath, targetFilename, @@ -86,9 +86,9 @@ class AbMergeManager { key: 'MessageTaskEncodingM4b' } const taskDescriptionString = { - text: `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.`, + text: `Encoding audiobook "${libraryItem.media.title}" into a single m4b file.`, key: 'MessageTaskEncodingM4bDescription', - subs: [libraryItem.media.metadata.title] + subs: [libraryItem.media.title] } task.setData('encode-m4b', taskTitleString, taskDescriptionString, false, taskData) TaskManager.addTask(task) @@ -103,7 +103,7 @@ class AbMergeManager { /** * - * @param {import('../objects/LibraryItem')} libraryItem + * @param {import('../models/LibraryItem')} libraryItem * @param {Task} task * @param {AbMergeEncodeOptions} encodingOptions */ @@ -141,7 +141,7 @@ class AbMergeManager { const embedFraction = 1 - encodeFraction try { const trackProgressMonitor = new TrackProgressMonitor( - libraryItem.media.tracks.map((t) => t.duration), + libraryItem.media.includedAudioFiles.map((t) => t.duration), (trackIndex) => SocketAuthority.adminEmitter('track_started', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] }), (trackIndex, progressInTrack, taskProgress) => { SocketAuthority.adminEmitter('track_progress', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex], progress: progressInTrack }) @@ -150,7 +150,7 @@ class AbMergeManager { (trackIndex) => SocketAuthority.adminEmitter('track_finished', { libraryItemId: libraryItem.id, ino: task.data.inos[trackIndex] }) ) task.data.ffmpeg = new Ffmpeg() - await ffmpegHelpers.mergeAudioFiles(libraryItem.media.tracks, task.data.duration, task.data.itemCachePath, task.data.tempFilepath, encodingOptions, (progress) => trackProgressMonitor.update(progress), task.data.ffmpeg) + await ffmpegHelpers.mergeAudioFiles(libraryItem.media.includedAudioFiles, task.data.duration, task.data.itemCachePath, task.data.tempFilepath, encodingOptions, (progress) => trackProgressMonitor.update(progress), task.data.ffmpeg) delete task.data.ffmpeg trackProgressMonitor.finish() } catch (error) { diff --git a/server/managers/AudioMetadataManager.js b/server/managers/AudioMetadataManager.js index 36aecb97de..7471a1ca0a 100644 --- a/server/managers/AudioMetadataManager.js +++ b/server/managers/AudioMetadataManager.js @@ -40,14 +40,14 @@ class AudioMetadataMangaer { * @returns */ getMetadataObjectForApi(libraryItem) { - return ffmpegHelpers.getFFMetadataObject(libraryItem.toOldJSONExpanded(), libraryItem.media.includedAudioFiles.length) + return ffmpegHelpers.getFFMetadataObject(libraryItem, libraryItem.media.includedAudioFiles.length) } /** * * @param {string} userId - * @param {*} libraryItems - * @param {*} options + * @param {import('../models/LibraryItem')[]} libraryItems + * @param {UpdateMetadataOptions} options */ handleBatchEmbed(userId, libraryItems, options = {}) { libraryItems.forEach((li) => { @@ -58,7 +58,7 @@ class AudioMetadataMangaer { /** * * @param {string} userId - * @param {import('../objects/LibraryItem')} libraryItem + * @param {import('../models/LibraryItem')} libraryItem * @param {UpdateMetadataOptions} [options={}] */ async updateMetadataForItem(userId, libraryItem, options = {}) { @@ -108,14 +108,14 @@ class AudioMetadataMangaer { key: 'MessageTaskEmbeddingMetadata' } const taskDescriptionString = { - text: `Embedding metadata in audiobook "${libraryItem.media.metadata.title}".`, + text: `Embedding metadata in audiobook "${libraryItem.media.title}".`, key: 'MessageTaskEmbeddingMetadataDescription', - subs: [libraryItem.media.metadata.title] + subs: [libraryItem.media.title] } task.setData('embed-metadata', taskTitleString, taskDescriptionString, false, taskData) if (this.tasksRunning.length >= this.MAX_CONCURRENT_TASKS) { - Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.metadata.title}"`) + Logger.info(`[AudioMetadataManager] Queueing embed metadata for audiobook "${libraryItem.media.title}"`) SocketAuthority.adminEmitter('metadata_embed_queue_update', { libraryItemId: libraryItem.id, queued: true diff --git a/server/models/Book.js b/server/models/Book.js index dff79da242..5a4eee54af 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -130,130 +130,6 @@ class Book extends Model { this.series } - static getOldBook(libraryItemExpanded) { - const bookExpanded = libraryItemExpanded.media - let authors = [] - if (bookExpanded.authors?.length) { - authors = bookExpanded.authors.map((au) => { - return { - id: au.id, - name: au.name - } - }) - } else if (bookExpanded.bookAuthors?.length) { - authors = bookExpanded.bookAuthors - .map((ba) => { - if (ba.author) { - return { - id: ba.author.id, - name: ba.author.name - } - } else { - Logger.error(`[Book] Invalid bookExpanded bookAuthors: no author`, ba) - return null - } - }) - .filter((a) => a) - } - - let series = [] - if (bookExpanded.series?.length) { - series = bookExpanded.series.map((se) => { - return { - id: se.id, - name: se.name, - sequence: se.bookSeries.sequence - } - }) - } else if (bookExpanded.bookSeries?.length) { - series = bookExpanded.bookSeries - .map((bs) => { - if (bs.series) { - return { - id: bs.series.id, - name: bs.series.name, - sequence: bs.sequence - } - } else { - Logger.error(`[Book] Invalid bookExpanded bookSeries: no series`, bs) - return null - } - }) - .filter((s) => s) - } - - return { - id: bookExpanded.id, - libraryItemId: libraryItemExpanded.id, - coverPath: bookExpanded.coverPath, - tags: bookExpanded.tags, - audioFiles: bookExpanded.audioFiles, - chapters: bookExpanded.chapters, - ebookFile: bookExpanded.ebookFile, - metadata: { - title: bookExpanded.title, - subtitle: bookExpanded.subtitle, - authors: authors, - narrators: bookExpanded.narrators, - series: series, - genres: bookExpanded.genres, - publishedYear: bookExpanded.publishedYear, - publishedDate: bookExpanded.publishedDate, - publisher: bookExpanded.publisher, - description: bookExpanded.description, - isbn: bookExpanded.isbn, - asin: bookExpanded.asin, - language: bookExpanded.language, - explicit: bookExpanded.explicit, - abridged: bookExpanded.abridged - } - } - } - - /** - * @param {object} oldBook - * @returns {boolean} true if updated - */ - static saveFromOld(oldBook) { - const book = this.getFromOld(oldBook) - return this.update(book, { - where: { - id: book.id - } - }) - .then((result) => result[0] > 0) - .catch((error) => { - Logger.error(`[Book] Failed to save book ${book.id}`, error) - return false - }) - } - - static getFromOld(oldBook) { - return { - id: oldBook.id, - title: oldBook.metadata.title, - titleIgnorePrefix: oldBook.metadata.titleIgnorePrefix, - subtitle: oldBook.metadata.subtitle, - publishedYear: oldBook.metadata.publishedYear, - publishedDate: oldBook.metadata.publishedDate, - publisher: oldBook.metadata.publisher, - description: oldBook.metadata.description, - isbn: oldBook.metadata.isbn, - asin: oldBook.metadata.asin, - language: oldBook.metadata.language, - explicit: !!oldBook.metadata.explicit, - abridged: !!oldBook.metadata.abridged, - narrators: oldBook.metadata.narrators, - ebookFile: oldBook.ebookFile?.toJSON() || null, - coverPath: oldBook.coverPath, - duration: oldBook.duration, - audioFiles: oldBook.audioFiles?.map((af) => af.toJSON()) || [], - chapters: oldBook.chapters, - tags: oldBook.tags, - genres: oldBook.metadata.genres - } - } - /** * Initialize model * @param {import('../Database').sequelize} sequelize diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index d19816a3d8..4035630d3f 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -1,11 +1,8 @@ -const util = require('util') const Path = require('path') const { DataTypes, Model } = require('sequelize') const fsExtra = require('../libs/fsExtra') const Logger = require('../Logger') -const oldLibraryItem = require('../objects/LibraryItem') const libraryFilters = require('../utils/queries/libraryFilters') -const { areEquivalent } = require('../utils/index') const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils') const LibraryFile = require('../objects/files/LibraryFile') const Book = require('./Book') @@ -122,44 +119,6 @@ class LibraryItem extends Model { }) } - /** - * Convert an expanded LibraryItem into an old library item - * - * @param {Model} libraryItemExpanded - * @returns {oldLibraryItem} - */ - static getOldLibraryItem(libraryItemExpanded) { - let media = null - if (libraryItemExpanded.mediaType === 'book') { - media = this.sequelize.models.book.getOldBook(libraryItemExpanded) - } else if (libraryItemExpanded.mediaType === 'podcast') { - media = this.sequelize.models.podcast.getOldPodcast(libraryItemExpanded) - } - - return new oldLibraryItem({ - id: libraryItemExpanded.id, - ino: libraryItemExpanded.ino, - oldLibraryItemId: libraryItemExpanded.extraData?.oldLibraryItemId || null, - libraryId: libraryItemExpanded.libraryId, - folderId: libraryItemExpanded.libraryFolderId, - path: libraryItemExpanded.path, - relPath: libraryItemExpanded.relPath, - isFile: libraryItemExpanded.isFile, - mtimeMs: libraryItemExpanded.mtime?.valueOf(), - ctimeMs: libraryItemExpanded.ctime?.valueOf(), - birthtimeMs: libraryItemExpanded.birthtime?.valueOf(), - addedAt: libraryItemExpanded.createdAt.valueOf(), - updatedAt: libraryItemExpanded.updatedAt.valueOf(), - lastScan: libraryItemExpanded.lastScan?.valueOf(), - scanVersion: libraryItemExpanded.lastScanVersion, - isMissing: !!libraryItemExpanded.isMissing, - isInvalid: !!libraryItemExpanded.isInvalid, - mediaType: libraryItemExpanded.mediaType, - media, - libraryFiles: libraryItemExpanded.libraryFiles - }) - } - /** * Remove library item by id * @@ -318,61 +277,12 @@ class LibraryItem extends Model { return libraryItem } - /** - * Get old library item by id - * @param {string} libraryItemId - * @returns {oldLibraryItem} - */ - static async getOldById(libraryItemId) { - if (!libraryItemId) return null - - const libraryItem = await this.findByPk(libraryItemId) - if (!libraryItem) { - Logger.error(`[LibraryItem] Library item not found with id "${libraryItemId}"`) - return null - } - - if (libraryItem.mediaType === 'podcast') { - libraryItem.media = await libraryItem.getMedia({ - include: [ - { - model: this.sequelize.models.podcastEpisode - } - ] - }) - } else { - libraryItem.media = await libraryItem.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'] - ] - }) - } - - if (!libraryItem.media) return null - return this.getOldLibraryItem(libraryItem) - } - /** * Get library items using filter and sort * @param {import('./Library')} library * @param {import('./User')} user * @param {object} options - * @returns {{ libraryItems:oldLibraryItem[], count:number }} + * @returns {{ libraryItems:Object[], count:number }} */ static async getByFilterAndSort(library, user, options) { let start = Date.now() @@ -426,17 +336,19 @@ class LibraryItem extends Model { // "Continue Listening" shelf const itemsInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, user, include, limit, false) if (itemsInProgressPayload.items.length) { - const ebookOnlyItemsInProgress = itemsInProgressPayload.items.filter((li) => li.media.isEBookOnly) - const audioOnlyItemsInProgress = itemsInProgressPayload.items.filter((li) => !li.media.isEBookOnly) + const ebookOnlyItemsInProgress = itemsInProgressPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks) + const audioItemsInProgress = itemsInProgressPayload.items.filter((li) => li.media.numTracks) - shelves.push({ - id: 'continue-listening', - label: 'Continue Listening', - labelStringKey: 'LabelContinueListening', - type: library.isPodcast ? 'episode' : 'book', - entities: audioOnlyItemsInProgress, - total: itemsInProgressPayload.count - }) + if (audioItemsInProgress.length) { + shelves.push({ + id: 'continue-listening', + label: 'Continue Listening', + labelStringKey: 'LabelContinueListening', + type: library.isPodcast ? 'episode' : 'book', + entities: audioItemsInProgress, + total: itemsInProgressPayload.count + }) + } if (ebookOnlyItemsInProgress.length) { // "Continue Reading" shelf @@ -535,17 +447,19 @@ class LibraryItem extends Model { // "Listen Again" shelf const mediaFinishedPayload = await libraryFilters.getMediaFinished(library, user, include, limit) if (mediaFinishedPayload.items.length) { - const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.isEBookOnly) - const audioOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => !li.media.isEBookOnly) + const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.ebookFormat && !li.media.numTracks) + const audioItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.numTracks) - shelves.push({ - id: 'listen-again', - label: 'Listen Again', - labelStringKey: 'LabelListenAgain', - type: library.isPodcast ? 'episode' : 'book', - entities: audioOnlyItemsInProgress, - total: mediaFinishedPayload.count - }) + if (audioItemsInProgress.length) { + shelves.push({ + id: 'listen-again', + label: 'Listen Again', + labelStringKey: 'LabelListenAgain', + type: library.isPodcast ? 'episode' : 'book', + entities: audioItemsInProgress, + total: mediaFinishedPayload.count + }) + } // "Read Again" shelf if (ebookOnlyItemsInProgress.length) { diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js index 80204ef5cc..bb8276826d 100644 --- a/server/models/MediaProgress.js +++ b/server/models/MediaProgress.js @@ -36,33 +36,6 @@ class MediaProgress extends Model { this.createdAt } - static upsertFromOld(oldMediaProgress) { - const mediaProgress = this.getFromOld(oldMediaProgress) - return this.upsert(mediaProgress) - } - - static getFromOld(oldMediaProgress) { - return { - id: oldMediaProgress.id, - userId: oldMediaProgress.userId, - mediaItemId: oldMediaProgress.mediaItemId, - mediaItemType: oldMediaProgress.mediaItemType, - duration: oldMediaProgress.duration, - currentTime: oldMediaProgress.currentTime, - ebookLocation: oldMediaProgress.ebookLocation || null, - ebookProgress: oldMediaProgress.ebookProgress || null, - isFinished: !!oldMediaProgress.isFinished, - hideFromContinueListening: !!oldMediaProgress.hideFromContinueListening, - finishedAt: oldMediaProgress.finishedAt, - createdAt: oldMediaProgress.startedAt || oldMediaProgress.lastUpdate, - updatedAt: oldMediaProgress.lastUpdate, - extraData: { - libraryItemId: oldMediaProgress.libraryItemId, - progress: oldMediaProgress.progress - } - } - } - static removeById(mediaProgressId) { return this.destroy({ where: { @@ -71,12 +44,6 @@ class MediaProgress extends Model { }) } - getMediaItem(options) { - if (!this.mediaItemType) return Promise.resolve(null) - const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}` - return this[mixinMethodName](options) - } - /** * Initialize model * @@ -162,6 +129,12 @@ class MediaProgress extends Model { MediaProgress.belongsTo(user) } + getMediaItem(options) { + if (!this.mediaItemType) return Promise.resolve(null) + const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}` + return this[mixinMethodName](options) + } + getOldMediaProgress() { const isPodcastEpisode = this.mediaItemType === 'podcastEpisode' diff --git a/server/models/Podcast.js b/server/models/Podcast.js index aa7afbacb3..084911bf4f 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -66,66 +66,6 @@ class Podcast extends Model { this.podcastEpisodes } - static getOldPodcast(libraryItemExpanded) { - const podcastExpanded = libraryItemExpanded.media - const podcastEpisodes = podcastExpanded.podcastEpisodes?.map((ep) => ep.getOldPodcastEpisode(libraryItemExpanded.id).toJSON()).sort((a, b) => a.index - b.index) - return { - id: podcastExpanded.id, - libraryItemId: libraryItemExpanded.id, - metadata: { - title: podcastExpanded.title, - author: podcastExpanded.author, - description: podcastExpanded.description, - releaseDate: podcastExpanded.releaseDate, - genres: podcastExpanded.genres, - feedUrl: podcastExpanded.feedURL, - imageUrl: podcastExpanded.imageURL, - itunesPageUrl: podcastExpanded.itunesPageURL, - itunesId: podcastExpanded.itunesId, - itunesArtistId: podcastExpanded.itunesArtistId, - explicit: podcastExpanded.explicit, - language: podcastExpanded.language, - type: podcastExpanded.podcastType - }, - coverPath: podcastExpanded.coverPath, - tags: podcastExpanded.tags, - episodes: podcastEpisodes || [], - autoDownloadEpisodes: podcastExpanded.autoDownloadEpisodes, - autoDownloadSchedule: podcastExpanded.autoDownloadSchedule, - lastEpisodeCheck: podcastExpanded.lastEpisodeCheck?.valueOf() || null, - maxEpisodesToKeep: podcastExpanded.maxEpisodesToKeep, - maxNewEpisodesToDownload: podcastExpanded.maxNewEpisodesToDownload - } - } - - static getFromOld(oldPodcast) { - const oldPodcastMetadata = oldPodcast.metadata - return { - id: oldPodcast.id, - title: oldPodcastMetadata.title, - titleIgnorePrefix: oldPodcastMetadata.titleIgnorePrefix, - author: oldPodcastMetadata.author, - releaseDate: oldPodcastMetadata.releaseDate, - feedURL: oldPodcastMetadata.feedUrl, - imageURL: oldPodcastMetadata.imageUrl, - description: oldPodcastMetadata.description, - itunesPageURL: oldPodcastMetadata.itunesPageUrl, - itunesId: oldPodcastMetadata.itunesId, - itunesArtistId: oldPodcastMetadata.itunesArtistId, - language: oldPodcastMetadata.language, - podcastType: oldPodcastMetadata.type, - explicit: !!oldPodcastMetadata.explicit, - autoDownloadEpisodes: !!oldPodcast.autoDownloadEpisodes, - autoDownloadSchedule: oldPodcast.autoDownloadSchedule, - lastEpisodeCheck: oldPodcast.lastEpisodeCheck, - maxEpisodesToKeep: oldPodcast.maxEpisodesToKeep, - maxNewEpisodesToDownload: oldPodcast.maxNewEpisodesToDownload, - coverPath: oldPodcast.coverPath, - tags: oldPodcast.tags, - genres: oldPodcastMetadata.genres - } - } - /** * Payload from the /api/podcasts POST endpoint * diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index c1e66fdfa3..c6a1b9fa13 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -1,5 +1,4 @@ const { DataTypes, Model } = require('sequelize') -const oldPodcastEpisode = require('../objects/entities/PodcastEpisode') /** * @typedef ChapterObject @@ -53,40 +52,6 @@ class PodcastEpisode extends Model { this.updatedAt } - static createFromOld(oldEpisode) { - const podcastEpisode = this.getFromOld(oldEpisode) - return this.create(podcastEpisode) - } - - static getFromOld(oldEpisode) { - const extraData = {} - if (oldEpisode.oldEpisodeId) { - extraData.oldEpisodeId = oldEpisode.oldEpisodeId - } - if (oldEpisode.guid) { - extraData.guid = oldEpisode.guid - } - return { - id: oldEpisode.id, - index: oldEpisode.index, - season: oldEpisode.season, - episode: oldEpisode.episode, - episodeType: oldEpisode.episodeType, - title: oldEpisode.title, - subtitle: oldEpisode.subtitle, - description: oldEpisode.description, - pubDate: oldEpisode.pubDate, - enclosureURL: oldEpisode.enclosure?.url || null, - enclosureSize: oldEpisode.enclosure?.length || null, - enclosureType: oldEpisode.enclosure?.type || null, - publishedAt: oldEpisode.publishedAt, - podcastId: oldEpisode.podcastId, - audioFile: oldEpisode.audioFile?.toJSON() || null, - chapters: oldEpisode.chapters, - extraData - } - } - /** * * @param {import('../utils/podcastUtils').RssPodcastEpisode} rssPodcastEpisode @@ -208,42 +173,6 @@ class PodcastEpisode extends Model { return track } - /** - * @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`) diff --git a/server/models/User.js b/server/models/User.js index b2a4fd2bcc..56d6ba0ea0 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -563,9 +563,8 @@ class User extends Model { /** * Check user can access library item - * TODO: Currently supports both old and new library item models * - * @param {import('../objects/LibraryItem')|import('./LibraryItem')} libraryItem + * @param {import('./LibraryItem')} libraryItem * @returns {boolean} */ checkCanAccessLibraryItem(libraryItem) { diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js deleted file mode 100644 index 3cf89b104f..0000000000 --- a/server/objects/LibraryItem.js +++ /dev/null @@ -1,153 +0,0 @@ -const fs = require('../libs/fsExtra') -const Path = require('path') -const Logger = require('../Logger') -const LibraryFile = require('./files/LibraryFile') -const Book = require('./mediaTypes/Book') -const Podcast = require('./mediaTypes/Podcast') -const { areEquivalent, copyValue } = require('../utils/index') -const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils') - -class LibraryItem { - constructor(libraryItem = null) { - this.id = null - this.ino = null // Inode - this.oldLibraryItemId = null - - this.libraryId = null - this.folderId = null - - this.path = null - this.relPath = null - this.isFile = false - this.mtimeMs = null - this.ctimeMs = null - this.birthtimeMs = null - this.addedAt = null - this.updatedAt = null - this.lastScan = null - this.scanVersion = null - - // Was scanned and no longer exists - this.isMissing = false - // Was scanned and no longer has media files - this.isInvalid = false - - this.mediaType = null - this.media = null - - /** @type {LibraryFile[]} */ - this.libraryFiles = [] - - if (libraryItem) { - this.construct(libraryItem) - } - - // Temporary attributes - this.isSavingMetadata = false - } - - construct(libraryItem) { - this.id = libraryItem.id - this.ino = libraryItem.ino || null - this.oldLibraryItemId = libraryItem.oldLibraryItemId - this.libraryId = libraryItem.libraryId - this.folderId = libraryItem.folderId - this.path = libraryItem.path - this.relPath = libraryItem.relPath - this.isFile = !!libraryItem.isFile - this.mtimeMs = libraryItem.mtimeMs || 0 - this.ctimeMs = libraryItem.ctimeMs || 0 - this.birthtimeMs = libraryItem.birthtimeMs || 0 - this.addedAt = libraryItem.addedAt - this.updatedAt = libraryItem.updatedAt || this.addedAt - this.lastScan = libraryItem.lastScan || null - this.scanVersion = libraryItem.scanVersion || null - - this.isMissing = !!libraryItem.isMissing - this.isInvalid = !!libraryItem.isInvalid - - this.mediaType = libraryItem.mediaType - if (this.mediaType === 'book') { - this.media = new Book(libraryItem.media) - } else if (this.mediaType === 'podcast') { - this.media = new Podcast(libraryItem.media) - } - this.media.libraryItemId = this.id - - this.libraryFiles = libraryItem.libraryFiles.map((f) => new LibraryFile(f)) - - // Migration for v2.2.23 to set ebook library files as supplementary - if (this.isBook && this.media.ebookFile) { - for (const libraryFile of this.libraryFiles) { - if (libraryFile.isEBookFile && libraryFile.isSupplementary === null) { - libraryFile.isSupplementary = this.media.ebookFile.ino !== libraryFile.ino - } - } - } - } - - toJSON() { - return { - id: this.id, - ino: this.ino, - oldLibraryItemId: this.oldLibraryItemId, - libraryId: this.libraryId, - folderId: this.folderId, - path: this.path, - relPath: this.relPath, - isFile: this.isFile, - mtimeMs: this.mtimeMs, - ctimeMs: this.ctimeMs, - birthtimeMs: this.birthtimeMs, - addedAt: this.addedAt, - updatedAt: this.updatedAt, - lastScan: this.lastScan, - scanVersion: this.scanVersion, - isMissing: !!this.isMissing, - isInvalid: !!this.isInvalid, - mediaType: this.mediaType, - media: this.media.toJSON(), - libraryFiles: this.libraryFiles.map((f) => f.toJSON()) - } - } - - toJSONMinified() { - return { - id: this.id, - ino: this.ino, - oldLibraryItemId: this.oldLibraryItemId, - libraryId: this.libraryId, - folderId: this.folderId, - path: this.path, - relPath: this.relPath, - isFile: this.isFile, - mtimeMs: this.mtimeMs, - ctimeMs: this.ctimeMs, - birthtimeMs: this.birthtimeMs, - addedAt: this.addedAt, - updatedAt: this.updatedAt, - isMissing: !!this.isMissing, - isInvalid: !!this.isInvalid, - mediaType: this.mediaType, - media: this.media.toJSONMinified(), - numFiles: this.libraryFiles.length, - size: this.size - } - } - - get isPodcast() { - return this.mediaType === 'podcast' - } - get isBook() { - return this.mediaType === 'book' - } - get size() { - let total = 0 - this.libraryFiles.forEach((lf) => (total += lf.metadata.size)) - return total - } - get hasAudioFiles() { - return this.libraryFiles.some((lf) => lf.fileType === 'audio') - } -} -module.exports = LibraryItem diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js deleted file mode 100644 index 6a3f4cf6d0..0000000000 --- a/server/objects/entities/PodcastEpisode.js +++ /dev/null @@ -1,149 +0,0 @@ -const { areEquivalent, copyValue } = require('../../utils/index') -const AudioFile = require('../files/AudioFile') -const AudioTrack = require('../files/AudioTrack') - -class PodcastEpisode { - constructor(episode) { - this.libraryItemId = null - this.podcastId = null - this.id = null - this.oldEpisodeId = null - this.index = null - - this.season = null - this.episode = null - this.episodeType = null - this.title = null - this.subtitle = null - this.description = null - this.enclosure = null - this.guid = null - this.pubDate = null - this.chapters = [] - - this.audioFile = null - this.publishedAt = null - this.addedAt = null - this.updatedAt = null - - if (episode) { - this.construct(episode) - } - } - - construct(episode) { - this.libraryItemId = episode.libraryItemId - this.podcastId = episode.podcastId - this.id = episode.id - this.oldEpisodeId = episode.oldEpisodeId - this.index = episode.index - this.season = episode.season - this.episode = episode.episode - this.episodeType = episode.episodeType - this.title = episode.title - this.subtitle = episode.subtitle - this.description = episode.description - this.enclosure = episode.enclosure ? { ...episode.enclosure } : null - this.guid = episode.guid || null - this.pubDate = episode.pubDate - this.chapters = episode.chapters?.map((ch) => ({ ...ch })) || [] - this.audioFile = episode.audioFile ? new AudioFile(episode.audioFile) : null - this.publishedAt = episode.publishedAt - this.addedAt = episode.addedAt - this.updatedAt = episode.updatedAt - - if (this.audioFile) { - this.audioFile.index = 1 // Only 1 audio file per episode - } - } - - toJSON() { - return { - libraryItemId: this.libraryItemId, - podcastId: this.podcastId, - id: this.id, - oldEpisodeId: this.oldEpisodeId, - index: this.index, - season: this.season, - episode: this.episode, - episodeType: this.episodeType, - title: this.title, - subtitle: this.subtitle, - description: this.description, - enclosure: this.enclosure ? { ...this.enclosure } : null, - guid: this.guid, - pubDate: this.pubDate, - chapters: this.chapters.map((ch) => ({ ...ch })), - audioFile: this.audioFile?.toJSON() || null, - publishedAt: this.publishedAt, - addedAt: this.addedAt, - updatedAt: this.updatedAt - } - } - - toJSONExpanded() { - return { - libraryItemId: this.libraryItemId, - podcastId: this.podcastId, - id: this.id, - oldEpisodeId: this.oldEpisodeId, - index: this.index, - season: this.season, - episode: this.episode, - episodeType: this.episodeType, - title: this.title, - subtitle: this.subtitle, - description: this.description, - enclosure: this.enclosure ? { ...this.enclosure } : null, - guid: this.guid, - pubDate: this.pubDate, - chapters: this.chapters.map((ch) => ({ ...ch })), - audioFile: this.audioFile?.toJSON() || null, - audioTrack: this.audioTrack?.toJSON() || null, - publishedAt: this.publishedAt, - addedAt: this.addedAt, - updatedAt: this.updatedAt, - duration: this.duration, - size: this.size - } - } - - get audioTrack() { - if (!this.audioFile) return null - const audioTrack = new AudioTrack() - audioTrack.setData(this.libraryItemId, this.audioFile, 0) - return audioTrack - } - get tracks() { - return [this.audioTrack] - } - get duration() { - return this.audioFile?.duration || 0 - } - get size() { - return this.audioFile?.metadata.size || 0 - } - get enclosureUrl() { - return this.enclosure?.url || null - } - - update(payload) { - let hasUpdates = false - for (const key in this.toJSON()) { - let newValue = payload[key] - if (newValue === '') newValue = null - let existingValue = this[key] - if (existingValue === '') existingValue = null - - if (newValue != undefined && !areEquivalent(newValue, existingValue)) { - this[key] = copyValue(newValue) - hasUpdates = true - } - } - if (hasUpdates) { - this.updatedAt = Date.now() - } - return hasUpdates - } -} -module.exports = PodcastEpisode diff --git a/server/objects/mediaTypes/Book.js b/server/objects/mediaTypes/Book.js deleted file mode 100644 index b270e0e710..0000000000 --- a/server/objects/mediaTypes/Book.js +++ /dev/null @@ -1,138 +0,0 @@ -const Logger = require('../../Logger') -const BookMetadata = require('../metadata/BookMetadata') -const { areEquivalent, copyValue } = require('../../utils/index') -const { filePathToPOSIX } = require('../../utils/fileUtils') -const AudioFile = require('../files/AudioFile') -const AudioTrack = require('../files/AudioTrack') -const EBookFile = require('../files/EBookFile') - -class Book { - constructor(book) { - this.id = null - this.libraryItemId = null - this.metadata = null - - this.coverPath = null - this.tags = [] - - this.audioFiles = [] - this.chapters = [] - this.ebookFile = null - - this.lastCoverSearch = null - this.lastCoverSearchQuery = null - - if (book) { - this.construct(book) - } - } - - construct(book) { - this.id = book.id - this.libraryItemId = book.libraryItemId - 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.ebookFile = book.ebookFile ? new EBookFile(book.ebookFile) : null - this.lastCoverSearch = book.lastCoverSearch || null - this.lastCoverSearchQuery = book.lastCoverSearchQuery || null - } - - toJSON() { - return { - id: this.id, - libraryItemId: this.libraryItemId, - metadata: this.metadata.toJSON(), - coverPath: this.coverPath, - tags: [...this.tags], - audioFiles: this.audioFiles.map((f) => f.toJSON()), - chapters: this.chapters.map((c) => ({ ...c })), - ebookFile: this.ebookFile ? this.ebookFile.toJSON() : null - } - } - - toJSONMinified() { - return { - id: this.id, - metadata: this.metadata.toJSONMinified(), - coverPath: this.coverPath, - tags: [...this.tags], - numTracks: this.tracks.length, - numAudioFiles: this.audioFiles.length, - numChapters: this.chapters.length, - duration: this.duration, - size: this.size, - ebookFormat: this.ebookFile?.ebookFormat - } - } - - toJSONForMetadataFile() { - return { - tags: [...this.tags], - chapters: this.chapters.map((c) => ({ ...c })), - ...this.metadata.toJSONForMetadataFile() - } - } - - get size() { - var total = 0 - this.audioFiles.forEach((af) => (total += af.metadata.size)) - if (this.ebookFile) { - total += this.ebookFile.metadata.size - } - return total - } - get includedAudioFiles() { - return this.audioFiles.filter((af) => !af.exclude) - } - get tracks() { - let startOffset = 0 - return this.includedAudioFiles.map((af) => { - const audioTrack = new AudioTrack() - audioTrack.setData(this.libraryItemId, af, startOffset) - startOffset += audioTrack.duration - return audioTrack - }) - } - get duration() { - let total = 0 - this.tracks.forEach((track) => (total += track.duration)) - return total - } - get numTracks() { - return this.tracks.length - } - get isEBookOnly() { - return this.ebookFile && !this.numTracks - } - - update(payload) { - const json = this.toJSON() - - let hasUpdates = false - for (const key in json) { - if (payload[key] !== undefined) { - if (key === 'metadata') { - if (this.metadata.update(payload.metadata)) { - hasUpdates = true - } - } else if (!areEquivalent(payload[key], json[key])) { - this[key] = copyValue(payload[key]) - Logger.debug('[Book] Key updated', key, this[key]) - hasUpdates = true - } - } - } - return hasUpdates - } - - updateCover(coverPath) { - coverPath = filePathToPOSIX(coverPath) - if (this.coverPath === coverPath) return false - this.coverPath = coverPath - return true - } -} -module.exports = Book diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js deleted file mode 100644 index 2ec4a8735f..0000000000 --- a/server/objects/mediaTypes/Podcast.js +++ /dev/null @@ -1,161 +0,0 @@ -const Logger = require('../../Logger') -const PodcastEpisode = require('../entities/PodcastEpisode') -const PodcastMetadata = require('../metadata/PodcastMetadata') -const { areEquivalent, copyValue } = require('../../utils/index') -const { filePathToPOSIX } = require('../../utils/fileUtils') - -class Podcast { - constructor(podcast) { - this.id = null - this.libraryItemId = null - this.metadata = null - this.coverPath = null - this.tags = [] - this.episodes = [] - - this.autoDownloadEpisodes = false - this.autoDownloadSchedule = null - this.lastEpisodeCheck = 0 - this.maxEpisodesToKeep = 0 - this.maxNewEpisodesToDownload = 3 - - this.lastCoverSearch = null - this.lastCoverSearchQuery = null - - if (podcast) { - this.construct(podcast) - } - } - - construct(podcast) { - this.id = podcast.id - this.libraryItemId = podcast.libraryItemId - this.metadata = new PodcastMetadata(podcast.metadata) - this.coverPath = podcast.coverPath - this.tags = [...podcast.tags] - this.episodes = podcast.episodes.map((e) => { - var podcastEpisode = new PodcastEpisode(e) - podcastEpisode.libraryItemId = this.libraryItemId - return podcastEpisode - }) - this.autoDownloadEpisodes = !!podcast.autoDownloadEpisodes - this.autoDownloadSchedule = podcast.autoDownloadSchedule || '0 * * * *' // Added in 2.1.3 so default to hourly - this.lastEpisodeCheck = podcast.lastEpisodeCheck || 0 - this.maxEpisodesToKeep = podcast.maxEpisodesToKeep || 0 - - // Default is 3 but 0 is allowed - if (typeof podcast.maxNewEpisodesToDownload !== 'number') { - this.maxNewEpisodesToDownload = 3 - } else { - this.maxNewEpisodesToDownload = podcast.maxNewEpisodesToDownload - } - } - - toJSON() { - return { - id: this.id, - libraryItemId: this.libraryItemId, - metadata: this.metadata.toJSON(), - coverPath: this.coverPath, - tags: [...this.tags], - episodes: this.episodes.map((e) => e.toJSON()), - autoDownloadEpisodes: this.autoDownloadEpisodes, - autoDownloadSchedule: this.autoDownloadSchedule, - lastEpisodeCheck: this.lastEpisodeCheck, - maxEpisodesToKeep: this.maxEpisodesToKeep, - maxNewEpisodesToDownload: this.maxNewEpisodesToDownload - } - } - - toJSONMinified() { - return { - id: this.id, - metadata: this.metadata.toJSONMinified(), - coverPath: this.coverPath, - tags: [...this.tags], - numEpisodes: this.episodes.length, - autoDownloadEpisodes: this.autoDownloadEpisodes, - autoDownloadSchedule: this.autoDownloadSchedule, - lastEpisodeCheck: this.lastEpisodeCheck, - maxEpisodesToKeep: this.maxEpisodesToKeep, - maxNewEpisodesToDownload: this.maxNewEpisodesToDownload, - size: this.size - } - } - - toJSONForMetadataFile() { - return { - tags: [...this.tags], - title: this.metadata.title, - author: this.metadata.author, - description: this.metadata.description, - releaseDate: this.metadata.releaseDate, - genres: [...this.metadata.genres], - feedURL: this.metadata.feedUrl, - imageURL: this.metadata.imageUrl, - itunesPageURL: this.metadata.itunesPageUrl, - itunesId: this.metadata.itunesId, - itunesArtistId: this.metadata.itunesArtistId, - explicit: this.metadata.explicit, - language: this.metadata.language, - podcastType: this.metadata.type - } - } - - get size() { - var total = 0 - this.episodes.forEach((ep) => (total += ep.size)) - return total - } - get duration() { - let total = 0 - this.episodes.forEach((ep) => (total += ep.duration)) - return total - } - get numTracks() { - return this.episodes.length - } - - update(payload) { - var json = this.toJSON() - delete json.episodes // do not update media entities here - var hasUpdates = false - for (const key in json) { - if (payload[key] !== undefined) { - if (key === 'metadata') { - if (this.metadata.update(payload.metadata)) { - hasUpdates = true - } - } else if (!areEquivalent(payload[key], json[key])) { - this[key] = copyValue(payload[key]) - Logger.debug('[Podcast] Key updated', key, this[key]) - hasUpdates = true - } - } - } - return hasUpdates - } - - updateEpisode(id, payload) { - var episode = this.episodes.find((ep) => ep.id == id) - if (!episode) return false - return episode.update(payload) - } - - updateCover(coverPath) { - coverPath = filePathToPOSIX(coverPath) - if (this.coverPath === coverPath) return false - this.coverPath = coverPath - return true - } - - getEpisode(episodeId) { - if (!episodeId) return null - - // Support old episode ids for mobile downloads - if (episodeId.startsWith('ep_')) return this.episodes.find((ep) => ep.oldEpisodeId == episodeId) - - return this.episodes.find((ep) => ep.id == episodeId) - } -} -module.exports = Podcast diff --git a/server/objects/metadata/BookMetadata.js b/server/objects/metadata/BookMetadata.js deleted file mode 100644 index 5116f2f4a0..0000000000 --- a/server/objects/metadata/BookMetadata.js +++ /dev/null @@ -1,154 +0,0 @@ -const Logger = require('../../Logger') -const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') -const parseNameString = require('../../utils/parsers/parseNameString') -class BookMetadata { - constructor(metadata) { - this.title = null - this.subtitle = null - this.authors = [] - this.narrators = [] // Array of strings - this.series = [] - this.genres = [] // Array of strings - this.publishedYear = null - this.publishedDate = null - this.publisher = null - this.description = null - this.isbn = null - this.asin = null - this.language = null - this.explicit = false - this.abridged = false - - if (metadata) { - this.construct(metadata) - } - } - - construct(metadata) { - this.title = metadata.title - this.subtitle = metadata.subtitle - this.authors = metadata.authors?.map ? metadata.authors.map((a) => ({ ...a })) : [] - this.narrators = metadata.narrators ? [...metadata.narrators].filter((n) => n) : [] - this.series = metadata.series?.map - ? metadata.series.map((s) => ({ - ...s, - name: s.name || 'No Title' - })) - : [] - this.genres = metadata.genres ? [...metadata.genres] : [] - this.publishedYear = metadata.publishedYear || null - this.publishedDate = metadata.publishedDate || null - this.publisher = metadata.publisher - this.description = metadata.description - this.isbn = metadata.isbn - this.asin = metadata.asin - this.language = metadata.language - this.explicit = !!metadata.explicit - this.abridged = !!metadata.abridged - } - - toJSON() { - return { - title: this.title, - subtitle: this.subtitle, - authors: this.authors.map((a) => ({ ...a })), // Author JSONMinimal with name and id - narrators: [...this.narrators], - series: this.series.map((s) => ({ ...s })), // Series JSONMinimal with name, id and 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 - } - } - - toJSONMinified() { - return { - title: this.title, - titleIgnorePrefix: this.titlePrefixAtEnd, - subtitle: this.subtitle, - authorName: this.authorName, - authorNameLF: this.authorNameLF, - narratorName: this.narratorName, - 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 - } - } - - toJSONForMetadataFile() { - const json = this.toJSON() - json.authors = json.authors.map((au) => au.name) - json.series = json.series.map((se) => { - if (!se.sequence) return se.name - return `${se.name} #${se.sequence}` - }) - return json - } - - clone() { - return new BookMetadata(this.toJSON()) - } - - get titleIgnorePrefix() { - return getTitleIgnorePrefix(this.title) - } - get titlePrefixAtEnd() { - return getTitlePrefixAtEnd(this.title) - } - get authorName() { - if (!this.authors.length) return '' - return this.authors.map((au) => au.name).join(', ') - } - get authorNameLF() { - // Last, First - if (!this.authors.length) return '' - return this.authors.map((au) => parseNameString.nameToLastFirst(au.name)).join(', ') - } - get seriesName() { - if (!this.series.length) return '' - return this.series - .map((se) => { - if (!se.sequence) return se.name - return `${se.name} #${se.sequence}` - }) - .join(', ') - } - get narratorName() { - return this.narrators.join(', ') - } - - getSeries(seriesId) { - return this.series.find((se) => se.id == seriesId) - } - - update(payload) { - const json = this.toJSON() - let hasUpdates = false - - for (const key in json) { - if (payload[key] !== undefined) { - if (!areEquivalent(payload[key], json[key])) { - this[key] = copyValue(payload[key]) - Logger.debug('[BookMetadata] Key updated', key, this[key]) - hasUpdates = true - } - } - } - return hasUpdates - } -} -module.exports = BookMetadata diff --git a/server/objects/metadata/PodcastMetadata.js b/server/objects/metadata/PodcastMetadata.js deleted file mode 100644 index ccc94ce072..0000000000 --- a/server/objects/metadata/PodcastMetadata.js +++ /dev/null @@ -1,105 +0,0 @@ -const Logger = require('../../Logger') -const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') - -class PodcastMetadata { - constructor(metadata) { - this.title = null - this.author = null - this.description = null - this.releaseDate = null - this.genres = [] - this.feedUrl = null - this.imageUrl = null - this.itunesPageUrl = null - this.itunesId = null - this.itunesArtistId = null - this.explicit = false - this.language = null - this.type = null - - if (metadata) { - this.construct(metadata) - } - } - - construct(metadata) { - this.title = metadata.title - this.author = metadata.author - this.description = metadata.description - this.releaseDate = metadata.releaseDate - this.genres = [...metadata.genres] - this.feedUrl = metadata.feedUrl - this.imageUrl = metadata.imageUrl - this.itunesPageUrl = metadata.itunesPageUrl - this.itunesId = metadata.itunesId - this.itunesArtistId = metadata.itunesArtistId - this.explicit = metadata.explicit - this.language = metadata.language || null - this.type = metadata.type || 'episodic' - } - - toJSON() { - 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.type - } - } - - toJSONMinified() { - return { - title: this.title, - titleIgnorePrefix: this.titlePrefixAtEnd, - 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.type - } - } - - clone() { - return new PodcastMetadata(this.toJSON()) - } - - get titleIgnorePrefix() { - return getTitleIgnorePrefix(this.title) - } - - get titlePrefixAtEnd() { - return getTitlePrefixAtEnd(this.title) - } - - update(payload) { - const json = this.toJSON() - let hasUpdates = false - for (const key in json) { - if (payload[key] !== undefined) { - if (!areEquivalent(payload[key], json[key])) { - this[key] = copyValue(payload[key]) - Logger.debug('[PodcastMetadata] Key updated', key, this[key]) - hasUpdates = true - } - } - } - return hasUpdates - } -} -module.exports = PodcastMetadata diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 5d4e1cc594..1a2a7aafb5 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -32,7 +32,7 @@ class Scanner { * @param {import('../routers/ApiRouter')} apiRouterCtx * @param {import('../models/LibraryItem')} libraryItem * @param {QuickMatchOptions} options - * @returns {Promise<{updated: boolean, libraryItem: import('../objects/LibraryItem')}>} + * @returns {Promise<{updated: boolean, libraryItem: Object}>} */ async quickMatchLibraryItem(apiRouterCtx, libraryItem, options = {}) { const provider = options.provider || 'google' diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index 8771ae7aad..f81f889c9a 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -5,7 +5,6 @@ const fs = require('../libs/fsExtra') const Path = require('path') const Logger = require('../Logger') const { filePathToPOSIX, copyToExisting } = require('./fileUtils') -const LibraryItem = require('../objects/LibraryItem') function escapeSingleQuotes(path) { // A ' within a quoted string is escaped with '\'' in ffmpeg (see https://www.ffmpeg.org/ffmpeg-utils.html#Quoting-and-escaping) @@ -365,28 +364,26 @@ function escapeFFMetadataValue(value) { /** * Retrieves the FFmpeg metadata object for a given library item. * - * @param {LibraryItem} libraryItem - The library item containing the media metadata. + * @param {import('../models/LibraryItem')} libraryItem - The library item containing the media metadata. * @param {number} audioFilesLength - The length of the audio files. * @returns {Object} - The FFmpeg metadata object. */ function getFFMetadataObject(libraryItem, audioFilesLength) { - const metadata = libraryItem.media.metadata - const ffmetadata = { - title: metadata.title, - artist: metadata.authorName, - album_artist: metadata.authorName, - album: (metadata.title || '') + (metadata.subtitle ? `: ${metadata.subtitle}` : ''), - TIT3: metadata.subtitle, // mp3 only - genre: metadata.genres?.join('; '), - date: metadata.publishedYear, - comment: metadata.description, - description: metadata.description, - composer: metadata.narratorName, - copyright: metadata.publisher, - publisher: metadata.publisher, // mp3 only + title: libraryItem.media.title, + artist: libraryItem.media.authorName, + album_artist: libraryItem.media.authorName, + album: (libraryItem.media.title || '') + (libraryItem.media.subtitle ? `: ${libraryItem.media.subtitle}` : ''), + TIT3: libraryItem.media.subtitle, // mp3 only + genre: libraryItem.media.genres?.join('; '), + date: libraryItem.media.publishedYear, + comment: libraryItem.media.description, + description: libraryItem.media.description, + composer: (libraryItem.media.narrators || []).join(', '), + copyright: libraryItem.media.publisher, + publisher: libraryItem.media.publisher, // mp3 only TRACKTOTAL: `${audioFilesLength}`, // mp3 only - grouping: metadata.series?.map((s) => s.name + (s.sequence ? ` #${s.sequence}` : '')).join('; ') + grouping: libraryItem.media.series?.map((s) => s.name + (s.bookSeries.sequence ? ` #${s.bookSeries.sequence}` : '')).join('; ') } Object.keys(ffmetadata).forEach((key) => { if (!ffmetadata[key]) { @@ -402,7 +399,7 @@ module.exports.getFFMetadataObject = getFFMetadataObject /** * Merges audio files into a single output file using FFmpeg. * - * @param {Array} audioTracks - The audio tracks to merge. + * @param {import('../models/Book').AudioFileObject} audioTracks - The audio tracks to merge. * @param {number} duration - The total duration of the audio tracks. * @param {string} itemCachePath - The path to the item cache. * @param {string} outputFilePath - The path to the output file. diff --git a/server/utils/libraryHelpers.js b/server/utils/libraryHelpers.js index 664bd6e301..5702071e56 100644 --- a/server/utils/libraryHelpers.js +++ b/server/utils/libraryHelpers.js @@ -6,35 +6,41 @@ const naturalSort = createNewSortInstance({ }) module.exports = { - getSeriesFromBooks(books, filterSeries, hideSingleBookSeries) { + /** + * + * @param {import('../models/LibraryItem')[]} libraryItems + * @param {*} filterSeries + * @param {*} hideSingleBookSeries + * @returns + */ + getSeriesFromBooks(libraryItems, filterSeries, hideSingleBookSeries) { const _series = {} const seriesToFilterOut = {} - books.forEach((libraryItem) => { + libraryItems.forEach((libraryItem) => { // get all book series for item that is not already filtered out - const bookSeries = (libraryItem.media.metadata.series || []).filter((se) => !seriesToFilterOut[se.id]) - if (!bookSeries.length) return + const allBookSeries = (libraryItem.media.series || []).filter((se) => !seriesToFilterOut[se.id]) + if (!allBookSeries.length) return - bookSeries.forEach((bookSeriesObj) => { - // const series = allSeries.find(se => se.id === bookSeriesObj.id) - - const abJson = libraryItem.toJSONMinified() - abJson.sequence = bookSeriesObj.sequence + allBookSeries.forEach((bookSeries) => { + const abJson = libraryItem.toOldJSONMinified() + abJson.sequence = bookSeries.bookSeries.sequence if (filterSeries) { - abJson.filterSeriesSequence = libraryItem.media.metadata.getSeries(filterSeries).sequence + const series = libraryItem.media.series.find((se) => se.id === filterSeries) + abJson.filterSeriesSequence = series.bookSeries.sequence } - if (!_series[bookSeriesObj.id]) { - _series[bookSeriesObj.id] = { - id: bookSeriesObj.id, - name: bookSeriesObj.name, - nameIgnorePrefix: getTitlePrefixAtEnd(bookSeriesObj.name), - nameIgnorePrefixSort: getTitleIgnorePrefix(bookSeriesObj.name), + if (!_series[bookSeries.id]) { + _series[bookSeries.id] = { + id: bookSeries.id, + name: bookSeries.name, + nameIgnorePrefix: getTitlePrefixAtEnd(bookSeries.name), + nameIgnorePrefixSort: getTitleIgnorePrefix(bookSeries.name), type: 'series', books: [abJson], totalDuration: isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration) } } else { - _series[bookSeriesObj.id].books.push(abJson) - _series[bookSeriesObj.id].totalDuration += isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration) + _series[bookSeries.id].books.push(abJson) + _series[bookSeries.id].totalDuration += isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration) } }) }) @@ -52,6 +58,13 @@ module.exports = { }) }, + /** + * + * @param {import('../models/LibraryItem')[]} libraryItems + * @param {string} filterSeries - series id + * @param {boolean} hideSingleBookSeries + * @returns + */ collapseBookSeries(libraryItems, filterSeries, hideSingleBookSeries) { // Get series from the library items. If this list is being collapsed after filtering for a series, // don't collapse that series, only books that are in other series. @@ -123,8 +136,9 @@ module.exports = { let libraryItems = books .map((book) => { const libraryItem = book.libraryItem + delete book.libraryItem libraryItem.media = book - return Database.libraryItemModel.getOldLibraryItem(libraryItem) + return libraryItem }) .filter((li) => { return user.checkCanAccessLibraryItem(li) @@ -143,15 +157,18 @@ module.exports = { if (!payload.sortBy || payload.sortBy === 'sequence') { sortArray = [ { - [direction]: (li) => li.media.metadata.getSeries(seriesId).sequence + [direction]: (li) => { + const series = li.media.series.find((se) => se.id === seriesId) + return series.bookSeries.sequence + } }, { // If no series sequence then fallback to sorting by title (or collapsed series name for sub-series) [direction]: (li) => { if (sortingIgnorePrefix) { - return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix + return li.collapsedSeries?.nameIgnorePrefix || li.media.titleIgnorePrefix } else { - return li.collapsedSeries?.name || li.media.metadata.title + return li.collapsedSeries?.name || li.media.title } } } @@ -174,9 +191,9 @@ module.exports = { [direction]: (li) => { if (payload.sortBy === 'media.metadata.title') { if (sortingIgnorePrefix) { - return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix + return li.collapsedSeries?.nameIgnorePrefix || li.media.titleIgnorePrefix } else { - return li.collapsedSeries?.name || li.media.metadata.title + return li.collapsedSeries?.name || li.media.title } } else { return payload.sortBy.split('.').reduce((a, b) => a[b], li) @@ -194,12 +211,12 @@ module.exports = { return Promise.all( libraryItems.map(async (li) => { - const filteredSeries = li.media.metadata.getSeries(seriesId) - const json = li.toJSONMinified() + const filteredSeries = li.media.series.find((se) => se.id === seriesId) + const json = li.toOldJSONMinified() json.media.metadata.series = { id: filteredSeries.id, name: filteredSeries.name, - sequence: filteredSeries.sequence + sequence: filteredSeries.bookSeries.sequence } if (li.collapsedSeries) { diff --git a/server/utils/migrations/dbMigration.js b/server/utils/migrations/dbMigration.js index eb42c81cef..1d4c47985b 100644 --- a/server/utils/migrations/dbMigration.js +++ b/server/utils/migrations/dbMigration.js @@ -1200,7 +1200,7 @@ async function migrationPatchNewColumns(queryInterface) { */ async function handleOldLibraryItems(ctx) { const oldLibraryItems = await oldDbFiles.loadOldData('libraryItems') - const libraryItems = (await ctx.models.libraryItem.findAllExpandedWhere()).map((li) => ctx.models.libraryItem.getOldLibraryItem(li)) + const libraryItems = await ctx.models.libraryItem.findAllExpandedWhere() const bulkUpdateItems = [] const bulkUpdateEpisodes = [] @@ -1218,8 +1218,8 @@ async function handleOldLibraryItems(ctx) { } }) - if (libraryItem.media.episodes?.length && matchingOldLibraryItem.media.episodes?.length) { - for (const podcastEpisode of libraryItem.media.episodes) { + if (libraryItem.media.podcastEpisodes?.length && matchingOldLibraryItem.media.episodes?.length) { + for (const podcastEpisode of libraryItem.media.podcastEpisodes) { // Find matching old episode by audio file ino const matchingOldPodcastEpisode = matchingOldLibraryItem.media.episodes.find((oep) => oep.audioFile?.ino && oep.audioFile.ino === podcastEpisode.audioFile?.ino) if (matchingOldPodcastEpisode) { diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 60c0780586..5d5f0c83c6 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -415,7 +415,7 @@ module.exports = { * @param {import('../../models/User')} user * @param {number} limit * @param {number} offset - * @returns {Promise<{ libraryItems:import('../../objects/LibraryItem')[], count:number }>} + * @returns {Promise<{ libraryItems:import('../../models/LibraryItem')[], count:number }>} */ async getLibraryItemsForAuthor(author, user, limit, offset) { const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(author.libraryId, user, 'authors', author.id, 'addedAt', true, false, [], limit, offset) diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 0aaf6f4bc6..36241f33f9 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -297,7 +297,7 @@ module.exports = { delete podcast.libraryItem libraryItem.media = podcast - libraryItem.recentEpisode = ep.getOldPodcastEpisode(libraryItem.id).toJSON() + libraryItem.recentEpisode = ep.toOldJSON(libraryItem.id) return libraryItem }) @@ -460,13 +460,14 @@ module.exports = { }) const episodeResults = episodes.map((ep) => { - const libraryItem = ep.podcast.libraryItem - libraryItem.media = ep.podcast - const oldPodcast = Database.podcastModel.getOldPodcast(libraryItem) - const oldPodcastEpisode = ep.getOldPodcastEpisode(libraryItem.id).toJSONExpanded() - oldPodcastEpisode.podcast = oldPodcast - oldPodcastEpisode.libraryId = libraryItem.libraryId - return oldPodcastEpisode + ep.podcast.podcastEpisodes = [] // Not needed + const oldPodcastJson = ep.podcast.toOldJSON(ep.podcast.libraryItem.id) + + const oldPodcastEpisodeJson = ep.toOldJSONExpanded(ep.podcast.libraryItem.id) + + oldPodcastEpisodeJson.podcast = oldPodcastJson + oldPodcastEpisodeJson.libraryId = ep.podcast.libraryItem.libraryId + return oldPodcastEpisodeJson }) return episodeResults From a1ec10bd0d9579cc644dda13327311906f362b5b Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 6 Jan 2025 11:39:55 -0600 Subject: [PATCH 024/118] Fix sync request responding with 500 status code --- server/managers/PlaybackSessionManager.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index 25992f0ae1..76c140fd3a 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -343,20 +343,20 @@ class PlaybackSessionManager { * @param {import('../models/User')} user * @param {*} session * @param {*} syncData - * @returns + * @returns {Promise} */ async syncSession(user, session, syncData) { // TODO: Combine libraryItem query with library query const libraryItem = await Database.libraryItemModel.getExpandedById(session.libraryItemId) if (!libraryItem) { Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`) - return null + return false } const library = await Database.libraryModel.findByPk(libraryItem.libraryId) if (!library) { Logger.error(`[PlaybackSessionManager] syncSession Library not found "${libraryItem.libraryId}"`) - return null + return false } session.currentTime = syncData.currentTime @@ -382,6 +382,8 @@ class PlaybackSessionManager { }) } this.saveSession(session) + + return true } /** From 977bdbf0bbacaa401751c1aadb42f2a731994cf8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 6 Jan 2025 13:30:31 -0600 Subject: [PATCH 025/118] Fix podcast episode AudioTrack object --- server/models/PodcastEpisode.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index c6a1b9fa13..9eb146322d 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -168,7 +168,7 @@ class PodcastEpisode extends Model { getAudioTrack(libraryItemId) { const track = structuredClone(this.audioFile) track.startOffset = 0 - track.title = this.audioFile.metadata.title + track.title = this.audioFile.metadata.filename track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}` return track } From 0eed38b771778f8c7c5cec299e178d65c616e450 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 6 Jan 2025 14:32:10 -0600 Subject: [PATCH 026/118] Fix playback sessions num days listened in last year to be accurate for smaller screen sizes --- client/components/stats/Heatmap.vue | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/client/components/stats/Heatmap.vue b/client/components/stats/Heatmap.vue index 4e49162100..4fc8ca0483 100644 --- a/client/components/stats/Heatmap.vue +++ b/client/components/stats/Heatmap.vue @@ -63,9 +63,6 @@ export default { dayOfWeekToday() { return new Date().getDay() }, - firstWeekStart() { - return this.$addDaysToToday(-this.daysToShow) - }, dayLabels() { return [ { @@ -198,12 +195,25 @@ export default { let minValue = 0 const dates = [] - for (let i = 0; i < this.daysToShow + 1; i++) { - const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i) + + const numDaysInTheLastYear = 52 * 7 + this.dayOfWeekToday + const firstDay = this.$addDaysToToday(-numDaysInTheLastYear) + for (let i = 0; i < numDaysInTheLastYear + 1; i++) { + const date = i === 0 ? firstDay : this.$addDaysToDate(firstDay, i) const dateString = this.$formatJsDate(date, 'yyyy-MM-dd') + + if (this.daysListening[dateString] > 0) { + this.daysListenedInTheLastYear++ + } + + const visibleDayIndex = i - (numDaysInTheLastYear - this.daysToShow) + if (visibleDayIndex < 0) { + continue + } + const dateObj = { - col: Math.floor(i / 7), - row: i % 7, + col: Math.floor(visibleDayIndex / 7), + row: visibleDayIndex % 7, date, dateString, datePretty: this.$formatJsDate(date, 'MMM d, yyyy'), @@ -215,7 +225,6 @@ export default { dates.push(dateObj) if (dateObj.value > 0) { - this.daysListenedInTheLastYear++ if (dateObj.value > maxValue) maxValue = dateObj.value if (!minValue || dateObj.value < minValue) minValue = dateObj.value } From 2ec84edb5e271e7ba73ffc37e79ba4fb092b186a Mon Sep 17 00:00:00 2001 From: Nicholas Wallace Date: Mon, 6 Jan 2025 20:00:42 -0700 Subject: [PATCH 027/118] Add: episode pubdate validation before saving --- client/components/modals/podcast/tabs/EpisodeDetails.vue | 6 ++++++ client/components/ui/TextInputWithLabel.vue | 9 +++++---- client/strings/en-us.json | 1 + 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/client/components/modals/podcast/tabs/EpisodeDetails.vue b/client/components/modals/podcast/tabs/EpisodeDetails.vue index 85cfb4ffb2..9eaef85e56 100644 --- a/client/components/modals/podcast/tabs/EpisodeDetails.vue +++ b/client/components/modals/podcast/tabs/EpisodeDetails.vue @@ -150,6 +150,12 @@ export default { this.$toast.info(this.$strings.ToastNoUpdatesNecessary) return false } + + // Check pubdate is valid if it is being updated. Cannot be set to null in the web client + if (updatedDetails.pubDate === null) { + this.$toast.error(this.$strings.ToastEpisodePubDateUpdateFailed) + return null + } return this.updateDetails(updatedDetails) }, async updateDetails(updatedDetails) { diff --git a/client/components/ui/TextInputWithLabel.vue b/client/components/ui/TextInputWithLabel.vue index f653a18be0..ee9ffb7a97 100644 --- a/client/components/ui/TextInputWithLabel.vue +++ b/client/components/ui/TextInputWithLabel.vue @@ -1,9 +1,10 @@ From 4701b3ed0ccc8b105eab7a7db16625a1869fccbc Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 17 Jan 2025 17:21:35 -0600 Subject: [PATCH 096/118] Update audiobook rss feeds to increment pub dates in 1 minute intervals #3442 --- server/models/FeedEpisode.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/models/FeedEpisode.js b/server/models/FeedEpisode.js index 0767577ae8..4133f69116 100644 --- a/server/models/FeedEpisode.js +++ b/server/models/FeedEpisode.js @@ -139,7 +139,8 @@ class FeedEpisode extends Model { */ static getFeedEpisodeObjFromAudiobookTrack(book, pubDateStart, feed, slug, audioTrack, useChapterTitles, existingEpisodeId = null) { // Example: Fri, 04 Feb 2015 00:00:00 GMT - let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 1000 // Offset pubdate to ensure correct order + // Offset pubdate in 1 minute intervals to ensure correct order + let timeOffset = isNaN(audioTrack.index) ? 0 : Number(audioTrack.index) * 60000 let episodeId = existingEpisodeId || uuidv4() // e.g. Track 1 will have a pub date before Track 2 From 4a3254d33885a20c6de42845b116f9090ec976ee Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 18 Jan 2025 15:57:44 -0600 Subject: [PATCH 097/118] Fix create library with mark media as finished when setting #3856 --- server/controllers/LibraryController.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index d73b92d234..0ece483f8b 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -100,6 +100,15 @@ class LibraryController { return res.status(400).send(`Invalid request. Settings "${key}" must be a string`) } newLibraryPayload.settings[key] = req.body.settings[key] + } else if (key === 'markAsFinishedPercentComplete' || key === 'markAsFinishedTimeRemaining') { + if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) { + return res.status(400).send(`Invalid request. Setting "${key}" must be a number`) + } else if (key === 'markAsFinishedPercentComplete' && req.body.settings[key] !== null && (Number(req.body.settings[key]) < 0 || Number(req.body.settings[key]) > 100)) { + return res.status(400).send(`Invalid request. Setting "${key}" must be between 0 and 100`) + } else if (key === 'markAsFinishedTimeRemaining' && req.body.settings[key] !== null && Number(req.body.settings[key]) < 0) { + return res.status(400).send(`Invalid request. Setting "${key}" must be greater than or equal to 0`) + } + newLibraryPayload.settings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key]) } else { if (typeof req.body.settings[key] !== typeof newLibraryPayload.settings[key]) { return res.status(400).send(`Invalid request. Setting "${key}" must be of type ${typeof newLibraryPayload.settings[key]}`) @@ -325,7 +334,7 @@ class LibraryController { } if (req.body.settings[key] !== updatedSettings[key]) { hasUpdates = true - updatedSettings[key] = Number(req.body.settings[key]) + updatedSettings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key]) Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`) } } else if (key === 'markAsFinishedTimeRemaining') { @@ -338,7 +347,7 @@ class LibraryController { } if (req.body.settings[key] !== updatedSettings[key]) { hasUpdates = true - updatedSettings[key] = Number(req.body.settings[key]) + updatedSettings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key]) Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`) } } else { From 77ad9c8a16099effe8c46a1c474f79537bf88973 Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Sat, 18 Jan 2025 08:57:02 +0000 Subject: [PATCH 098/118] Translated using Weblate (Swedish) Currently translated at 78.1% (846 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 56 ++++++++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index b2f5e71b4f..361a5d33f4 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -184,7 +184,7 @@ "HeaderSleepTimer": "Sovtidtagare", "HeaderStatsLargestItems": "Största objekten", "HeaderStatsLongestItems": "Längsta objekten (timmar)", - "HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagar)", + "HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagarna)", "HeaderStatsRecentSessions": "Senaste sessioner", "HeaderStatsTop10Authors": "10 populäraste författarna", "HeaderStatsTop5Genres": "5 populäraste kategorierna", @@ -195,7 +195,7 @@ "HeaderUpdateDetails": "Uppdatera detaljer", "HeaderUpdateLibrary": "Uppdatera bibliotek", "HeaderUsers": "Användare", - "HeaderYearReview": "Sammanställning för {0}", + "HeaderYearReview": "Sammanställning av {0}", "HeaderYourStats": "Din statistik", "LabelAbridged": "Förkortad", "LabelAccountType": "Kontotyp", @@ -247,7 +247,7 @@ "LabelClickForMoreInfo": "Klicka för mer information", "LabelClickToUseCurrentValue": "Klicka för att använda aktuellt värde", "LabelClosePlayer": "Stäng spelaren", - "LabelCollapseSeries": "Fäll ihop serie", + "LabelCollapseSeries": "Komprimera serier", "LabelCollection": "Samling", "LabelCollections": "Samlingar", "LabelComplete": "Komplett", @@ -281,7 +281,7 @@ "LabelDurationComparisonExactMatch": "(exakt matchning)", "LabelDurationFound": "Varaktighet hittad:", "LabelEbook": "E-bok", - "LabelEbooks": "Eböcker", + "LabelEbooks": "E-böcker", "LabelEdit": "Redigera", "LabelEmail": "E-postadress", "LabelEmailSettingsFromAddress": "Från adress", @@ -299,11 +299,12 @@ "LabelEpisodeTitle": "Avsnittsrubrik", "LabelEpisodeType": "Avsnittstyp", "LabelExample": "Exempel", + "LabelExpandSeries": "Expandera serier", "LabelFeedURL": "Flödes-URL", "LabelFetchingMetadata": "Hämtar metadata", "LabelFile": "Fil", - "LabelFileBirthtime": "Födelse-tidpunkt för fil", - "LabelFileModified": "Fil ändrad", + "LabelFileBirthtime": "Tidpunkt, filen skapades", + "LabelFileModified": "Tidpunkt, filen ändrades", "LabelFilename": "Filnamn", "LabelFilterByUser": "Välj användare", "LabelFindEpisodes": "Hitta avsnitt", @@ -318,8 +319,8 @@ "LabelGenre": "Kategori", "LabelGenres": "Kategorier", "LabelHardDeleteFile": "Hård radering av fil", - "LabelHasEbook": "Har E-bok", - "LabelHasSupplementaryEbook": "Har komplimenterande E-bok", + "LabelHasEbook": "Har e-bok", + "LabelHasSupplementaryEbook": "Har kompletterande e-bok", "LabelHideSubtitles": "Dölj underrubriker", "LabelHighestPriority": "Högst prioritet", "LabelHost": "Värd", @@ -372,8 +373,10 @@ "LabelMetadataOrderOfPrecedenceDescription": "Källor för metadata med högre prioritet har företräde före källor med lägre prioritet", "LabelMetadataProvider": "Källa för metadata", "LabelMinute": "Minut", - "LabelMissing": "Saknad", + "LabelMinutes": "Minuter", + "LabelMissing": "Saknar", "LabelMissingEbook": "Saknar e-bok", + "LabelMissingSupplementaryEbook": "Saknar kompletterande e-bok", "LabelMore": "Mer", "LabelMoreInfo": "Mer information", "LabelName": "Namn", @@ -388,7 +391,7 @@ "LabelNoCustomMetadataProviders": "Ingen egen källa för metadata", "LabelNoEpisodesSelected": "Inga avsnitt valda", "LabelNotFinished": "Ej avslutad", - "LabelNotStarted": "Inte påbörjad", + "LabelNotStarted": "Ej påbörjad", "LabelNotes": "Anteckningar", "LabelNotificationAppriseURL": "Apprise URL(er)", "LabelNotificationAvailableVariables": "Tillgängliga variabler", @@ -413,7 +416,7 @@ "LabelPermissionsDownload": "Kan ladda ner", "LabelPermissionsUpdate": "Kan uppdatera", "LabelPermissionsUpload": "Kan ladda upp", - "LabelPersonalYearReview": "Överblick av ditt år {0}", + "LabelPersonalYearReview": "En sammanställning av, sidan {0}", "LabelPhotoPathURL": "Bildsökväg/URL", "LabelPlayMethod": "Spelläge", "LabelPlaylists": "Spellistor", @@ -428,6 +431,7 @@ "LabelProvider": "Källa", "LabelPubDate": "Publiceringsdatum", "LabelPublishYear": "Publiceringsår", + "LabelPublishedDecade": "Årtionde för publicering", "LabelPublisher": "Utgivare", "LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post", "LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn", @@ -521,13 +525,13 @@ "LabelStatsBestDay": "Bästa dag", "LabelStatsDailyAverage": "Dagligt genomsnitt", "LabelStatsDays": "Dagar", - "LabelStatsDaysListened": "Dagar lyssnade", + "LabelStatsDaysListened": "dagars lyssnande", "LabelStatsHours": "Timmar", "LabelStatsInARow": "i rad", - "LabelStatsItemsFinished": "Objekt avslutade", + "LabelStatsItemsFinished": "böcker avslutade", "LabelStatsItemsInLibrary": "Objekt i biblioteket", "LabelStatsMinutes": "minuter", - "LabelStatsMinutesListening": "Minuter av lyssnande", + "LabelStatsMinutesListening": "minuters lyssnande", "LabelStatsOverallDays": "Totalt antal dagar", "LabelStatsOverallHours": "Totalt antal timmar", "LabelStatsWeekListening": "Veckans lyssnande", @@ -592,8 +596,8 @@ "LabelViewQueue": "Visa spellista", "LabelVolume": "Volym", "LabelWeekdaysToRun": "Veckodagar att köra skanning", - "LabelYearReviewHide": "Dölj sammanställning för året", - "LabelYearReviewShow": "Visa sammanställning för året", + "LabelYearReviewHide": "Dölj årets sammanställning", + "LabelYearReviewShow": "Visa årets sammanställning", "LabelYourAudiobookDuration": "Din ljudboks varaktighet", "LabelYourBookmarks": "Dina bokmärken", "LabelYourPlaylists": "Dina spellistor", @@ -648,9 +652,10 @@ "MessageConfirmRenameTagWarning": "VARNING! En liknande tagg med annat skrivsätt finns redan \"{0}\".", "MessageConfirmResetProgress": "Är du säker på att du vill nollställa ditt framsteg?", "MessageConfirmSendEbookToDevice": "Är du säker på att du vill skicka {0} e-bok \"{1}\" till enheten \"{2}\"?", + "MessageDaysListenedInTheLastYear": "{0} dagars lyssnande det senaste året", "MessageDownloadingEpisode": "Laddar ner avsnitt", "MessageDragFilesIntoTrackOrder": "Dra filer till rätt spårordning", - "MessageEmbedFinished": "Inbäddning klar!", + "MessageEmbedFinished": "Inbäddning genomförd!", "MessageEpisodesQueuedForDownload": "{0} avsnitt i kö för nedladdning", "MessageFeedURLWillBe": "Flödes-URL kommer att vara {0}", "MessageFetching": "Hämtar...", @@ -667,9 +672,9 @@ "MessageM4BFinished": "M4B klar!", "MessageMapChapterTitles": "Kartlägg kapitelrubriker till dina befintliga ljudbokskapitel utan att justera tidstämplar", "MessageMarkAllEpisodesFinished": "Markera alla avsnitt som avslutade", - "MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som inte avslutade", + "MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som ej avslutade", "MessageMarkAsFinished": "Markera som avslutad", - "MessageMarkAsNotFinished": "Markera som inte avslutad", + "MessageMarkAsNotFinished": "Markera som ej avslutad", "MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från
den valda källan och fylla i uppgifter som saknas och bokomslag.
Inga befintliga uppgifter kommer att ersättas.", "MessageNoAudioTracks": "Inga ljudspår", "MessageNoAuthors": "Inga författare", @@ -688,7 +693,7 @@ "MessageNoIssues": "Inga problem", "MessageNoItems": "Inga objekt", "MessageNoItemsFound": "Inga objekt hittades", - "MessageNoListeningSessions": "Inga lyssningssessioner", + "MessageNoListeningSessions": "Inga lyssningstillfällen", "MessageNoLogs": "Inga loggar", "MessageNoMediaProgress": "Ingen medieförlopp", "MessageNoNotifications": "Inga aviseringar", @@ -744,6 +749,9 @@ "PlaceholderNewPlaylist": "Nytt spellistanamn", "PlaceholderSearch": "Sök...", "PlaceholderSearchEpisode": "Sök avsnitt...", + "StatsBooksFinished": "avslutade böcker", + "StatsBooksFinishedThisYear": "Några böcker som avslutats under året…", + "StatsSpentListening": "tillbringat på att lyssna", "StatsTopAuthor": "POPULÄRAST FÖRFATTAREN", "StatsTopAuthors": "POPULÄRASTE FÖRFATTARNA", "StatsTopGenre": "Populäraste kategorin", @@ -791,10 +799,10 @@ "ToastInvalidUrl": "Felaktig URL-adress", "ToastItemCoverUpdateSuccess": "Objektets bokomslag har uppdaterats", "ToastItemDetailsUpdateSuccess": "Objektdetaljer uppdaterade", - "ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera som färdig", - "ToastItemMarkedAsFinishedSuccess": "Objekt markerat som färdig", - "ToastItemMarkedAsNotFinishedFailed": "Misslyckades med att markera som ej färdig", - "ToastItemMarkedAsNotFinishedSuccess": "Objekt markerat som ej färdig", + "ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera den som avslutad", + "ToastItemMarkedAsFinishedSuccess": "Den har markerat som avslutad", + "ToastItemMarkedAsNotFinishedFailed": "Misslyckades med att markera den som ej avslutad", + "ToastItemMarkedAsNotFinishedSuccess": "Den har markerats som ej avslutad", "ToastLibraryCreateFailed": "Det gick inte att skapa biblioteket", "ToastLibraryCreateSuccess": "Biblioteket \"{0}\" har skapats", "ToastLibraryDeleteFailed": "Det gick inte att ta bort biblioteket", From 47247323cf2125b7452f64241f8e419c5d41fe85 Mon Sep 17 00:00:00 2001 From: Kieli Puoli Date: Sat, 18 Jan 2025 09:49:23 +0000 Subject: [PATCH 099/118] Translated using Weblate (Finnish) Currently translated at 44.0% (477 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/ --- client/strings/fi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/fi.json b/client/strings/fi.json index 95cba8c959..276febd948 100644 --- a/client/strings/fi.json +++ b/client/strings/fi.json @@ -69,7 +69,7 @@ "ButtonQuickMatch": "Pikatäsmää", "ButtonReScan": "Uudelleenskannaa", "ButtonRead": "Lue", - "ButtonReadLess": "Näytä vähemmän", + "ButtonReadLess": "Lue vähemmän", "ButtonReadMore": "Näytä enemmän", "ButtonRefresh": "Päivitä", "ButtonRemove": "Poista", From 7d278ebc564b4a76198700e5881da30526efc67a Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Sat, 18 Jan 2025 09:50:20 +0000 Subject: [PATCH 100/118] Translated using Weblate (Swedish) Currently translated at 78.1% (846 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index 361a5d33f4..a70cf73ccd 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -136,8 +136,8 @@ "HeaderIgnoredFiles": "Ignorerade filer", "HeaderItemFiles": "Föremålsfiler", "HeaderItemMetadataUtils": "Metadataverktyg för föremål", - "HeaderLastListeningSession": "Senaste lyssningssession", - "HeaderLatestEpisodes": "Senaste avsnitt", + "HeaderLastListeningSession": "Senaste lyssningstillfället", + "HeaderLatestEpisodes": "Senaste avsnitten", "HeaderLibraries": "Bibliotek", "HeaderLibraryFiles": "Filer i biblioteket", "HeaderLibraryStats": "Biblioteksstatistik", @@ -185,7 +185,7 @@ "HeaderStatsLargestItems": "Största objekten", "HeaderStatsLongestItems": "Längsta objekten (timmar)", "HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagarna)", - "HeaderStatsRecentSessions": "Senaste sessioner", + "HeaderStatsRecentSessions": "Senaste tillfällena", "HeaderStatsTop10Authors": "10 populäraste författarna", "HeaderStatsTop5Genres": "5 populäraste kategorierna", "HeaderTableOfContents": "Innehållsförteckning", @@ -349,7 +349,7 @@ "LabelLastBookUpdated": "Bok senast uppdaterad", "LabelLastSeen": "Senast inloggad", "LabelLastTime": "Senaste tillfället", - "LabelLastUpdate": "Senaste uppdatering", + "LabelLastUpdate": "Senast uppdaterad", "LabelLayout": "Layout", "LabelLayoutSinglePage": "En sida", "LabelLayoutSplitPage": "Uppslag", @@ -443,7 +443,7 @@ "LabelRead": "Läst", "LabelReadAgain": "Läs igen", "LabelReadEbookWithoutProgress": "Läs e-bok utan att behålla framsteg", - "LabelRecentSeries": "Senaste serier", + "LabelRecentSeries": "Senaste serierna", "LabelRecentlyAdded": "Nyligen tillagda", "LabelRecommended": "Rekommenderad", "LabelReleaseDate": "Utgivningsdatum", From bc2d7ff14dd1a2a94d98bbb85adbf286c5d281b8 Mon Sep 17 00:00:00 2001 From: Kieli Puoli Date: Sat, 18 Jan 2025 09:52:37 +0000 Subject: [PATCH 101/118] Translated using Weblate (Finnish) Currently translated at 44.1% (478 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/ --- client/strings/fi.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/strings/fi.json b/client/strings/fi.json index 276febd948..ae9ae30687 100644 --- a/client/strings/fi.json +++ b/client/strings/fi.json @@ -66,11 +66,12 @@ "ButtonQueueAddItem": "Lisää jonoon", "ButtonQueueRemoveItem": "Poista jonosta", "ButtonQuickEmbed": "Pikaupota", + "ButtonQuickEmbedMetadata": "Upota kuvailutiedot nopeasti", "ButtonQuickMatch": "Pikatäsmää", "ButtonReScan": "Uudelleenskannaa", "ButtonRead": "Lue", "ButtonReadLess": "Lue vähemmän", - "ButtonReadMore": "Näytä enemmän", + "ButtonReadMore": "Lue enemmän", "ButtonRefresh": "Päivitä", "ButtonRemove": "Poista", "ButtonRemoveAll": "Poista kaikki", From 68af5933e537deea9a968e492e9b80c262e22fc7 Mon Sep 17 00:00:00 2001 From: Kieli Puoli Date: Sat, 18 Jan 2025 09:58:29 +0000 Subject: [PATCH 102/118] Translated using Weblate (Finnish) Currently translated at 44.2% (479 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/ --- client/strings/fi.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/fi.json b/client/strings/fi.json index ae9ae30687..3b52c1759b 100644 --- a/client/strings/fi.json +++ b/client/strings/fi.json @@ -302,6 +302,7 @@ "LabelFinished": "Valmis", "LabelFolder": "Kansio", "LabelFolders": "Kansiot", + "LabelFontBoldness": "Kirjasintyyppien lihavointi", "LabelGenre": "Lajityyppi", "LabelGenres": "Lajityypit", "LabelHost": "Isäntä", From 5294335bca4f709d00f7f0a24f592222f0c0ab4d Mon Sep 17 00:00:00 2001 From: Kieli Puoli Date: Sat, 18 Jan 2025 09:58:48 +0000 Subject: [PATCH 103/118] Translated using Weblate (Finnish) Currently translated at 44.3% (480 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/ --- client/strings/fi.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/fi.json b/client/strings/fi.json index 3b52c1759b..fa83969f56 100644 --- a/client/strings/fi.json +++ b/client/strings/fi.json @@ -303,6 +303,7 @@ "LabelFolder": "Kansio", "LabelFolders": "Kansiot", "LabelFontBoldness": "Kirjasintyyppien lihavointi", + "LabelFontScale": "Kirjasintyyppien skaalautuminen", "LabelGenre": "Lajityyppi", "LabelGenres": "Lajityypit", "LabelHost": "Isäntä", From 318e57170d5f24570227b5a43b65f690313995a8 Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Sat, 18 Jan 2025 10:01:11 +0000 Subject: [PATCH 104/118] Translated using Weblate (Swedish) Currently translated at 78.1% (846 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index a70cf73ccd..59963c3929 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -725,7 +725,7 @@ "MessageSelected": "{0} valda", "MessageServerCouldNotBeReached": "Servern kunde inte nås", "MessageSetChaptersFromTracksDescription": "Ställ in kapitel med varje ljudfil som ett kapitel och kapitelrubrik som ljudfilens namn", - "MessageStartPlaybackAtTime": "Starta uppspelning för \"{0}\" kl. {1}?", + "MessageStartPlaybackAtTime": "Starta uppspelning av \"{0}\" vid tidpunkt {1}?", "MessageTaskFailedToCreateCacheDirectory": "Misslyckades med att skapa bibliotek för cachen", "MessageThinking": "Tänker...", "MessageUploaderItemFailed": "Misslyckades med att ladda upp", From 76b270ddf67f7e1a461a4eb616855425cf68164b Mon Sep 17 00:00:00 2001 From: Kieli Puoli Date: Sat, 18 Jan 2025 10:01:03 +0000 Subject: [PATCH 105/118] Translated using Weblate (Finnish) Currently translated at 44.4% (481 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/ --- client/strings/fi.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/fi.json b/client/strings/fi.json index fa83969f56..fc0cace92d 100644 --- a/client/strings/fi.json +++ b/client/strings/fi.json @@ -324,6 +324,7 @@ "LabelLastBookAdded": "Viimeisin lisätty kirja", "LabelLastBookUpdated": "Viimeisin päivitetty kirja", "LabelLastUpdate": "Viimeisin päivitys", + "LabelLayout": "Asettelu", "LabelLibrariesAccessibleToUser": "Käyttäjälle saatavilla olevat kirjastot", "LabelLibrary": "Kirjasto", "LabelLibraryName": "Kirjaston nimi", From 6052bb9fda030ff4cf1975c5dd106521e1ff94f4 Mon Sep 17 00:00:00 2001 From: Kieli Puoli Date: Sat, 18 Jan 2025 10:02:03 +0000 Subject: [PATCH 106/118] Translated using Weblate (Finnish) Currently translated at 44.5% (482 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/ --- client/strings/fi.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/fi.json b/client/strings/fi.json index fc0cace92d..c0a3ce0bb0 100644 --- a/client/strings/fi.json +++ b/client/strings/fi.json @@ -325,6 +325,7 @@ "LabelLastBookUpdated": "Viimeisin päivitetty kirja", "LabelLastUpdate": "Viimeisin päivitys", "LabelLayout": "Asettelu", + "LabelLayoutSinglePage": "Yksi sivu", "LabelLibrariesAccessibleToUser": "Käyttäjälle saatavilla olevat kirjastot", "LabelLibrary": "Kirjasto", "LabelLibraryName": "Kirjaston nimi", From 3aa6b358b328a886593bb6bf9381d108a3e222c6 Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Sat, 18 Jan 2025 10:20:58 +0000 Subject: [PATCH 107/118] Translated using Weblate (Swedish) Currently translated at 79.9% (865 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 83 ++++++++++++++++++++++++++---------------- 1 file changed, 51 insertions(+), 32 deletions(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index 59963c3929..1b1ae6e2ff 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -119,7 +119,7 @@ "HeaderChapters": "Kapitel", "HeaderChooseAFolder": "Välj en mapp", "HeaderCollection": "Samling", - "HeaderCollectionItems": "Samlingselement", + "HeaderCollectionItems": "Böcker i samlingen", "HeaderCover": "Bokomslag", "HeaderCurrentDownloads": "Aktuella nedladdningar", "HeaderCustomMetadataProviders": "Egen källa för metadata", @@ -129,7 +129,7 @@ "HeaderEmail": "E-postadress", "HeaderEmailSettings": "Inställningar för e-post", "HeaderEpisodes": "Avsnitt", - "HeaderEreaderDevices": "E-boksläsarenheter", + "HeaderEreaderDevices": "Enheter för att läsa e-böcker", "HeaderEreaderSettings": "E-boksinställningar", "HeaderFiles": "Filer", "HeaderFindChapters": "Hitta kapitel", @@ -158,10 +158,10 @@ "HeaderOtherFiles": "Andra filer", "HeaderPasswordAuthentication": "Lösenordsautentisering", "HeaderPermissions": "Behörigheter", - "HeaderPlayerQueue": "Spelarkö", - "HeaderPlayerSettings": "Spelarinställningar", + "HeaderPlayerQueue": "Spellista", + "HeaderPlayerSettings": "Inställningar för uppspelning", "HeaderPlaylist": "Spellista", - "HeaderPlaylistItems": "Spellistobjekt", + "HeaderPlaylistItems": "Böcker i spellistan", "HeaderPodcastsToAdd": "Podcaster att lägga till", "HeaderPreviewCover": "Förhandsgranska bokomslag", "HeaderRSSFeedGeneral": "RSS-information", @@ -169,7 +169,7 @@ "HeaderRSSFeeds": "RSS-flöden", "HeaderRemoveEpisode": "Ta bort avsnitt", "HeaderRemoveEpisodes": "Ta bort {0} avsnitt", - "HeaderSavedMediaProgress": "Sparad medieförlopp", + "HeaderSavedMediaProgress": "Sparad historik", "HeaderSchedule": "Schema", "HeaderScheduleEpisodeDownloads": "Schemalägg automatiska avsnittsnedladdningar", "HeaderScheduleLibraryScans": "Schema för skanning av biblioteket", @@ -181,7 +181,7 @@ "HeaderSettingsGeneral": "Allmänt", "HeaderSettingsScanner": "Skanner", "HeaderSettingsWebClient": "Webklient", - "HeaderSleepTimer": "Sovtidtagare", + "HeaderSleepTimer": "Timer för att sova", "HeaderStatsLargestItems": "Största objekten", "HeaderStatsLongestItems": "Längsta objekten (timmar)", "HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagarna)", @@ -192,12 +192,13 @@ "HeaderTools": "Verktyg", "HeaderUpdateAccount": "Uppdatera konto", "HeaderUpdateAuthor": "Uppdatera författare", - "HeaderUpdateDetails": "Uppdatera detaljer", + "HeaderUpdateDetails": "Uppdatera detaljer om boken", "HeaderUpdateLibrary": "Uppdatera bibliotek", "HeaderUsers": "Användare", "HeaderYearReview": "Sammanställning av {0}", "HeaderYourStats": "Din statistik", "LabelAbridged": "Förkortad", + "LabelAccessibleBy": "Tillgänglig för", "LabelAccountType": "Kontotyp", "LabelAccountTypeAdmin": "Administratör", "LabelAccountTypeGuest": "Gäst", @@ -284,12 +285,12 @@ "LabelEbooks": "E-böcker", "LabelEdit": "Redigera", "LabelEmail": "E-postadress", - "LabelEmailSettingsFromAddress": "Från adress", + "LabelEmailSettingsFromAddress": "Från e-postadress", "LabelEmailSettingsRejectUnauthorized": "Avvisa icke-autentiserade certifikat", "LabelEmailSettingsRejectUnauthorizedHelp": "Inaktivering av SSL-certifikatsvalidering kan exponera din anslutning för säkerhetsrisker, såsom man-in-the-middle-attacker. Inaktivera bara denna inställning om du förstår implikationerna och litar på den epostserver du ansluter till.", "LabelEmailSettingsSecure": "Säker", "LabelEmailSettingsSecureHelp": "Om sant kommer anslutningen att använda TLS vid anslutning till servern. Om falskt används TLS om servern stöder STARTTLS-tillägget. I de flesta fall, om du ansluter till port 465, bör du ställa in detta värde till sant. För port 587 eller 25, låt det vara falskt. (från nodemailer.com/smtp/#authentication)", - "LabelEmailSettingsTestAddress": "Testadress", + "LabelEmailSettingsTestAddress": "E-postadress för test", "LabelEmbeddedCover": "Inbäddat bokomslag", "LabelEnable": "Aktivera", "LabelEncodingBackupLocation": "En säkerhetskopia av dina orginalljudfiler kommer att lagras i:", @@ -342,6 +343,8 @@ "LabelIntervalEveryHour": "Varje timme", "LabelInvert": "Invertera", "LabelItem": "Objekt", + "LabelJumpBackwardAmount": "Inställning för \"hopp bakåt\"", + "LabelJumpForwardAmount": "Inställning för \"hopp framåt\"", "LabelLanguage": "Språk", "LabelLanguageDefaultServer": "Standardspråk för server", "LabelLanguages": "Språk", @@ -354,7 +357,7 @@ "LabelLayoutSinglePage": "En sida", "LabelLayoutSplitPage": "Uppslag", "LabelLess": "Mindre", - "LabelLibrariesAccessibleToUser": "Åtkomliga bibliotek för användare", + "LabelLibrariesAccessibleToUser": "Bibliotek användaren har tillgång till", "LabelLibrary": "Bibliotek", "LabelLibraryItem": "Objekt", "LabelLibraryName": "Biblioteksnamn", @@ -382,7 +385,7 @@ "LabelName": "Namn", "LabelNarrator": "Uppläsare", "LabelNarrators": "Uppläsare", - "LabelNew": "Ny", + "LabelNew": "Nytt", "LabelNewPassword": "Nytt lösenord", "LabelNewestAuthors": "Senast adderade författare", "LabelNewestEpisodes": "Senast tillagda avsnitt", @@ -412,11 +415,12 @@ "LabelPermissionsAccessAllLibraries": "Kan komma åt alla bibliotek", "LabelPermissionsAccessAllTags": "Kan komma åt alla taggar", "LabelPermissionsAccessExplicitContent": "Kan komma åt explicit innehåll", + "LabelPermissionsCreateEreader": "Kan addera e-läsarenhet", "LabelPermissionsDelete": "Kan radera", "LabelPermissionsDownload": "Kan ladda ner", "LabelPermissionsUpdate": "Kan uppdatera", "LabelPermissionsUpload": "Kan ladda upp", - "LabelPersonalYearReview": "En sammanställning av, sidan {0}", + "LabelPersonalYearReview": "En sammanställning av ditt år, sidan {0}", "LabelPhotoPathURL": "Bildsökväg/URL", "LabelPlayMethod": "Spelläge", "LabelPlaylists": "Spellistor", @@ -467,6 +471,7 @@ "LabelSeriesName": "Serienamn", "LabelSeriesProgress": "Status för serier", "LabelServerLogLevel": "Nivå på loggning", + "LabelServerYearReview": "En sammanställning av ditt bibliotek, sidan {0}", "LabelSetEbookAsPrimary": "Ange som primär fil", "LabelSetEbookAsSupplementary": "Ange som kompletterande", "LabelSettingsAllowIframe": "Tillåt att Audiobookshelf får visas i en iframe", @@ -515,7 +520,7 @@ "LabelShowSeconds": "Visa sekunder", "LabelShowSubtitles": "Visa underrubriker", "LabelSize": "Storlek", - "LabelSleepTimer": "Sleeptimer", + "LabelSleepTimer": "Timer för sova", "LabelStart": "Starta", "LabelStartTime": "Starttid", "LabelStarted": "Startad", @@ -539,7 +544,7 @@ "LabelSupportedFileTypes": "Filtyper som accepteras", "LabelTag": "Tagg", "LabelTags": "Taggar", - "LabelTagsAccessibleToUser": "Taggar tillgängliga för användaren", + "LabelTagsAccessibleToUser": "Taggar användaren har tillgång till", "LabelTagsNotAccessibleToUser": "Taggar inte tillgängliga för användaren", "LabelTasks": "Körande uppgifter", "LabelTextEditorBulletedList": "Punktlista", @@ -591,8 +596,9 @@ "LabelUsername": "Användarnamn", "LabelValue": "Värde", "LabelVersion": "Version", - "LabelViewBookmarks": "Visa bokmärken", + "LabelViewBookmarks": "Bokmärken", "LabelViewChapters": "Visa kapitel", + "LabelViewPlayerSettings": "Visa inställningar för uppspelning", "LabelViewQueue": "Visa spellista", "LabelVolume": "Volym", "LabelWeekdaysToRun": "Veckodagar att köra skanning", @@ -619,11 +625,13 @@ "MessageCheckingCron": "Kontrollerar cron...", "MessageConfirmCloseFeed": "Är du säker på att du vill stänga detta flöde?", "MessageConfirmDeleteBackup": "Är du säker på att du vill radera säkerhetskopian för {0}?", + "MessageConfirmDeleteDevice": "Är du säkert på att du vill radera enheten för e-böcker \"{0}\"?", "MessageConfirmDeleteFile": "Detta kommer att radera filen från ditt filsystem. Är du säker?", "MessageConfirmDeleteLibrary": "Är du säker på att du vill radera biblioteket '{0}'?", "MessageConfirmDeleteLibraryItem": "Detta kommer att radera biblioteksobjektet från databasen och ditt filsystem. Är du säker?", "MessageConfirmDeleteLibraryItems": "Detta kommer att radera {0} biblioteksobjekt från databasen och ditt filsystem. Är du säker?", - "MessageConfirmDeleteSession": "Är du säker på att du vill radera denna session?", + "MessageConfirmDeleteMetadataProvider": "Är du säker på att du vill radera din egen källa för metadata \"{0}\"?", + "MessageConfirmDeleteSession": "Är du säker på att du vill radera detta lyssningstillfälle?", "MessageConfirmForceReScan": "Är du säker på att du vill tvinga omgenomsökning?", "MessageConfirmMarkAllEpisodesFinished": "Är du säker på att du vill markera alla avsnitt som avslutade?", "MessageConfirmMarkAllEpisodesNotFinished": "Är du säker på att du vill markera alla avsnitt som ej avslutade?", @@ -634,7 +642,7 @@ "MessageConfirmPurgeCache": "När du rensar cashen kommer katalogen /metadata/cache att raderas.

Är du säker på att du vill radera katalogen?", "MessageConfirmPurgeItemsCache": "När du rensar cashen för föremål kommer katalogen /metadata/cache/items att raderas.

Är du säker på att du vill radera katalogen?", "MessageConfirmQuickEmbed": "VARNING! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler.

Vill du fortsätta?", - "MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra omgenomsökning för {0} objekt?", + "MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra en ny genomsökning för {0} objekt?", "MessageConfirmRemoveAllChapters": "Är du säker på att du vill ta bort alla kapitel?", "MessageConfirmRemoveAuthor": "Är du säker på att du vill ta bort författaren \"{0}\"?", "MessageConfirmRemoveCollection": "Är du säker på att du vill ta bort samlingen \"{0}\"?", @@ -657,6 +665,7 @@ "MessageDragFilesIntoTrackOrder": "Dra filer till rätt spårordning", "MessageEmbedFinished": "Inbäddning genomförd!", "MessageEpisodesQueuedForDownload": "{0} avsnitt i kö för nedladdning", + "MessageEreaderDevices": "För att säkerställa överföring av e-böcker kan du bli tvungen
att addera ovanstående e-postadress som godkänd
avsändare för varje enhet angiven nedan.", "MessageFeedURLWillBe": "Flödes-URL kommer att vara {0}", "MessageFetching": "Hämtar...", "MessageForceReScanDescription": "kommer att göra en omgångssökning av alla filer som en färsk sökning. ID3-taggar för ljudfiler, OPF-filer och textfiler kommer att sökas som nya.", @@ -684,6 +693,7 @@ "MessageNoCollections": "Inga samlingar", "MessageNoCoversFound": "Inga bokomslag hittades", "MessageNoDescription": "Ingen beskrivning", + "MessageNoDevices": "Inga enheter angivna", "MessageNoDownloadsInProgress": "Inga nedladdningar pågår för närvarande", "MessageNoDownloadsQueued": "Inga nedladdningar i kö", "MessageNoEpisodeMatchesFound": "Inga matchande avsnitt hittades", @@ -742,25 +752,32 @@ "NoteRSSFeedPodcastAppsHttps": "Varning: De flesta podcastappar kräver att RSS-flödets URL används med HTTPS", "NoteRSSFeedPodcastAppsPubDate": "Varning: 1 eller flera av dina avsnitt har inte ett publiceringsdatum. Vissa podcastappar kräver detta.", "NoteUploaderFoldersWithMediaFiles": "Mappar med flera mediefiler hanteras som separata objekt i biblioteket.", - "NoteUploaderOnlyAudioFiles": "Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.", + "NoteUploaderOnlyAudioFiles": "
Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.", "NoteUploaderUnsupportedFiles": "Oaccepterade filer ignoreras. När du väljer eller släpper en mapp ignoreras andra filer som inte finns i ett objektmapp.", "PlaceholderNewCollection": "Nytt samlingsnamn", - "PlaceholderNewFolderPath": "Nytt mappväg", - "PlaceholderNewPlaylist": "Nytt spellistanamn", + "PlaceholderNewFolderPath": "Nytt sökväg till mappen", + "PlaceholderNewPlaylist": "Nytt namn på spellistan", "PlaceholderSearch": "Sök...", "PlaceholderSearchEpisode": "Sök avsnitt...", + "StatsAuthorsAdded": "författare har adderats", + "StatsBooksAdded": "böcker har adderats", + "StatsBooksAdditional": "Några exempel på det som adderats…", "StatsBooksFinished": "avslutade böcker", "StatsBooksFinishedThisYear": "Några böcker som avslutats under året…", - "StatsSpentListening": "tillbringat på att lyssna", - "StatsTopAuthor": "POPULÄRAST FÖRFATTAREN", - "StatsTopAuthors": "POPULÄRASTE FÖRFATTARNA", - "StatsTopGenre": "Populäraste kategorin", + "StatsBooksListenedTo": "böcker, lyssnat på", + "StatsCollectionGrewTo": "Ditt biblioteks storlek ökade till…", + "StatsSessions": "lyssningstillfällen", + "StatsSpentListening": "tid, som lyssnats", + "StatsTopAuthor": "Populäraste författare", + "StatsTopAuthors": "Populäraste författarna", + "StatsTopGenre": "Populäraste kategori", "StatsTopGenres": "Populäraste kategorierna", - "StatsTopMonth": "Bästa månaden", - "StatsTopNarrator": "Populärast uppläsarna", - "StatsTopNarrators": "Populäraste uppläsaren", - "StatsYearInReview": "SAMMANSTÄLLNING AV ÅRET", - "ToastAccountUpdateSuccess": "Kontot uppdaterat", + "StatsTopMonth": "Bästa månad", + "StatsTopNarrator": "Populäraste uppläsare", + "StatsTopNarrators": "Populäraste uppläsarna", + "StatsTotalDuration": "Med en total varaktighet…", + "StatsYearInReview": "- SAMMANSTÄLLNING AV ÅRET", + "ToastAccountUpdateSuccess": "Kontot har uppdaterats", "ToastAsinRequired": "En ASIN-kod krävs", "ToastAuthorImageRemoveSuccess": "Författarens bild borttagen", "ToastAuthorNotFound": "Författaren \"{0}\" kunde inte identifieras", @@ -793,12 +810,13 @@ "ToastDateTimeInvalidOrIncomplete": "Datum och klockslag är felaktigt eller ej komplett", "ToastDeleteFileFailed": "Misslyckades att radera filen", "ToastDeleteFileSuccess": "Filen har raderats", + "ToastEmailSettingsUpdateSuccess": "Inställningarna av e-post har uppdaterats", "ToastFailedToLoadData": "Misslyckades med att ladda data", "ToastInvalidImageUrl": "Felaktig URL-adress till omslagsbilden", "ToastInvalidMaxEpisodesToDownload": "Ogiltigt maximalt antal avsnitt att ladda ner", "ToastInvalidUrl": "Felaktig URL-adress", "ToastItemCoverUpdateSuccess": "Objektets bokomslag har uppdaterats", - "ToastItemDetailsUpdateSuccess": "Objektdetaljer uppdaterade", + "ToastItemDetailsUpdateSuccess": "Detaljerna om boken har uppdaterats", "ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera den som avslutad", "ToastItemMarkedAsFinishedSuccess": "Den har markerat som avslutad", "ToastItemMarkedAsNotFinishedFailed": "Misslyckades med att markera den som ej avslutad", @@ -817,7 +835,7 @@ "ToastNoUpdatesNecessary": "Inga uppdateringar var nödvändiga", "ToastPlaylistCreateFailed": "Det gick inte att skapa spellistan", "ToastPlaylistCreateSuccess": "Spellistan skapad", - "ToastPlaylistRemoveSuccess": "Spellistan borttagen", + "ToastPlaylistRemoveSuccess": "Spellistan har tagits bort", "ToastPlaylistUpdateSuccess": "Spellistan uppdaterad", "ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten", "ToastPodcastCreateSuccess": "Podcasten skapad framgångsrikt", @@ -833,6 +851,7 @@ "ToastServerSettingsUpdateSuccess": "Inställningarna för servern har uppdaterats", "ToastSessionDeleteFailed": "Misslyckades med att ta bort sessionen", "ToastSessionDeleteSuccess": "Sessionen borttagen", + "ToastSleepTimerDone": "Timer har stängt av lyssning. Sov gott... zZzzZz", "ToastSocketConnected": "Socket ansluten", "ToastSocketDisconnected": "Socket frånkopplad", "ToastSocketFailedToConnect": "Socket misslyckades med att ansluta", From 1d1bdb2f0056552a164b69faca910cc2616d188e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=93=D0=BE=D1=80?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D1=96=D1=87?= Date: Sat, 18 Jan 2025 20:29:23 +0000 Subject: [PATCH 108/118] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1082 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/uk.json b/client/strings/uk.json index 0d0f889167..d4cff07b0c 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -463,7 +463,7 @@ "LabelNotificationsMaxQueueSize": "Ліміт розміру черги сповіщень", "LabelNotificationsMaxQueueSizeHelp": "Події обмежені до 1 на секунду. Події буде проігноровано, якщо ліміт черги досягнуто. Це запобігає спаму сповіщеннями.", "LabelNumberOfBooks": "Кількість книг", - "LabelNumberOfEpisodes": "Кількість епізодів", + "LabelNumberOfEpisodes": "Кількість серій", "LabelOpenIDAdvancedPermsClaimDescription": "Назва OpenID claim, що містить розширені дозволи на дії користувачів у додатку, які будуть застосовуватися до ролей, що не є адміністраторами (якщо налаштовано). Якщо у відповіді нема claim, у доступі до Audiobookshelf буде відмовлено. Якщо відсутня хоча б одна опція, відповідь буде вважатися хибною. Переконайтеся, що запит постачальника ідентифікаційних даних відповідає очікуваній структурі:", "LabelOpenIDClaims": "Не змінюйте наступні параметри, аби вимкнути розширене призначення груп і дозволів, автоматично призначаючи групу 'Користувач'.", "LabelOpenIDGroupClaimDescription": "Ім'я OpenID claim, що містить список груп користувачів. Зазвичай їх називають групами. Якщо налаштовано, застосунок автоматично призначатиме ролі на основі членства користувача в групах, за умови, що ці групи названі в claim'і без урахування реєстру 'admin', 'user' або 'guest'. Claim мусить містити список, і якщо користувач належить до кількох груп, програма призначить йому роль, що відповідає найвищому рівню доступу. Якщо жодна група не збігається, у доступі буде відмовлено.", From 94df14f0cb105cf4f0abb15bf6868584a377232b Mon Sep 17 00:00:00 2001 From: Kieli Puoli Date: Sat, 18 Jan 2025 10:08:37 +0000 Subject: [PATCH 109/118] Translated using Weblate (Finnish) Currently translated at 50.9% (551 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fi/ --- client/strings/fi.json | 85 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/client/strings/fi.json b/client/strings/fi.json index c0a3ce0bb0..07587a13a3 100644 --- a/client/strings/fi.json +++ b/client/strings/fi.json @@ -185,10 +185,12 @@ "HeaderSettingsGeneral": "Yleiset", "HeaderSettingsScanner": "Skannaaja", "HeaderSleepTimer": "Uniajastin", + "HeaderStatsLargestItems": "Suurimmat kohteet", + "HeaderStatsLongestItems": "Pisimmät kohteet (h)", "HeaderStatsMinutesListeningChart": "Kuunteluminuutit (viim. 7 pv)", "HeaderStatsRecentSessions": "Viimeaikaiset istunnot", - "HeaderStatsTop10Authors": "Top 10 kirjailijat", - "HeaderStatsTop5Genres": "Top 5 lajityypit", + "HeaderStatsTop10Authors": "Suosituimmat 10 kirjailijaa", + "HeaderStatsTop5Genres": "Suosituimmat 5 lajityyppiä", "HeaderTableOfContents": "Sisällysluettelo", "HeaderTools": "Työkalut", "HeaderUpdateAccount": "Päivitä tili", @@ -199,6 +201,8 @@ "HeaderYearReview": "Vuosi {0} tarkasteltuna", "HeaderYourStats": "Tilastosi", "LabelAbridged": "Lyhennetty", + "LabelAbridgedChecked": "Lyhennetty (tarkistettu)", + "LabelAbridgedUnchecked": "Lyhentämätön (tarkistamaton)", "LabelAccountType": "Tilin tyyppi", "LabelAccountTypeAdmin": "Järjestelmänvalvoja", "LabelAccountTypeGuest": "Vieras", @@ -216,6 +220,7 @@ "LabelAllUsersExcludingGuests": "Kaikki käyttäjät vieraita lukuun ottamatta", "LabelAllUsersIncludingGuests": "Kaikki käyttäjät mukaan lukien vieraat", "LabelAlreadyInYourLibrary": "Jo kirjastossasi", + "LabelApiToken": "Sovellusliittymätunnus", "LabelAudioBitrate": "Äänen bittinopeus (esim. 128k)", "LabelAudioChannels": "Äänikanavat (1 tai 2)", "LabelAudioCodec": "Äänikoodekki", @@ -225,19 +230,30 @@ "LabelAuthors": "Tekijät", "LabelAutoDownloadEpisodes": "Lataa jaksot automaattisesti", "LabelAutoFetchMetadata": "Etsi metadata automaattisesti", + "LabelAutoLaunch": "Automaattinen käynnistys", + "LabelAutoRegister": "Automaattinen rekisteröinti", + "LabelAutoRegisterDescription": "Luo automaattisesti uusia käyttäjiä kirjautumisen jälkeen", "LabelBackToUser": "Takaisin käyttäjään", + "LabelBackupAudioFiles": "Varmuuskopioi äänitiedostot", "LabelBackupLocation": "Varmuuskopiointipaikka", "LabelBackupsEnableAutomaticBackups": "Ota automaattinen varmuuskopiointi käyttöön", "LabelBackupsEnableAutomaticBackupsHelp": "Varmuuskopiot tallennettu kansioon /metadata/backups", "LabelBackupsMaxBackupSize": "Varmuuskopion enimmäiskoko (Gt) (0 rajaton)", + "LabelBackupsMaxBackupSizeHelp": "Virheellisten asetusten estämiseksi varmuuskopiot epäonnistuvat, jos ne ovat asetettua kokoa suurempia.", "LabelBackupsNumberToKeep": "Säilytettävien varmuuskopioiden määrä", + "LabelBackupsNumberToKeepHelp": "Varmuuskopiot poistetaan yksi kerrallaan, joten jos niitä on enemmän kuin yksi, ne on poistettava manuaalisesti.", "LabelBitrate": "Bittinopeus", + "LabelBonus": "Bonus", "LabelBooks": "Kirjat", "LabelButtonText": "Painikkeen teksti", "LabelChangePassword": "Vaihda salasana", "LabelChannels": "Kanavat", + "LabelChapterCount": "{0} lukua", + "LabelChapterTitle": "Luvun nimi", "LabelChapters": "Luvut", + "LabelChaptersFound": "lukua löydetty", "LabelClickForMoreInfo": "Napsauta saadaksesi lisätietoja", + "LabelClickToUseCurrentValue": "Käytä nykyistä arvoa napsauttamalla", "LabelClosePlayer": "Sulje soitin", "LabelCodec": "Koodekki", "LabelCollapseSeries": "Pienennä sarja", @@ -252,7 +268,9 @@ "LabelCoverImageURL": "Kansikuvan URL-osoite", "LabelCreatedAt": "Luotu", "LabelCurrent": "Nykyinen", + "LabelCurrently": "Nyt:", "LabelDays": "Päivää", + "LabelDeleteFromFileSystemCheckbox": "Poista tiedostojärjestelmästä (poista merkintä, jos haluat poistaa vain tietokannasta)", "LabelDescription": "Kuvaus", "LabelDeselectAll": "Poista valinta kaikista", "LabelDevice": "Laite", @@ -264,6 +282,7 @@ "LabelDownloadNEpisodes": "Lataa {0} jaksoa", "LabelDownloadable": "Ladattavissa", "LabelDuration": "Kesto", + "LabelDurationComparisonExactMatch": "(tarkka vastaavuus)", "LabelDurationComparisonLonger": "({0} pidempi)", "LabelDurationComparisonShorter": "({0} lyhyempi)", "LabelDurationFound": "Kesto löydetty:", @@ -272,12 +291,15 @@ "LabelEdit": "Muokkaa", "LabelEmail": "Sähköposti", "LabelEmailSettingsFromAddress": "Osoitteesta", + "LabelEmailSettingsRejectUnauthorized": "Hylkää luvattomat sertifikaatit", "LabelEmailSettingsRejectUnauthorizedHelp": "SSL-sertifikaatin varmentamisen käytöstä poistaminen saattaa vaarantaa yhteytesti turvallisuusriskeihin, kuten man-in-the-middle hyökkäyksiin. Poista käytöstä vain jos ymmärrät vaaran ja luotat yhdistämääsi sähköpostipalvelimeen.", "LabelEmailSettingsSecure": "Turvallinen", "LabelEmailSettingsTestAddress": "Testiosoite", "LabelEmbeddedCover": "Upotettu kansikuva", "LabelEnable": "Ota käyttöön", "LabelEncodingBackupLocation": "Alkuperäisistä audiotiedostoistasi tallennetaan varmuuskopio osoitteessa:", + "LabelEncodingChaptersNotEmbedded": "Lukuja ei upoteta moniraitaisiin äänikirjoihin.", + "LabelEncodingInfoEmbedded": "Kuvailutiedot upotetaan äänikirjakansion ääniraitoihin.", "LabelEncodingStartedNavigation": "Voit poistua sivulta kun tehtävä on aloitettu.", "LabelEncodingTimeWarning": "Koodaus saattaa kestää 30 minuuttiin asti.", "LabelEncodingWarningAdvancedSettings": "Varoitus: Älä päivitä näitä asetuksia ellet ymmärrä ffmpeg-koodausasetuksia.", @@ -291,25 +313,40 @@ "LabelEpisodeUrlFromRssFeed": "Jakson URL RSS-syötteestä", "LabelEpisodes": "Jaksot", "LabelExample": "Esimerkki", + "LabelExpandSeries": "Laajenna sarja", + "LabelExpandSubSeries": "Laajenna alisarja", + "LabelExportOPML": "Vie OPML", "LabelFeedURL": "Syötteen URL", + "LabelFetchingMetadata": "Noudetaan kuvailutietoja", "LabelFile": "Tiedosto", "LabelFileBirthtime": "Tiedoston syntymäaika", "LabelFileBornDate": "Syntynyt {0}", "LabelFileModified": "Muutettu tiedosto", "LabelFileModifiedDate": "Muokattu {0}", "LabelFilename": "Tiedostonimi", + "LabelFilterByUser": "Suodata käyttäjien perusteella", "LabelFindEpisodes": "Etsi jaksoja", "LabelFinished": "Valmis", "LabelFolder": "Kansio", "LabelFolders": "Kansiot", + "LabelFontBold": "Lihavoitu", "LabelFontBoldness": "Kirjasintyyppien lihavointi", + "LabelFontFamily": "Kirjasinperhe", + "LabelFontItalic": "Kursiivi", "LabelFontScale": "Kirjasintyyppien skaalautuminen", + "LabelFontStrikethrough": "Yliviivattu", + "LabelFull": "Täynnä", "LabelGenre": "Lajityyppi", "LabelGenres": "Lajityypit", + "LabelHighestPriority": "Tärkein", "LabelHost": "Isäntä", "LabelHours": "Tunnit", + "LabelIcon": "Kuvake", + "LabelImageURLFromTheWeb": "Kuvan verkko-osoite", "LabelInProgress": "Kesken", "LabelIncomplete": "Keskeneräinen", + "LabelInterval": "Väli", + "LabelIntervalCustomDailyWeekly": "Mukautettu päivittäinen/viikoittainen", "LabelIntervalEvery12Hours": "12 tunnin välein", "LabelIntervalEvery15Minutes": "15 minuutin välein", "LabelIntervalEvery2Hours": "2 tunnin välein", @@ -323,20 +360,34 @@ "LabelLanguages": "Kielet", "LabelLastBookAdded": "Viimeisin lisätty kirja", "LabelLastBookUpdated": "Viimeisin päivitetty kirja", + "LabelLastSeen": "Nähty viimeksi", "LabelLastUpdate": "Viimeisin päivitys", "LabelLayout": "Asettelu", "LabelLayoutSinglePage": "Yksi sivu", + "LabelLayoutSplitPage": "Jaa sivu osiin", + "LabelLess": "Vähemmän", "LabelLibrariesAccessibleToUser": "Käyttäjälle saatavilla olevat kirjastot", "LabelLibrary": "Kirjasto", "LabelLibraryName": "Kirjaston nimi", + "LabelLimit": "Raja", "LabelLineSpacing": "Riviväli", "LabelListenAgain": "Kuuntele uudelleen", + "LabelLogLevelInfo": "Tiedot", + "LabelLogLevelWarn": "Varoita", "LabelLookForNewEpisodesAfterDate": "Etsi uusia jaksoja tämän päivämäärän jälkeen", + "LabelLowestPriority": "Vähiten tärkeä", "LabelMaxEpisodesToDownload": "Jaksojen maksimilatausmäärä. 0 poistaa rajoituksen.", + "LabelMaxEpisodesToKeep": "Säilytettävien jaksojen enimmäismäärä", + "LabelMaxEpisodesToKeepHelp": "Jos arvona on 0, enimmäisrajaa ei ole. Kun uusi jakso ladataan automaattisesti, vanhin jakso poistetaan, jos jaksoja on yli X. Tämä poistaa vain yhden jakson uutta latauskertaa kohden.", "LabelMediaPlayer": "Mediasoitin", "LabelMediaType": "Mediatyyppi", + "LabelMetaTag": "Metatunniste", + "LabelMetaTags": "Metatunnisteet", + "LabelMetadataOrderOfPrecedenceDescription": "Tärkeämmät kuvailutietojen lähteet ohittavat vähemmän tärkeät lähteet", + "LabelMetadataProvider": "Kuvailutietojen toimittaja", "LabelMinute": "Minuutti", "LabelMinutes": "Minuutit", + "LabelMissing": "Puuttuu", "LabelMissingEbook": "Ei e-kirjaa", "LabelMore": "Lisää", "LabelMoreInfo": "Lisätietoja", @@ -348,31 +399,61 @@ "LabelNewestAuthors": "Uusimmat kirjailijat", "LabelNewestEpisodes": "Uusimmat jaksot", "LabelNextBackupDate": "Seuraava varmuuskopiointipäivämäärä", + "LabelNextScheduledRun": "Seuraava ajastettu suorittaminen", + "LabelNoCustomMetadataProviders": "Ei mukautettuja kuvailutietojen toimittajia", + "LabelNoEpisodesSelected": "Jaksoja ei ole valittu", + "LabelNotFinished": "Ei valmis", "LabelNotStarted": "Ei aloitettu", + "LabelNotes": "Muistiinpanoja", + "LabelNotificationAvailableVariables": "Käytettävissä olevat muuttujat", + "LabelNotificationEvent": "Ilmoitustapahtuma", + "LabelNotificationsMaxFailedAttempts": "Epäonnistuneiden yritysten enimmäismäärä", + "LabelNotificationsMaxFailedAttemptsHelp": "Ilmoitukset poistetaan käytöstä, jos niiden lähettäminen epäonnistuu näin monta kertaa", + "LabelNotificationsMaxQueueSize": "Ilmoitustapahtumajonon enimmäispituus", + "LabelNumberOfBooks": "Kirjojen määrä", + "LabelNumberOfEpisodes": "Jaksojen määrä", + "LabelOverwrite": "Korvaa", + "LabelPaginationPageXOfY": "Sivu {0}/{1}", "LabelPassword": "Salasana", "LabelPath": "Polku", "LabelPermanent": "Pysyvä", "LabelPermissionsAccessAllLibraries": "Käyttöoikeudet kaikkiin kirjastoihin", + "LabelPermissionsAccessAllTags": "Saa käyttää kaikkia tunnisteita", + "LabelPermissionsAccessExplicitContent": "Saa käyttää aikuisille tarkoitettua sisältöä", "LabelPermissionsDelete": "Voi poistaa", "LabelPermissionsDownload": "Voi ladata", "LabelPermissionsUpdate": "Voi päivittää", "LabelPermissionsUpload": "Voi lähettää", + "LabelPlayMethod": "Toistotapa", + "LabelPlayerChapterNumberMarker": "{0}/{1}", "LabelPlaylists": "Soittolistat", "LabelPodcast": "Podcast", + "LabelPodcastSearchRegion": "Podcastien hakualue", + "LabelPodcastType": "Podcastien tyyppi", "LabelPodcasts": "Podcastit", "LabelPort": "Portti", + "LabelPrimaryEbook": "Ensisijainen e-kirja", + "LabelProgress": "Edistyminen", + "LabelProvider": "Toimittaja", + "LabelPubDate": "Julkaisupäivä", "LabelPublishYear": "Julkaisuvuosi", + "LabelPublishedDate": "Julkaistu {0}", "LabelPublisher": "Julkaisija", "LabelPublishers": "Julkaisijat", "LabelRSSFeedPreventIndexing": "Estä indeksointi", "LabelRandomly": "Satunnaisesti", "LabelRead": "Lue", "LabelReadAgain": "Lue uudelleen", + "LabelReadEbookWithoutProgress": "Lue e-kirja tallentamatta edistymistietoja", "LabelRecentSeries": "Viimeisimmät sarjat", "LabelRecentlyAdded": "Viimeeksi lisätyt", "LabelRecommended": "Suositeltu", + "LabelRedo": "Tee uudelleen", "LabelRegion": "Alue", + "LabelReleaseDate": "Julkaisupäivä", "LabelRemoveCover": "Poista kansikuva", + "LabelRowsPerPage": "Rivejä sivulla", + "LabelSearchTerm": "Hakusana", "LabelSeason": "Kausi", "LabelSelectAll": "Valitse kaikki", "LabelSelectUsers": "Valitse käyttäjät", From 3a33553aec45355822a7b23d14684e5272b50c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=93=D0=BE=D1=80?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D1=96=D1=87?= Date: Sat, 18 Jan 2025 20:53:25 +0000 Subject: [PATCH 110/118] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1082 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/uk.json b/client/strings/uk.json index d4cff07b0c..c88c34056a 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -51,7 +51,7 @@ "ButtonNext": "Наступний", "ButtonNextChapter": "Наступна глава", "ButtonNextItemInQueue": "Наступний елемент у черзі", - "ButtonOk": "Гаразд", + "ButtonOk": "Добре", "ButtonOpenFeed": "Відкрити стрічку", "ButtonOpenManager": "Відкрити менеджер", "ButtonPause": "Пауза", From 831f9ab9e7238906cf9d6f3b5afea3a3ffa0d0e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=20=D0=A7=D0=B5=D1=80=D0=B2=D0=BE?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B9?= Date: Sun, 19 Jan 2025 06:38:20 +0000 Subject: [PATCH 111/118] Translated using Weblate (Russian) Currently translated at 100.0% (1082 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/ --- client/strings/ru.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/ru.json b/client/strings/ru.json index 716a09ea47..84a176f214 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -51,7 +51,7 @@ "ButtonNext": "Следующий", "ButtonNextChapter": "Следующая глава", "ButtonNextItemInQueue": "Следующий элемент в очереди", - "ButtonOk": "Ok", + "ButtonOk": "Ок", "ButtonOpenFeed": "Открыть канал", "ButtonOpenManager": "Открыть менеджер", "ButtonPause": "Пауза", From 0ac92b6dc1556ad1d10ca6d9d40dcbc4bbe53e08 Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Sun, 19 Jan 2025 13:44:01 +0000 Subject: [PATCH 112/118] Translated using Weblate (Swedish) Currently translated at 82.7% (895 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index 1b1ae6e2ff..ec83a708d8 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -209,6 +209,7 @@ "LabelAddToPlaylist": "Lägg till i Spellista", "LabelAddToPlaylistBatch": "Lägg till {0} objekt i Spellistan", "LabelAddedAt": "Datum adderad", + "LabelAddedDate": "Adderad {0}", "LabelAdminUsersOnly": "Endast administratörer", "LabelAll": "Alla", "LabelAllUsers": "Alla användare", @@ -248,6 +249,7 @@ "LabelClickForMoreInfo": "Klicka för mer information", "LabelClickToUseCurrentValue": "Klicka för att använda aktuellt värde", "LabelClosePlayer": "Stäng spelaren", + "LabelCodec": "Codec", "LabelCollapseSeries": "Komprimera serier", "LabelCollection": "Samling", "LabelCollections": "Samlingar", @@ -306,6 +308,7 @@ "LabelFile": "Fil", "LabelFileBirthtime": "Tidpunkt, filen skapades", "LabelFileModified": "Tidpunkt, filen ändrades", + "LabelFileModifiedDate": "Ändrad {0}", "LabelFilename": "Filnamn", "LabelFilterByUser": "Välj användare", "LabelFindEpisodes": "Hitta avsnitt", @@ -315,6 +318,7 @@ "LabelFontBold": "Fetstil", "LabelFontBoldness": "Fetstil", "LabelFontFamily": "Typsnittsfamilj", + "LabelFontItalic": "Kursiverad", "LabelFontScale": "Skala på typsnitt", "LabelFontStrikethrough": "Genomstruken", "LabelGenre": "Kategori", @@ -423,11 +427,13 @@ "LabelPersonalYearReview": "En sammanställning av ditt år, sidan {0}", "LabelPhotoPathURL": "Bildsökväg/URL", "LabelPlayMethod": "Spelläge", + "LabelPlayerChapterNumberMarker": "{0} av {1}", "LabelPlaylists": "Spellistor", "LabelPodcast": "Podcast", "LabelPodcastSearchRegion": "Podcast-sökområde", "LabelPodcastType": "Podcasttyp", "LabelPodcasts": "Podcasts", + "LabelPort": "Port", "LabelPrefixesToIgnore": "Prefix att ignorera (skiftlägesokänsligt)", "LabelPreventIndexing": "Förhindra att ditt flöde indexeras av iTunes och Google-podcastsökmotorer", "LabelPrimaryEbook": "Primär e-bok", @@ -450,6 +456,7 @@ "LabelRecentSeries": "Senaste serierna", "LabelRecentlyAdded": "Nyligen tillagda", "LabelRecommended": "Rekommenderad", + "LabelRegion": "Region", "LabelReleaseDate": "Utgivningsdatum", "LabelRemoveAllMetadataAbs": "Radera alla 'metadata.abs' filer", "LabelRemoveAllMetadataJson": "Radera alla 'metadata.json' filer", @@ -477,7 +484,7 @@ "LabelSettingsAllowIframe": "Tillåt att Audiobookshelf får visas i en iframe", "LabelSettingsAudiobooksOnly": "Endast ljudböcker", "LabelSettingsAudiobooksOnlyHelp": "När detta alternativ aktiveras kommer filer med e-böcker
att ignoreras om de inte lagras i en mapp med en ljudbok.
I det fallet kommer de att anges som en kompletterande e-bok", - "LabelSettingsBookshelfViewHelp": "Skeumorfisk design med trähyllor", + "LabelSettingsBookshelfViewHelp": "Bakgrund med ett utseende liknande en bokhylla i trä", "LabelSettingsChromecastSupport": "Stöd för Chromecast", "LabelSettingsDateFormat": "Datumformat", "LabelSettingsDisableWatcher": "Inaktivera Watcher", @@ -516,11 +523,14 @@ "LabelSettingsStoreMetadataWithItem": "Lagra metadata med objektet", "LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i mappen '/metadata/items'. Genom att aktivera detta alternativ kommer metadatafilerna att lagra i din biblioteksmapp", "LabelSettingsTimeFormat": "Tidsformat", + "LabelShare": "Dela", "LabelShowAll": "Visa alla", "LabelShowSeconds": "Visa sekunder", "LabelShowSubtitles": "Visa underrubriker", "LabelSize": "Storlek", "LabelSleepTimer": "Timer för sova", + "LabelSortAscending": "Stigande", + "LabelSortDescending": "Fallande", "LabelStart": "Starta", "LabelStartTime": "Starttid", "LabelStarted": "Startad", @@ -588,10 +598,13 @@ "LabelUpdateDetailsHelp": "Tillåt att befintliga detaljer för de valda böckerna ersätts när en matchning hittas", "LabelUpdatedAt": "Uppdaterades", "LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar", + "LabelUploaderDragAndDropFilesOnly": "Dra & släpp filer", "LabelUploaderDropFiles": "Släpp filer", "LabelUploaderItemFetchMetadataHelp": "Hämtar automatiskt titel, författare och serier", + "LabelUseAdvancedOptions": "Använd avancerade inställningar", "LabelUseChapterTrack": "Använd kapitelspår", "LabelUseFullTrack": "Använd hela spåret", + "LabelUseZeroForUnlimited": "0 = Obegränsad", "LabelUser": "Användare", "LabelUsername": "Användarnamn", "LabelValue": "Värde", @@ -602,6 +615,8 @@ "LabelViewQueue": "Visa spellista", "LabelVolume": "Volym", "LabelWeekdaysToRun": "Veckodagar att köra skanning", + "LabelXBooks": "{0} böcker", + "LabelXItems": "{0} objekt", "LabelYearReviewHide": "Dölj årets sammanställning", "LabelYearReviewShow": "Visa årets sammanställning", "LabelYourAudiobookDuration": "Din ljudboks varaktighet", @@ -611,6 +626,9 @@ "MessageAddToPlayerQueue": "Lägg till i spellistan", "MessageAppriseDescription": "För att använda den här funktionen behöver du ha en instans av Apprise API igång eller en API som hanterar dessa begäranden.
Apprise API-urlen bör vara hela URL-sökvägen för att skicka meddelandet, t.ex., om din API-instans är tillgänglig på http://192.168.1.1:8337, bör du ange http://192.168.1.1:8337/notify.", "MessageBackupsDescription": "Säkerhetskopior inkluderar användare, användarnas framsteg, biblioteksobjekt, serverinställningar
och bilder lagrade i /metadata/items & /metadata/authors.
De inkluderar INTE några filer lagrade i dina biblioteksmappar.", + "MessageBackupsLocationEditNote": "OBS: När du ändrar plats för säkerhetskopiorna så flyttas INTE gamla säkerhetskopior dit", + "MessageBackupsLocationNoEditNote": "OBS: Platsen där säkerhetskopiorna lagras bestäms av en central inställning och kan inte ändras här.", + "MessageBackupsLocationPathEmpty": "Uppgiften om platsen för lagring av säkerhetskopior kan inte lämnas tom", "MessageBatchQuickMatchDescription": "Quick Match kommer försöka lägga till saknade omslag och metadata för de valda föremålen. Aktivera alternativen nedan för att tillåta Quick Match att överskriva befintliga omslag och/eller metadata.", "MessageBookshelfNoCollections": "Du har ännu inte skapat några samlingar", "MessageBookshelfNoRSSFeeds": "Inga RSS-flöden är öppna", @@ -736,7 +754,10 @@ "MessageServerCouldNotBeReached": "Servern kunde inte nås", "MessageSetChaptersFromTracksDescription": "Ställ in kapitel med varje ljudfil som ett kapitel och kapitelrubrik som ljudfilens namn", "MessageStartPlaybackAtTime": "Starta uppspelning av \"{0}\" vid tidpunkt {1}?", + "MessageTaskCanceledByUser": "Uppgiften avslutades av användaren", + "MessageTaskFailed": "Misslyckades", "MessageTaskFailedToCreateCacheDirectory": "Misslyckades med att skapa bibliotek för cachen", + "MessageTaskMatchingBooksInLibrary": "Matchar böcker i biblioteket \"{0}\"", "MessageThinking": "Tänker...", "MessageUploaderItemFailed": "Misslyckades med att ladda upp", "MessageUploaderItemSuccess": "Uppladdning lyckades!", @@ -810,6 +831,8 @@ "ToastDateTimeInvalidOrIncomplete": "Datum och klockslag är felaktigt eller ej komplett", "ToastDeleteFileFailed": "Misslyckades att radera filen", "ToastDeleteFileSuccess": "Filen har raderats", + "ToastDeviceTestEmailFailed": "Misslyckades med att skicka ett testmail", + "ToastDeviceTestEmailSuccess": "Ett testmail har skickats", "ToastEmailSettingsUpdateSuccess": "Inställningarna av e-post har uppdaterats", "ToastFailedToLoadData": "Misslyckades med att ladda data", "ToastInvalidImageUrl": "Felaktig URL-adress till omslagsbilden", @@ -832,6 +855,10 @@ "ToastMetadataFilesRemovedNoneFound": "Inga 'metadata.{0}' filer hittades i biblioteket", "ToastMetadataFilesRemovedNoneRemoved": "Inga 'metadata.{0}' filer raderades", "ToastMetadataFilesRemovedSuccess": "{0} 'metadata.{1}' raderades", + "ToastNameEmailRequired": "Ett namn och en e-postadress måste anges", + "ToastNameRequired": "Ett namn måste anges", + "ToastNewUserCreatedFailed": "Misslyckades med att skapa kontot \"{0}\"", + "ToastNewUserCreatedSuccess": "Ett nytt konto har skapats", "ToastNoUpdatesNecessary": "Inga uppdateringar var nödvändiga", "ToastPlaylistCreateFailed": "Det gick inte att skapa spellistan", "ToastPlaylistCreateSuccess": "Spellistan skapad", @@ -839,6 +866,9 @@ "ToastPlaylistUpdateSuccess": "Spellistan uppdaterad", "ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten", "ToastPodcastCreateSuccess": "Podcasten skapad framgångsrikt", + "ToastProviderCreatedFailed": "Misslyckades med att addera en källa", + "ToastProviderCreatedSuccess": "En ny källa har adderats", + "ToastProviderRemoveSuccess": "Källan har tagits bort", "ToastRSSFeedCloseFailed": "Misslyckades med att stänga RSS-flödet", "ToastRSSFeedCloseSuccess": "RSS-flödet stängt", "ToastRemoveItemFromCollectionFailed": "Misslyckades med att ta bort objektet från samlingen", From 152683ff9c439f5f095c773359117d57cf2fca1e Mon Sep 17 00:00:00 2001 From: thehijacker Date: Sun, 19 Jan 2025 10:12:18 +0000 Subject: [PATCH 113/118] Translated using Weblate (Slovenian) Currently translated at 100.0% (1082 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index c0dc9bf3e7..73c2504beb 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -463,7 +463,7 @@ "LabelNotificationsMaxQueueSize": "Največja velikost čakalne vrste za dogodke obvestil", "LabelNotificationsMaxQueueSizeHelp": "Dogodki so omejeni na sprožitev 1 na sekundo. Dogodki bodo prezrti, če je čakalna vrsta najvišja. To preprečuje neželeno pošiljanje obvestil.", "LabelNumberOfBooks": "Število knjig", - "LabelNumberOfEpisodes": "število epizod", + "LabelNumberOfEpisodes": "# epizod", "LabelOpenIDAdvancedPermsClaimDescription": "Ime zahtevka OpenID, ki vsebuje napredna dovoljenja za uporabniška dejanja v aplikaciji, ki bodo veljala za neskrbniške vloge (če je konfigurirano). Če trditev manjka v odgovoru, bo dostop do ABS zavrnjen. Če ena možnost manjka, bo obravnavana kot false. Zagotovite, da se zahtevek ponudnika identitete ujema s pričakovano strukturo:", "LabelOpenIDClaims": "Pustite naslednje možnosti prazne, da onemogočite napredno dodeljevanje skupin in dovoljenj, nato pa samodejno dodelite skupino 'Uporabnik'.", "LabelOpenIDGroupClaimDescription": "Ime zahtevka OpenID, ki vsebuje seznam uporabnikovih skupin. Običajno imenovane skupine. Če je konfigurirana, bo aplikacija samodejno dodelila vloge na podlagi članstva v skupini uporabnika, pod pogojem, da so te skupine v zahtevku poimenovane 'admin', 'user' ali 'guest' brez razlikovanja med velikimi in malimi črkami. Zahtevek mora vsebovati seznam in če uporabnik pripada več skupinam, mu aplikacija dodeli vlogo, ki ustreza najvišjemu nivoju dostopa. Če se nobena skupina ne ujema, bo dostop zavrnjen.", From ca5c8a4d419f01cabd2b0010dddaafe0065c8dc0 Mon Sep 17 00:00:00 2001 From: "J. Lavoie" Date: Sun, 19 Jan 2025 17:49:53 +0000 Subject: [PATCH 114/118] Translated using Weblate (French) Currently translated at 98.8% (1070 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index a2e461246c..d9c97c35e6 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -51,7 +51,7 @@ "ButtonNext": "Suivant", "ButtonNextChapter": "Chapitre suivant", "ButtonNextItemInQueue": "Élément suivant dans la file d’attente", - "ButtonOk": "D’accord", + "ButtonOk": "D'accord", "ButtonOpenFeed": "Ouvrir le flux", "ButtonOpenManager": "Ouvrir le gestionnaire", "ButtonPause": "Pause", From d8de61437cedde8a9f40a28bd6a8ec6553e48f35 Mon Sep 17 00:00:00 2001 From: "J. Lavoie" Date: Sun, 19 Jan 2025 17:54:58 +0000 Subject: [PATCH 115/118] Translated using Weblate (German) Currently translated at 100.0% (1082 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/de.json b/client/strings/de.json index 0f09df40d1..db4e0629db 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -51,7 +51,7 @@ "ButtonNext": "Vor", "ButtonNextChapter": "Nächstes Kapitel", "ButtonNextItemInQueue": "Das nächste Element in der Warteschlange", - "ButtonOk": "OK", + "ButtonOk": "Einverstanden", "ButtonOpenFeed": "Feed öffnen", "ButtonOpenManager": "Manager öffnen", "ButtonPause": "Pausieren", From 61827643402690ad8e110d88b6396ebd7b541f55 Mon Sep 17 00:00:00 2001 From: "J. Lavoie" Date: Sun, 19 Jan 2025 17:52:09 +0000 Subject: [PATCH 116/118] Translated using Weblate (French) Currently translated at 98.8% (1070 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index d9c97c35e6..2ba75af1f5 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -459,7 +459,7 @@ "LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente", "LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Les notifications seront ignorées si la file d’attente est à son maximum. Cela empêche un flot trop important.", "LabelNumberOfBooks": "Nombre de livres", - "LabelNumberOfEpisodes": "Nombre d’épisodes", + "LabelNumberOfEpisodes": "Nombre d'épisodes", "LabelOpenIDAdvancedPermsClaimDescription": "Nom de la demande OpenID qui contient des autorisations avancées pour les actions de l’utilisateur dans l’application, qui s’appliqueront à des rôles autres que celui d’administrateur (s’il est configuré). Si la demande est absente de la réponse, l’accès à ABS sera refusé. Si une seule option est manquante, elle sera considérée comme false. Assurez-vous que la demande du fournisseur d’identité correspond à la structure attendue :", "LabelOpenIDClaims": "Laissez les options suivantes vides pour désactiver l’attribution avancée de groupes et d’autorisations, en attribuant alors automatiquement le groupe « Utilisateur ».", "LabelOpenIDGroupClaimDescription": "Nom de la demande OpenID qui contient une liste des groupes de l’utilisateur. Communément appelé groups. Si elle est configurée, l’application attribuera automatiquement des rôles en fonction de l’appartenance de l’utilisateur à un groupe, à condition que ces groupes soient nommés -sensible à la casse- tel que « admin », « user » ou « guest » dans la demande. Elle doit contenir une liste, et si un utilisateur appartient à plusieurs groupes, l’application attribuera le rôle correspondant au niveau d’accès le plus élevé. Si aucun groupe ne correspond, l’accès sera refusé.", From 60add37ba0b4bb6c8f0383c7aacead6f10ee211a Mon Sep 17 00:00:00 2001 From: "J. Lavoie" Date: Sun, 19 Jan 2025 17:55:15 +0000 Subject: [PATCH 117/118] Translated using Weblate (Italian) Currently translated at 98.6% (1067 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/it/ --- client/strings/it.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/it.json b/client/strings/it.json index a8f4c3296b..712320e9fc 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -51,7 +51,7 @@ "ButtonNext": "Prossimo", "ButtonNextChapter": "Prossimo Capitolo", "ButtonNextItemInQueue": "Elemento successivo in coda", - "ButtonOk": "D’accordo", + "ButtonOk": "D'accordo", "ButtonOpenFeed": "Apri il flusso", "ButtonOpenManager": "Apri Manager", "ButtonPause": "Pausa", From 64992b33087fd77fc94a0a01ba7907ac7d415f72 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 19 Jan 2025 17:11:36 -0600 Subject: [PATCH 118/118] Version bump v2.18.0 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 822fe2cf8b..f896b93821 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.17.7", + "version": "2.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.17.7", + "version": "2.18.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 8a90d68e4c..32d408908d 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.17.7", + "version": "2.18.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 6fd8cc2e96..962b70566b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.17.7", + "version": "2.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.17.7", + "version": "2.18.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index ea73669c7d..8612a17ef3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.17.7", + "version": "2.18.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js",