From 9b01d11b27fa0e56debac8610dd02b1cea3aeecc Mon Sep 17 00:00:00 2001 From: Lauri Vuorela Date: Tue, 22 Oct 2024 23:58:09 +0200 Subject: [PATCH 001/509] allow setting createdAt and respect set finishedAt when syncing progress --- server/models/User.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/models/User.js b/server/models/User.js index aa63aea8..84f471e9 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -607,13 +607,14 @@ class User extends Model { ebookLocation: progressPayload.ebookLocation || null, ebookProgress: isNullOrNaN(progressPayload.ebookProgress) ? 0 : Number(progressPayload.ebookProgress), finishedAt: progressPayload.finishedAt || null, + createdAt: progressPayload.createdAt || new Date(), extraData: { libraryItemId: progressPayload.libraryItemId, progress: isNullOrNaN(progressPayload.progress) ? 0 : Number(progressPayload.progress) } } if (newMediaProgressPayload.isFinished) { - newMediaProgressPayload.finishedAt = new Date() + newMediaProgressPayload.finishedAt = newMediaProgressPayload.finishedAt || new Date() newMediaProgressPayload.extraData.progress = 1 } else { newMediaProgressPayload.finishedAt = null From 33e0987d7325191b82bc99b51ef3426b5d300e80 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Sat, 7 Dec 2024 10:09:14 +0100 Subject: [PATCH 002/509] Added mediaMetadata to playbackSessions --- server/managers/PlaybackSessionManager.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index ce43fc8c..d0daa3ba 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -175,6 +175,12 @@ class PlaybackSessionManager { // New session from local session = new PlaybackSession(sessionJson) session.deviceInfo = deviceInfo + // This makes sure that the client's metadata is preferred over the library's metadata, if available, to make a non-breaking change + if(session.mediaMetadata == null) { + // Only sync important metadata + const { title, subtitle, narrators, authors, series, genres } = libraryItem.media.metadata || {}; + session.mediaMetadata = { title, subtitle, narrators, authors, series, genres }; + } session.setDuration(libraryItem, sessionJson.episodeId) Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`) await Database.createPlaybackSession(session) From 89167543fa18e43eab06bf194bbc1996bb08b648 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Sat, 7 Dec 2024 10:25:52 +0100 Subject: [PATCH 003/509] added author for podcasts --- server/managers/PlaybackSessionManager.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index d0daa3ba..8dd7bc36 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -178,8 +178,8 @@ class PlaybackSessionManager { // This makes sure that the client's metadata is preferred over the library's metadata, if available, to make a non-breaking change if(session.mediaMetadata == null) { // Only sync important metadata - const { title, subtitle, narrators, authors, series, genres } = libraryItem.media.metadata || {}; - session.mediaMetadata = { title, subtitle, narrators, authors, series, genres }; + const { title, subtitle, narrators, authors, author, series, genres } = libraryItem.media.metadata || {}; + session.mediaMetadata = { title, subtitle, narrators, authors, series, genres, author}; } session.setDuration(libraryItem, sessionJson.episodeId) Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`) From 2fbb31e0ea84fabe0e08564141d427e3ab385cbf Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Sat, 7 Dec 2024 10:37:00 +0100 Subject: [PATCH 004/509] added null saftey and added displayTitle and displayAuthor --- server/managers/PlaybackSessionManager.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index 8dd7bc36..e8704470 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -178,8 +178,14 @@ class PlaybackSessionManager { // This makes sure that the client's metadata is preferred over the library's metadata, if available, to make a non-breaking change if(session.mediaMetadata == null) { // Only sync important metadata - const { title, subtitle, narrators, authors, author, series, genres } = libraryItem.media.metadata || {}; - session.mediaMetadata = { title, subtitle, narrators, authors, series, genres, author}; + const { title, subtitle, narrators, authors, author, series, genres } = libraryItem?.media?.metadata || {} + session.mediaMetadata = { title, subtitle, narrators, authors, series, genres, author} + } + if(session.displayTitle == null || session.displayTitle === '') { + session.displayTitle = libraryItem?.media?.metadata?.title ?? '' + } + if(session.displayAuthor == null || session.displayAuthor === '') { + session.displayAuthor = libraryItem?.media?.metadata?.authors?.map(a => a.name).join(', ') ?? libraryItem?.media?.metadata?.author ?? '' } session.setDuration(libraryItem, sessionJson.episodeId) Logger.debug(`[PlaybackSessionManager] Inserting new session for "${session.displayTitle}" (${session.id})`) From f9bbd7117405e393c8bef56653cb279575896c32 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Tue, 17 Dec 2024 15:27:37 +0100 Subject: [PATCH 005/509] added type to be saved. Should support podcasts It should support everything important from the podcast metadata: https://api.audiobookshelf.org/#podcast-metadata And the book metadata: https://api.audiobookshelf.org/#book-metadata --- server/managers/PlaybackSessionManager.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index e8704470..1f40f38e 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -178,8 +178,8 @@ class PlaybackSessionManager { // This makes sure that the client's metadata is preferred over the library's metadata, if available, to make a non-breaking change if(session.mediaMetadata == null) { // Only sync important metadata - const { title, subtitle, narrators, authors, author, series, genres } = libraryItem?.media?.metadata || {} - session.mediaMetadata = { title, subtitle, narrators, authors, series, genres, author} + const { title, subtitle, narrators, authors, author, series, genres, type } = libraryItem?.media?.metadata || {} + session.mediaMetadata = { title, subtitle, narrators, authors, author, series, genres, type } } if(session.displayTitle == null || session.displayTitle === '') { session.displayTitle = libraryItem?.media?.metadata?.title ?? '' From 4d2241769ec9a7d77926f99cd1986412da4eb0eb Mon Sep 17 00:00:00 2001 From: Toni Barth Date: Wed, 18 Dec 2024 19:15:09 +0100 Subject: [PATCH 006/509] also check for mrss item enclosures when extracting items --- server/utils/podcastUtils.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 627e24b2..0d362593 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -75,15 +75,19 @@ function extractPodcastMetadata(channel) { function extractEpisodeData(item) { // Episode must have url - if (!item.enclosure?.[0]?.['$']?.url) { + let enclosure + + if (item.enclosure?.[0]?.['$']?.url) { + enclosure = item.enclosure[0]['$'] + } else if(item['media:content']?.find(c => c?.['$']?.url && (c?.['$']?.type ?? "").startsWith("audio"))) { + enclosure = item['media:content'].find(c => (c['$']?.type ?? "").startsWith("audio"))['$'] + } else { Logger.error(`[podcastUtils] Invalid podcast episode data`) return null } const episode = { - enclosure: { - ...item.enclosure[0]['$'] - } + enclosure: enclosure, } episode.enclosure.url = episode.enclosure.url.trim() From 0d8d0a650bc41039fa23e059a2a735532a5a28c1 Mon Sep 17 00:00:00 2001 From: sbyrx Date: Wed, 1 Jan 2025 16:01:26 +0000 Subject: [PATCH 007/509] 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 46850cbb..183c28a3 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 c7024225..d9e77161 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 26bd1733..0c742407 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 008/509] 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 c7024225..db0f23a0 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 009/509] 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 17c7be83..8a5ab860 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) } - libraryItem.media.updateAudioTracks(orderedFileData) - await Database.updateLibraryItem(libraryItem) - SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded()) - res.json(libraryItem.toJSON()) + if (!Array.isArray(orderedFileData) || !orderedFileData.length) { + 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()) } /** * 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 ce43fc8c..aace3df7 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 a904f536..8f3e1cae 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 2aa41b70..412860d2 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 60f879d0..ec26e091 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 1fa32da7..23d237e0 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 010/509] 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 8a5ab860..f1d11c15 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) } - req.oldLibraryItem.updatedAt = Date.now() - await Database.updateLibraryItem(req.oldLibraryItem) - SocketAuthority.emitter('item_updated', req.oldLibraryItem.toJSONExpanded()) + + if (req.libraryItem.media.changed()) { + await req.libraryItem.media.save() + } + + await req.libraryItem.save() + + SocketAuthority.emitter('item_updated', req.libraryItem.toOldJSONExpanded()) res.sendStatus(200) } @@ -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 7911178e..36aecb97 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 2b3a697d..c995a446 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 412860d2..03e67a9e 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 84a37897..4656a028 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 8fdff988..4701e422 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 c7d91d0d..510575ed 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 6c808aaa..00bd44d3 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 011/509] 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 f1d11c15..b187d2b6 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 3fcd1cf8..846dd891 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 012/509] 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 b187d2b6..4b9ee894 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 846dd891..fb65cc4b 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 013/509] 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 4b9ee894..f08a6011 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 7a8c9bd0..a4dbe6b4 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 8f3e1cae..756a9dea 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 ec26e091..172e36a2 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 014/509] 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 4656a028..6578bae0 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 4701e422..5d455018 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 510575ed..d33b28ba 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 015/509] 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 235d25cd..a4239745 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 016/509] 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 35009447..81b58c99 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 017/509] 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 | 74 +++++++++++++++++++---- 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, 284 insertions(+), 193 deletions(-) diff --git a/server/controllers/SessionController.js b/server/controllers/SessionController.js index cc6c0fd7..c3361ce9 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 93c6e9fb..3e7ea1de 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 aace3df7..97c87bbe 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 756a9dea..4c2006a1 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,16 +374,6 @@ class Book extends Model { return this.audioFiles.filter((af) => !af.exclude) } - get trackList() { - let startOffset = 0 - return this.includedAudioFiles.map((af) => { - const track = structuredClone(af) - track.startOffset = startOffset - startOffset += track.duration - return track - }) - } - get hasMediaFiles() { return !!this.hasAudioTracks || !!this.ebookFile } @@ -385,6 +382,59 @@ class Book extends Model { return !!this.includedAudioFiles.length } + /** + * Supported mime types are sent from the web client and are retrieved using the browser Audio player "canPlayType" function. + * + * @param {string[]} supportedMimeTypes + * @returns {boolean} + */ + checkCanDirectPlay(supportedMimeTypes) { + if (!Array.isArray(supportedMimeTypes)) { + Logger.error(`[Book] checkCanDirectPlay: supportedMimeTypes is not an array`, supportedMimeTypes) + return false + } + return this.includedAudioFiles.every((af) => supportedMimeTypes.includes(af.mimeType)) + } + + /** + * Get the track list to be used in client audio players + * AudioTrack is the AudioFile with startOffset, contentUrl and title + * + * @param {string} libraryItemId + * @returns {AudioTrack[]} + */ + getTracklist(libraryItemId) { + let startOffset = 0 + return this.includedAudioFiles.map((af) => { + const track = structuredClone(af) + track.title = af.metadata.filename + track.startOffset = startOffset + track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}` + startOffset += track.duration + return track + }) + } + + /** + * + * @returns {ChapterObject[]} + */ + getChapters() { + return structuredClone(this.chapters) || [] + } + + getPlaybackTitle() { + return this.title + } + + getPlaybackAuthor() { + return this.authorName + } + + getPlaybackDuration() { + return this.duration + } + /** * Total file size of all audio files and ebook file * @@ -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 5825dd4e..0767577a 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 03e67a9e..3381b94a 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 2d7b3896..2d5be8f6 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 172e36a2..188c1070 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 23d237e0..24d07041 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 6578bae0..b1cdf43b 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 6950a544..ba031b66 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 2ab6f503..5f4feeef 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 69a9b2f0..945e0e56 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 5d455018..488c3aac 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 d33b28ba..2a009eb2 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 c6192f11..0dfe1dbf 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 018/509] 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 bd14fbd5..2137b3c1 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 f08a6011..5a46be4b 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 7817211f..ec56248d 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 235d25cd..4402fd04 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 019/509] 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 5a46be4b..4da68866 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 020/509] 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 4da68866..74b8bdfc 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 021/509] 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 b87f89c7..9702ce38 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 2084ddee..85cfb4ff 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 3610c2ea..c62742a5 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] + } } - res.json(libraryItem.toJSONExpanded()) + 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(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 a4dbe6b4..c61fb049 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 456927c8..92053707 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 188c1070..fd471305 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 24d07041..4c9967f8 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 eb9f059a..ecda4a47 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 945e0e56..e759a0eb 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 2a009eb2..8d6b541d 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 c7024225..06e20f1d 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 022/509] 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 2ab6f503..1a94a92e 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 023/509] Update podcasts to new library item model --- server/controllers/PodcastController.js | 100 +++++-- server/managers/CoverManager.js | 7 +- server/managers/CronManager.js | 2 +- server/managers/NotificationManager.js | 15 +- server/managers/PodcastManager.js | 292 +++++++++++++-------- 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, 416 insertions(+), 281 deletions(-) diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index c62742a5..3d8ff240 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 + 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') } - const libraryItem = new LibraryItem() - libraryItem.setData('podcast', libraryItemPayload) + 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 c995a446..945c69ab 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 c61fb049..3f948583 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 c48e878c..8edcf428 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 92053707..bd42e74b 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 - } + let newLibraryItem = null + const transaction = await Database.sequelize.transaction() + try { + const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath) - 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 + 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 fd471305..aa7afbac 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 4c9967f8..c1e66fdf 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 b1cdf43b..17d7484c 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 ecda4a47..ffdad9f0 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 e759a0eb..6a3f4cf6 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 8d6b541d..5f43ebc8 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 8300e93a..0df40df0 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 06e20f1d..f86df9eb 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 26bd1733..d28c3b9d 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 024/509] 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 2137b3c1..45af5248 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 45bbdf84..a6b5a2f4 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 6986f2b7..00b82ce9 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 f42a023d..216f7595 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 74b8bdfc..14a85f6e 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 cc67b320..87acd221 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 b35619b7..48eca3f8 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 8c13ecb2..972c352a 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 bd42e74b..0b45dfc4 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 c8f62e69..d5f36ba9 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 3381b94a..0d87a328 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 ec56248d..35bd6c99 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 5f43ebc8..f27f3fa2 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 5edfc2e2..bd99060c 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 a52350f6..c4f6410d 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 8337f5aa..eb42c81c 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 57ca48ba..60c07805 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 ccce5304..9e74276a 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 c7c0914b..0aaf6f4b 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 2e0e2346..ed71e5b3 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 025/509] 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 916b4268..5d433e0a 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 97c87bbe..25992f0a 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 026/509] 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 45af5248..61385981 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 a6b5a2f4..31c94307 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 0d87a328..31a6a0b4 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 17d7484c..d955356e 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 027/509] 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 | 144 +++++++--- 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, 435 insertions(+), 845 deletions(-) diff --git a/server/Database.js b/server/Database.js index 61385981..82a8fbd1 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 31c94307..47150883 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 14a85f6e..3a4fb159 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 (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?.seriesAdded.length) { + seriesUpdateData.seriesAdded.forEach((se) => { + Database.addSeriesToFilterData(libraryItem.libraryId, se.name, se.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 3d8ff240..1d1c106d 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 a19ff876..51aaa910 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 47d1118c..f4323094 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 945c69ab..c8f88910 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 f3bbba57..287b6697 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 4c2006a1..dff79da2 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -542,49 +542,113 @@ 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)) - - 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 } + /** + * Creates or removes authors from the book using the author names from the request + * + * @param {string[]} authors + * @param {string} libraryId + * @returns {Promise<{authorsRemoved: import('./Author')[], authorsAdded: import('./Author')[]}>} + */ + async updateAuthorsFromRequest(authors, libraryId) { + if (!Array.isArray(authors)) return null + + if (!this.authors) { + throw new Error(`[Book] Cannot update authors because authors are not loaded for book ${this.id}`) + } + + /** @type {typeof import('./Author')} */ + const authorModel = this.sequelize.models.author + + /** @type {typeof import('./BookAuthor')} */ + const bookAuthorModel = this.sequelize.models.bookAuthor + + const authorsCleaned = authors.map((a) => a.toLowerCase()).filter((a) => a) + const authorsRemoved = this.authors.filter((au) => !authorsCleaned.includes(au.name.toLowerCase())) + const newAuthorNames = authors.filter((a) => !this.authors.some((au) => au.name.toLowerCase() === a.toLowerCase())) + + for (const author of authorsRemoved) { + await bookAuthorModel.removeByIds(author.id, this.id) + Logger.debug(`[Book] "${this.title}" Removed author "${author.name}"`) + this.authors = this.authors.filter((au) => au.id !== author.id) + } + const authorsAdded = [] + for (const authorName of newAuthorNames) { + const author = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId) + await bookAuthorModel.create({ bookId: this.id, authorId: author.id }) + Logger.debug(`[Book] "${this.title}" Added author "${author.name}"`) + this.authors.push(author) + authorsAdded.push(author) + } + + return { + authorsRemoved, + authorsAdded + } + } + + /** + * Creates or removes series from the book using the series names from the request. + * Updates series sequence if it has changed. + * + * @param {{ name: string, sequence: string }[]} seriesObjects + * @param {string} libraryId + * @returns {Promise<{seriesRemoved: import('./Series')[], seriesAdded: import('./Series')[], hasUpdates: boolean}>} + */ + async updateSeriesFromRequest(seriesObjects, libraryId) { + if (!Array.isArray(seriesObjects) || seriesObjects.some((se) => !se.name || typeof se.name !== 'string')) return null + + if (!this.series) { + throw new Error(`[Book] Cannot update series because series are not loaded for book ${this.id}`) + } + + /** @type {typeof import('./Series')} */ + const seriesModel = this.sequelize.models.series + + /** @type {typeof import('./BookSeries')} */ + const bookSeriesModel = this.sequelize.models.bookSeries + + const seriesNamesCleaned = seriesObjects.map((se) => se.name.toLowerCase()) + const seriesRemoved = this.series.filter((se) => !seriesNamesCleaned.includes(se.name.toLowerCase())) + const seriesAdded = [] + let hasUpdates = false + for (const seriesObj of seriesObjects) { + const seriesObjSequence = typeof seriesObj.sequence === 'string' ? seriesObj.sequence : null + + const existingSeries = this.series.find((se) => se.name.toLowerCase() === seriesObj.name.toLowerCase()) + if (existingSeries) { + if (existingSeries.bookSeries.sequence !== seriesObjSequence) { + existingSeries.bookSeries.sequence = seriesObjSequence + await existingSeries.bookSeries.save() + hasUpdates = true + Logger.debug(`[Book] "${this.title}" Updated series "${existingSeries.name}" sequence ${seriesObjSequence}`) + } + } else { + const series = await seriesModel.findOrCreateByNameAndLibrary(seriesObj.name, libraryId) + series.bookSeries = await bookSeriesModel.create({ bookId: this.id, seriesId: series.id, sequence: seriesObjSequence }) + this.series.push(series) + seriesAdded.push(series) + hasUpdates = true + Logger.debug(`[Book] "${this.title}" Added series "${series.name}"`) + } + } + + for (const series of seriesRemoved) { + await bookSeriesModel.removeByIds(series.id, this.id) + this.series = this.series.filter((se) => se.id !== series.id) + Logger.debug(`[Book] "${this.title}" Removed series ${series.id}`) + hasUpdates = true + } + + return { + seriesRemoved, + seriesAdded, + hasUpdates + } + } + /** * Old model kept metadata in a separate object */ diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 31a6a0b4..d19816a3 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 2d5be8f6..6bff17b8 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 c4bc1594..6ca28846 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 d955356e..3cf89b10 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 488c3aac..b270e0e7 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 f27f3fa2..2ec4a873 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 0dfe1dbf..5116f2f4 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 0df40df0..ccc94ce0 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 56f43dbf..db9e66c5 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 c4f6410d..1e92efde 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 942c4d02..5d4e1cc5 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 + + // For all series removed from book, check if it is empty now and should be removed + if (seriesIdsRemoved.length) { + await apiRouterCtx.checkRemoveEmptySeries(seriesIdsRemoved) + } } - if (!Object.keys(updatePayload.metadata).length) { - delete updatePayload.metadata + return { + updatePayload, + hasSeriesUpdates, + hasAuthorUpdates } - - return updatePayload } + /** + * + * @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 d28c3b9d..bc9892b2 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 028/509] 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 fb65cc4b..9972bd90 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 029/509] 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 8aa9f832..94122b46 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 ea70d73c..f6a56160 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 36aecb97..7471a1ca 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 dff79da2..5a4eee54 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 d19816a3..4035630d 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 80204ef5..bb827682 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 aa7afbac..084911bf 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 c1e66fdf..c6a1b9fa 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 b2a4fd2b..56d6ba0e 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 3cf89b10..00000000 --- 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 6a3f4cf6..00000000 --- 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 b270e0e7..00000000 --- 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 2ec4a873..00000000 --- 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 5116f2f4..00000000 --- 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 ccc94ce0..00000000 --- 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 5d4e1cc5..1a2a7aaf 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 8771ae7a..f81f889c 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 664bd6e3..5702071e 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 eb42c81c..1d4c4798 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 60c07805..5d5f0c83 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 0aaf6f4b..36241f33 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 030/509] 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 25992f0a..76c140fd 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 031/509] 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 c6a1b9fa..9eb14632 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 032/509] 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 4e491621..4fc8ca04 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 033/509] 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 85cfb4ff..9eaef85e 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 f653a18b..ee9ffb7a 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 102/509] 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 0767577a..4133f691 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 103/509] 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 d73b92d2..0ece483f 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 104/509] 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 b2f5e71b..361a5d33 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 105/509] 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 95cba8c9..276febd9 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 106/509] 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 361a5d33..a70cf73c 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 107/509] 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 276febd9..ae9ae306 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 108/509] 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 ae9ae306..3b52c175 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 109/509] 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 3b52c175..fa83969f 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 110/509] 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 a70cf73c..59963c39 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 111/509] 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 fa83969f..fc0cace9 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 112/509] 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 fc0cace9..c0a3ce0b 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 113/509] 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 59963c39..1b1ae6e2 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 114/509] 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 0d0f8891..d4cff07b 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 115/509] 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 c0a3ce0b..07587a13 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 116/509] 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 d4cff07b..c88c3405 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 117/509] 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 716a09ea..84a176f2 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 118/509] 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 1b1ae6e2..ec83a708 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 119/509] 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 c0dc9bf3..73c2504b 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 120/509] 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 a2e46124..d9c97c35 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 121/509] 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 0f09df40..db4e0629 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 122/509] 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 d9c97c35..2ba75af1 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 123/509] 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 a8f4c329..712320e9 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 124/509] 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 822fe2cf..f896b938 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 8a90d68e..32d40890 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 6fd8cc2e..962b7056 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 ea73669c..8612a17e 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", From 6c540ad789ece811b15da9255ee2eee50d13e935 Mon Sep 17 00:00:00 2001 From: mikiher Date: Mon, 20 Jan 2025 08:38:58 +0200 Subject: [PATCH 125/509] Fix missing texture image for subdirectory support --- client/assets/app.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/assets/app.css b/client/assets/app.css index 7daf74ff..0cd4ac49 100644 --- a/client/assets/app.css +++ b/client/assets/app.css @@ -5,7 +5,7 @@ @import './absicons.css'; :root { - --bookshelf-texture-img: url(/textures/wood_default.jpg); + --bookshelf-texture-img: url(~static/textures/wood_default.jpg); --bookshelf-divider-bg: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); } @@ -247,4 +247,4 @@ Bookshelf Label .abs-btn:disabled::before { background-color: rgba(0, 0, 0, 0.2); -} \ No newline at end of file +} From 6745efc4d6845f199fc65fa955f8677af9b24f7d Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 20 Jan 2025 08:59:45 -0600 Subject: [PATCH 126/509] Revert case-insensitive cache manager update in #3780 --- server/managers/ApiCacheManager.js | 3 +-- server/routers/ApiRouter.js | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/server/managers/ApiCacheManager.js b/server/managers/ApiCacheManager.js index 81b58c99..2d8eece8 100644 --- a/server/managers/ApiCacheManager.js +++ b/server/managers/ApiCacheManager.js @@ -42,8 +42,7 @@ 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}`) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index db9e66c5..5d706e65 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -65,7 +65,7 @@ class ApiRouter { // // Library Routes // - this.router.get(/^\/libraries/i, this.apiCacheManager.middleware) + this.router.get(/^\/libraries/, 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 78994b3589e65265cf28be33bff9284306404ffd Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 20 Jan 2025 09:06:45 -0600 Subject: [PATCH 127/509] Update epub ebook url to include routerBasePath --- client/components/readers/EpubReader.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue index 725e9cc1..350d8596 100644 --- a/client/components/readers/EpubReader.vue +++ b/client/components/readers/EpubReader.vue @@ -97,9 +97,9 @@ export default { }, ebookUrl() { if (this.fileId) { - return `/api/items/${this.libraryItemId}/ebook/${this.fileId}` + return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook/${this.fileId}` } - return `/api/items/${this.libraryItemId}/ebook` + return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook` }, themeRules() { const isDark = this.ereaderSettings.theme === 'dark' From 54a4b09592eef315d938c6ad384463f0d36fc0ad Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 20 Jan 2025 13:57:56 -0600 Subject: [PATCH 128/509] Update RSS feed to exclude empty tags, format duration, use CDATA --- server/models/Feed.js | 58 +++++++++++++++++++++++------------- server/models/FeedEpisode.js | 27 ++++++++++------- server/utils/index.js | 2 +- 3 files changed, 54 insertions(+), 33 deletions(-) diff --git a/server/models/Feed.js b/server/models/Feed.js index 41bca449..577dedfb 100644 --- a/server/models/Feed.js +++ b/server/models/Feed.js @@ -561,7 +561,42 @@ class Feed extends Model { * @param {string} hostPrefix */ buildXml(hostPrefix) { - const blockTags = [{ 'itunes:block': 'yes' }, { 'googleplay:block': 'yes' }] + const customElements = [ + { language: this.language || 'en' }, + { author: this.author || 'advplyr' }, + { 'itunes:author': this.author || 'advplyr' }, + { 'itunes:type': this.podcastType || 'serial' }, + { + 'itunes:image': { + _attr: { + href: `${hostPrefix}${this.imageURL}` + } + } + }, + { 'itunes:explicit': !!this.explicit } + ] + + if (this.description) { + customElements.push({ 'itunes:summary': { _cdata: this.description } }) + } + + const itunesOwnersData = [] + if (this.ownerName || this.author) { + itunesOwnersData.push({ 'itunes:name': this.ownerName || this.author }) + } + if (this.ownerEmail) { + itunesOwnersData.push({ 'itunes:email': this.ownerEmail }) + } + if (itunesOwnersData.length) { + customElements.push({ + 'itunes:owner': itunesOwnersData + }) + } + + if (this.preventIndexing) { + customElements.push({ 'itunes:block': 'yes' }, { 'googleplay:block': 'yes' }) + } + const rssData = { title: this.title, description: this.description || '', @@ -571,29 +606,10 @@ class Feed extends Model { image_url: `${hostPrefix}${this.imageURL}`, custom_namespaces: { itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd', - psc: 'http://podlove.org/simple-chapters', podcast: 'https://podcastindex.org/namespace/1.0', googleplay: 'http://www.google.com/schemas/play-podcasts/1.0' }, - custom_elements: [ - { language: this.language || 'en' }, - { author: this.author || 'advplyr' }, - { 'itunes:author': this.author || 'advplyr' }, - { 'itunes:summary': this.description || '' }, - { 'itunes:type': this.podcastType }, - { - 'itunes:image': { - _attr: { - href: `${hostPrefix}${this.imageURL}` - } - } - }, - { - 'itunes:owner': [{ 'itunes:name': this.ownerName || this.author || '' }, { 'itunes:email': this.ownerEmail || '' }] - }, - { 'itunes:explicit': !!this.explicit }, - ...(this.preventIndexing ? blockTags : []) - ] + custom_elements: customElements } const rssfeed = new RSS(rssData) diff --git a/server/models/FeedEpisode.js b/server/models/FeedEpisode.js index 4133f691..a2540a20 100644 --- a/server/models/FeedEpisode.js +++ b/server/models/FeedEpisode.js @@ -305,6 +305,21 @@ class FeedEpisode extends Model { * @param {string} hostPrefix */ getRSSData(hostPrefix) { + const customElements = [ + { 'itunes:author': this.author || null }, + { 'itunes:duration': Math.round(Number(this.duration)) }, + { 'itunes:summary': this.description || null }, + { + 'itunes:explicit': !!this.explicit + }, + { 'itunes:episodeType': this.episodeType || null }, + { 'itunes:season': this.season || null }, + { 'itunes:episode': this.episode || null } + ].filter((element) => { + // Remove empty custom elements + return Object.values(element)[0] !== null + }) + return { title: this.title, description: this.description || '', @@ -317,17 +332,7 @@ class FeedEpisode extends Model { type: this.enclosureType, size: this.enclosureSize }, - custom_elements: [ - { 'itunes:author': this.author }, - { 'itunes:duration': secondsToTimestamp(this.duration) }, - { 'itunes:summary': this.description || '' }, - { - 'itunes:explicit': !!this.explicit - }, - { 'itunes:episodeType': this.episodeType }, - { 'itunes:season': this.season }, - { 'itunes:episode': this.episode } - ] + custom_elements: customElements } } } diff --git a/server/utils/index.js b/server/utils/index.js index fa7ae92e..a8c0ff54 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -112,7 +112,7 @@ function secondsToTimestamp(seconds, includeMs = false, alwaysIncludeHours = fal var ms = _seconds - Math.floor(seconds) _seconds = Math.floor(_seconds) - var msString = '.' + (includeMs ? ms.toFixed(3) : '0.0').split('.')[1] + const msString = includeMs ? '.' + ms.toFixed(3).split('.')[1] : '' if (alwaysIncludeHours) { return `${_hours.toString().padStart(2, '0')}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}${msString}` } From a8b92819d1fcc70ae47cd54ec028310835a1cd0f Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 20 Jan 2025 14:04:18 -0600 Subject: [PATCH 129/509] Update feed episode description to use CDATA --- server/models/FeedEpisode.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/models/FeedEpisode.js b/server/models/FeedEpisode.js index a2540a20..5a0dbac3 100644 --- a/server/models/FeedEpisode.js +++ b/server/models/FeedEpisode.js @@ -308,7 +308,6 @@ class FeedEpisode extends Model { const customElements = [ { 'itunes:author': this.author || null }, { 'itunes:duration': Math.round(Number(this.duration)) }, - { 'itunes:summary': this.description || null }, { 'itunes:explicit': !!this.explicit }, @@ -319,6 +318,9 @@ class FeedEpisode extends Model { // Remove empty custom elements return Object.values(element)[0] !== null }) + if (this.description) { + customElements.push({ 'itunes:summary': { _cdata: this.description } }) + } return { title: this.title, From b816c0e7c439532d069589f3cf675b9822776b08 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 20 Jan 2025 14:18:22 -0600 Subject: [PATCH 130/509] Fix opening feed for series and collections --- server/models/FeedEpisode.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/models/FeedEpisode.js b/server/models/FeedEpisode.js index 5a0dbac3..a5fa7262 100644 --- a/server/models/FeedEpisode.js +++ b/server/models/FeedEpisode.js @@ -220,7 +220,7 @@ class FeedEpisode extends Model { const feedEpisodeObjs = [] let numExisting = 0 for (const book of books) { - const trackList = book.libraryItem.getTrackList() + const trackList = book.getTracklist(book.libraryItem.id) const useChapterTitles = this.checkUseChapterTitlesForEpisodes(trackList, book) for (const track of trackList) { // Check for existing episode by filepath From 56550157d1266b2780e901318d0ffe0b35c5e1ed Mon Sep 17 00:00:00 2001 From: "J. Lavoie" Date: Sun, 19 Jan 2025 18:05:13 +0000 Subject: [PATCH 131/509] 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 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index db4e0629..99d5bb1e 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -964,7 +964,7 @@ "ToastCollectionRemoveSuccess": "Sammlung entfernt", "ToastCollectionUpdateSuccess": "Sammlung aktualisiert", "ToastCoverUpdateFailed": "Cover-Update fehlgeschlagen", - "ToastDateTimeInvalidOrIncomplete": "Datum und Zeit ist ungültig oder unvollständig", + "ToastDateTimeInvalidOrIncomplete": "Datum und Zeit sind ungültig oder unvollständig", "ToastDeleteFileFailed": "Die Datei konnte nicht gelöscht werden", "ToastDeleteFileSuccess": "Datei gelöscht", "ToastDeviceAddFailed": "Gerät konnte nicht hinzugefügt werden", @@ -1017,7 +1017,7 @@ "ToastNewUserTagError": "Mindestens ein Tag muss ausgewählt sein", "ToastNewUserUsernameError": "Nutzername eingeben", "ToastNoNewEpisodesFound": "Keine neuen Episoden gefunden", - "ToastNoRSSFeed": "Podcast hat keinen RSS Feed", + "ToastNoRSSFeed": "Podcast hat keinen RSS-Feed", "ToastNoUpdatesNecessary": "Keine Änderungen nötig", "ToastNotificationCreateFailed": "Fehler beim erstellen der Benachrichtig", "ToastNotificationDeleteFailed": "Fehler beim löschen der Benachrichtigung", From ff537de132dd10d3e6f6b11e9781e17535361910 Mon Sep 17 00:00:00 2001 From: "J. Lavoie" Date: Sun, 19 Jan 2025 18:03:22 +0000 Subject: [PATCH 132/509] Translated using Weblate (French) Currently translated at 99.9% (1081 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/client/strings/fr.json b/client/strings/fr.json index 2ba75af1..fd5d2182 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -88,6 +88,8 @@ "ButtonSaveTracklist": "Sauvegarder la liste de lecture", "ButtonScan": "Analyser", "ButtonScanLibrary": "Analyser la bibliothèque", + "ButtonScrollLeft": "Défiler vers la gauche", + "ButtonScrollRight": "Défiler vers la droite", "ButtonSearch": "Chercher", "ButtonSelectFolderPath": "Sélectionner le chemin du dossier", "ButtonSeries": "Séries", @@ -190,6 +192,7 @@ "HeaderSettingsExperimental": "Fonctionnalités expérimentales", "HeaderSettingsGeneral": "Général", "HeaderSettingsScanner": "Analyseur", + "HeaderSettingsWebClient": "Client Web", "HeaderSleepTimer": "Minuterie", "HeaderStatsLargestItems": "Éléments les plus grands", "HeaderStatsLongestItems": "Éléments les plus long (hrs)", @@ -297,6 +300,7 @@ "LabelDiscover": "Découvrir", "LabelDownload": "Téléchargement", "LabelDownloadNEpisodes": "Télécharger {0} épisode(s)", + "LabelDownloadable": "Téléchargeable", "LabelDuration": "Durée", "LabelDurationComparisonExactMatch": "(correspondance exacte)", "LabelDurationComparisonLonger": "({0} plus long)", @@ -542,6 +546,7 @@ "LabelServerYearReview": "Bilan de l’année du serveur ({0})", "LabelSetEbookAsPrimary": "Définir comme principale", "LabelSetEbookAsSupplementary": "Définir comme supplémentaire", + "LabelSettingsAllowIframe": "Autoriser l'intégration dans une iframe", "LabelSettingsAudiobooksOnly": "Livres audios seulement", "LabelSettingsAudiobooksOnlyHelp": "L’activation de ce paramètre ignorera les fichiers de type « livre numériques », sauf s’ils se trouvent dans un dossier spécifique , auquel cas ils seront définis comme des livres numériques supplémentaires", "LabelSettingsBookshelfViewHelp": "Interface skeumorphique avec étagères en bois", @@ -584,6 +589,7 @@ "LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les fichiers de métadonnées sont stockés dans /metadata/items. En activant ce paramètre, les fichiers de métadonnées seront stockés dans les dossiers des éléments de votre bibliothèque", "LabelSettingsTimeFormat": "Format d’heure", "LabelShare": "Partager", + "LabelShareDownloadableHelp": "Permet aux utilisateurs de télécharger un fichier ZIP de l'élément de la bibliothèque.", "LabelShareOpen": "Ouvrir le partage", "LabelShareURL": "Partager l’URL", "LabelShowAll": "Tout afficher", @@ -681,6 +687,8 @@ "LabelViewPlayerSettings": "Afficher les paramètres du lecteur", "LabelViewQueue": "Afficher la liste de lecture", "LabelVolume": "Volume", + "LabelWebRedirectURLsDescription": "Autoriser ces URL dans votre fournisseur OAuth pour permettre la redirection vers l'application web après la connexion :", + "LabelWebRedirectURLsSubfolder": "Sous-dossier pour les URL de redirection", "LabelWeekdaysToRun": "Jours de la semaine à exécuter", "LabelXBooks": "{0} livres", "LabelXItems": "{0} éléments", @@ -750,6 +758,7 @@ "MessageConfirmResetProgress": "Êtes-vous sûr·e de vouloir réinitialiser votre progression ?", "MessageConfirmSendEbookToDevice": "Êtes-vous sûr·e de vouloir envoyer {0} livre numérique « {1} » à l'appareil « {2} » ?", "MessageConfirmUnlinkOpenId": "Êtes-vous sûr·e de vouloir dissocier cet utilisateur d’OpenID ?", + "MessageDaysListenedInTheLastYear": "{0} jours écoutés l'an dernier", "MessageDownloadingEpisode": "Téléchargement de l’épisode", "MessageDragFilesIntoTrackOrder": "Faites glisser les fichiers dans l’ordre correct des pistes", "MessageEmbedFailed": "Échec de l’intégration !", @@ -828,6 +837,7 @@ "MessageResetChaptersConfirm": "Êtes-vous sûr·e de vouloir réinitialiser les chapitres et annuler les changements effectués ?", "MessageRestoreBackupConfirm": "Êtes-vous sûr·e de vouloir restaurer la sauvegarde créée le", "MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items et /metadata/authors.

Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.

Tous les clients utilisant votre serveur seront automatiquement mis à jour.", + "MessageScheduleLibraryScanNote": "Pour la plupart des utilisateurs, il est recommandé de laisser cette fonctionnalité désactivée et de maintenir le réglage du moniteur de dossier activé. Le moniteur de dossier détectera automatiquement les changements dans vos dossiers de bibliothèque. Le moniteur de dossier ne fonctionne pas pour chaque système de fichiers (comme NFS) afin que les scans de bibliothèques programmés puissent être utilisés à la place.", "MessageSearchResultsFor": "Résultats de recherche pour", "MessageSelected": "{0} sélectionnés", "MessageServerCouldNotBeReached": "Serveur inaccessible", @@ -954,6 +964,7 @@ "ToastCollectionRemoveSuccess": "Collection supprimée", "ToastCollectionUpdateSuccess": "Collection mise à jour", "ToastCoverUpdateFailed": "Échec de la mise à jour de la couverture", + "ToastDateTimeInvalidOrIncomplete": "La date et l'heure sont invalides ou incomplètes", "ToastDeleteFileFailed": "Échec de la suppression du fichier", "ToastDeleteFileSuccess": "Fichier supprimé", "ToastDeviceAddFailed": "Échec de l’ajout de l’appareil", @@ -1006,6 +1017,7 @@ "ToastNewUserTagError": "Au moins une étiquette est requise", "ToastNewUserUsernameError": "Entrez un nom d’utilisateur", "ToastNoNewEpisodesFound": "Aucun nouvel épisode trouvé", + "ToastNoRSSFeed": "Le podcast n'a pas de flux RSS", "ToastNoUpdatesNecessary": "Aucune mise à jour nécessaire", "ToastNotificationCreateFailed": "La création de la notification à échouée", "ToastNotificationDeleteFailed": "La suppression de la notification à échouée", From 63dc81972865c397092544e4c5c0a2a1bf213cce Mon Sep 17 00:00:00 2001 From: "J. Lavoie" Date: Sun, 19 Jan 2025 17:59:29 +0000 Subject: [PATCH 133/509] Translated using Weblate (Italian) Currently translated at 98.7% (1068 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/it/ --- client/strings/it.json | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/client/strings/it.json b/client/strings/it.json index 712320e9..1d04521f 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -289,32 +289,33 @@ "LabelDescription": "Descrizione", "LabelDeselectAll": "Deseleziona Tutto", "LabelDevice": "Dispositivo", - "LabelDeviceInfo": "Info Dispositivo", - "LabelDeviceIsAvailableTo": "Il dispositivo e disponibile su...", + "LabelDeviceInfo": "Info dispositivo", + "LabelDeviceIsAvailableTo": "Il dispositivo e disponibile su…", "LabelDirectory": "Elenco", "LabelDiscFromFilename": "Disco dal nome file", - "LabelDiscFromMetadata": "Disco dal Metadata", + "LabelDiscFromMetadata": "Disco dai metadati", "LabelDiscover": "Scopri", "LabelDownload": "Scarica", - "LabelDownloadNEpisodes": "Download {0} episodi", + "LabelDownloadNEpisodes": "Scarica {0} episodi", + "LabelDownloadable": "Scaricabile", "LabelDuration": "Durata", "LabelDurationComparisonExactMatch": "(corrispondenza esatta)", "LabelDurationComparisonLonger": "({0} lungo)", "LabelDurationComparisonShorter": "({0} corto)", - "LabelDurationFound": "Durata Trovata:", + "LabelDurationFound": "Durata trovata:", "LabelEbook": "Libro digitale", "LabelEbooks": "Libri digitali", "LabelEdit": "Modifica", "LabelEmail": "E-mail", - "LabelEmailSettingsFromAddress": "Da Indirizzo", + "LabelEmailSettingsFromAddress": "Indirizzo del mittente", "LabelEmailSettingsRejectUnauthorized": "Rifiuta i certificati non autorizzati", "LabelEmailSettingsRejectUnauthorizedHelp": "La disattivazione della convalida del certificato SSL può esporre la tua connessione a rischi per la sicurezza, come attacchi man-in-the-middle. Disattiva questa opzione solo se ne comprendi le implicazioni e ti fidi del server di posta a cui ti stai connettendo.", - "LabelEmailSettingsSecure": "SSL", + "LabelEmailSettingsSecure": "Sicuro", "LabelEmailSettingsSecureHelp": "Se vero, la connessione utilizzerà TLS durante la connessione al server. Se false, viene utilizzato TLS se il server supporta l'estensione STARTTLS. Nella maggior parte dei casi impostare questo valore su true se ci si connette alla porta 465. Per la porta 587 o 25 mantenerlo false. (da nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Indirizzo di test", - "LabelEmbeddedCover": "Cover Integrata", + "LabelEmbeddedCover": "Copertina integrata", "LabelEnable": "Abilita", - "LabelEncodingBackupLocation": "il backup dei file audio verrà archiviato in:", + "LabelEncodingBackupLocation": "Un backup dei file audio verrà archiviato in:", "LabelEncodingChaptersNotEmbedded": "Negli audiolibri multitraccia i capitoli non sono incorporati.", "LabelEncodingClearItemCache": "Assicurati di svuotare periodicamente la cache degli oggetti.", "LabelEncodingFinishedM4B": "L'M4B completato verrà inserito nella cartella:", @@ -459,7 +460,7 @@ "LabelNotificationsMaxQueueSize": "Coda Massima di notifiche eventi", "LabelNotificationsMaxQueueSizeHelp": "Le notifiche sono limitate per 1 al secondo, per evitare lo spamming le notifiche verrano ignorare se superano la coda.", "LabelNumberOfBooks": "Numero di libri", - "LabelNumberOfEpisodes": "# degli episodi", + "LabelNumberOfEpisodes": "Numero di episodi", "LabelOpenIDAdvancedPermsClaimDescription": "Nome dell'attestazione OpenID che contiene autorizzazioni avanzate per le azioni dell'utente all'interno dell'applicazione che verranno applicate ai ruoli non amministratori (se configurato). Se il reclamo manca nella risposta, l'accesso ad ABS verrà negato. Se manca una singola opzione, verrà trattata comefalsa. Assicurati che l'attestazione del provider di identità corrisponda alla struttura prevista:", "LabelOpenIDClaims": "Lasciare vuote le seguenti opzioni per disabilitare l'assegnazione avanzata di gruppi e autorizzazioni, assegnando quindi automaticamente il gruppo \"Utente\".", "LabelOpenIDGroupClaimDescription": "Nome dell'attestazione OpenID che contiene un elenco dei gruppi dell'utente. Comunemente indicato come gruppo. se configurato, l'applicazione assegnerà automaticamente i ruoli in base alle appartenenze ai gruppi dell'utente, a condizione che tali gruppi siano denominati \"admin\", \"utente\" o \"ospite\" senza distinzione tra maiuscole e minuscole nell'attestazione. L'attestazione deve contenere un elenco e, se un utente appartiene a più gruppi, l'applicazione assegnerà il ruolo corrispondente al livello di accesso più alto. Se nessun gruppo corrisponde, l'accesso verrà negato.", From eea446e217ef34d0479f35d6d8cb568d512b74a5 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Mon, 20 Jan 2025 02:30:23 +0000 Subject: [PATCH 134/509] Translated using Weblate (Chinese (Simplified Han script)) 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/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 7f72ddef..bbd55ac3 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -463,7 +463,7 @@ "LabelNotificationsMaxQueueSize": "通知事件的最大队列大小", "LabelNotificationsMaxQueueSizeHelp": "通知事件被限制为每秒触发 1 个. 如果队列处于最大大小, 则将忽略事件. 这可以防止通知垃圾邮件.", "LabelNumberOfBooks": "图书数量", - "LabelNumberOfEpisodes": "# 集", + "LabelNumberOfEpisodes": "# 集数", "LabelOpenIDAdvancedPermsClaimDescription": "OpenID 声明的名称, 该声明包含应用程序内用户操作的高级权限, 该权限将应用于非管理员角色(如果已配置). 如果响应中缺少声明, 获取 ABS 的权限将被拒绝. 如果缺少单个选项, 它将被视为 禁用. 确保身份提供商的声明与预期结构匹配:", "LabelOpenIDClaims": "将以下选项留空以禁用高级组和权限分配, 然后自动分配 'User' 组.", "LabelOpenIDGroupClaimDescription": "OpenID 声明的名称, 该声明包含用户组的列表. 通常称为如果已配置, 应用程序将根据用户的组成员身份自动分配角色, 前提是这些组在声明中以不区分大小写的方式命名为 'Admin', 'User' 或 'Guest'. 声明应包含一个列表, 如果用户属于多个组, 则应用程序将分配与最高访问级别相对应的角色. 如果没有组匹配, 访问将被拒绝.", From 035590236b6031cc09b1523fb2744458edf20f23 Mon Sep 17 00:00:00 2001 From: Losicek Date: Mon, 20 Jan 2025 10:08:57 +0000 Subject: [PATCH 135/509] Translated using Weblate (Czech) 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/cs/ --- client/strings/cs.json | 84 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 6 deletions(-) diff --git a/client/strings/cs.json b/client/strings/cs.json index 3844b048..5fbcfb64 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -300,6 +300,7 @@ "LabelDiscover": "Objevit", "LabelDownload": "Stáhnout", "LabelDownloadNEpisodes": "Stáhnout {0} epizody", + "LabelDownloadable": "Ke stažení", "LabelDuration": "Délka trvání", "LabelDurationComparisonExactMatch": "(přesná shoda)", "LabelDurationComparisonLonger": "({0} delší)", @@ -588,6 +589,7 @@ "LabelSettingsStoreMetadataWithItemHelp": "Ve výchozím nastavení jsou soubory metadat uloženy v adresáři /metadata/items, povolením tohoto nastavení budou soubory metadat uloženy ve složkách položek knihovny", "LabelSettingsTimeFormat": "Formát času", "LabelShare": "Sdílet", + "LabelShareDownloadableHelp": "Umožňuje uživatelům s odkazem na sdílení stáhnout soubor zip.", "LabelShareOpen": "Otevřít sdílení", "LabelShareURL": "Sdílet URL", "LabelShowAll": "Zobrazit vše", @@ -822,6 +824,7 @@ "MessagePlaylistCreateFromCollection": "Vytvořit seznam skladeb z kolekce", "MessagePleaseWait": "Čekejte prosím...", "MessagePodcastHasNoRSSFeedForMatching": "Podcast nemá žádnou adresu URL kanálu RSS, kterou by mohl použít pro porovnávání", + "MessagePodcastSearchField": "Zadejte hledaný pojem pro RSS feed URL", "MessageQuickEmbedInProgress": "Probíhá rychlé vkládání", "MessageQuickEmbedQueue": "Zařazeno do fronty pro rychlé vložení ({0} ve frontě)", "MessageQuickMatchAllEpisodes": "Rychlá shoda všech epizod", @@ -834,6 +837,7 @@ "MessageResetChaptersConfirm": "Opravdu chcete resetovat kapitoly a vrátit zpět provedené změny?", "MessageRestoreBackupConfirm": "Opravdu chcete obnovit zálohu vytvořenou dne", "MessageRestoreBackupWarning": "Obnovení zálohy přepíše celou databázi umístěnou v /config a obálku obrázků v /metadata/items & /metadata/authors.

Backups nezmění žádné soubory ve složkách knihovny. Pokud jste povolili nastavení serveru pro ukládání obrázků obalu a metadat do složek knihovny, nebudou zálohovány ani přepsány.

Všichni klienti používající váš server budou automaticky obnoveni.", + "MessageScheduleLibraryScanNote": "Většině uživatelů se doporučuje ponechat tuto funkci vypnutou a ponechat zapnuté nastavení sledování složek. Sledování složek automaticky zjistí změny ve složkách vaší knihovny. Sledování složek nefunguje pro každý souborový systém (jako je NFS), takže místo toho lze použít plánované skenování knihoven.", "MessageSearchResultsFor": "Výsledky hledání pro", "MessageSelected": "{0} vybráno", "MessageServerCouldNotBeReached": "Server je nedostupný", @@ -843,7 +847,7 @@ "MessageShareURLWillBe": "Sdílené URL bude {0}", "MessageStartPlaybackAtTime": "Spustit přehrávání pro \"{0}\" v {1}?", "MessageTaskAudioFileNotWritable": "Nelze zapisovat do audio souboru \"{0}\"", - "MessageTaskCanceledByUser": "Task zrušen uživatelem", + "MessageTaskCanceledByUser": "Příkaz zrušen uživatelem", "MessageTaskDownloadingEpisodeDescription": "Stahování epizody \"{0}\"", "MessageTaskEmbeddingMetadata": "Vkládání metadat", "MessageTaskEmbeddingMetadataDescription": "Vkládání metadat do audioknihy \"{0}\"", @@ -857,7 +861,7 @@ "MessageTaskFailedToMoveM4bFile": "Přesunutí m4b souboru selhalo", "MessageTaskFailedToWriteMetadataFile": "Zápis souboru metadat selhal", "MessageTaskMatchingBooksInLibrary": "Párování knih v knihovně „{0}“", - "MessageTaskNoFilesToScan": "Žádné soubory ke skenování", + "MessageTaskNoFilesToScan": "Žádné soubory k prohledání", "MessageTaskOpmlImport": "Import OPML", "MessageTaskOpmlImportDescription": "Vytváření podcastů z {0} RSS feedů", "MessageTaskOpmlImportFeed": "Importní zdroj OPML", @@ -869,6 +873,7 @@ "MessageTaskOpmlImportFinished": "Přidáno {0} podcastů", "MessageTaskOpmlParseFailed": "Selhalo parsování OPML souboru", "MessageTaskOpmlParseFastFail": "Neplatný OPML soubor tag nenalezen NEBO tag nenalezen", + "MessageTaskOpmlParseNoneFound": "Feed nebyl nalezen v OPML souboru", "MessageTaskScanItemsAdded": "{0} přidáno", "MessageTaskScanItemsMissing": "{0} chybí", "MessageTaskScanItemsUpdated": "{0} aktualizováno", @@ -876,7 +881,7 @@ "MessageTaskScanningFileChanges": "Skenování změn souborů v \"{0}\"", "MessageTaskScanningLibrary": "Skenování \"{0}\" knihovny", "MessageTaskTargetDirectoryNotWritable": "Do cílové složky nelze zapisovat", - "MessageThinking": "Přemýšlení...", + "MessageThinking": "Přemýšlím...", "MessageUploaderItemFailed": "Nahrávání selhalo", "MessageUploaderItemSuccess": "Úspěšně nahráno!", "MessageUploading": "Nahrávám...", @@ -892,7 +897,7 @@ "NoteRSSFeedPodcastAppsPubDate": "Upozornění: 1 nebo více epizod nemá datum vydání. Některé podcastové aplikace to vyžadují.", "NoteUploaderFoldersWithMediaFiles": "Se složkami s multimediálními soubory bude zacházeno jako se samostatnými položkami knihovny.", "NoteUploaderOnlyAudioFiles": "Pokud nahráváte pouze zvukové soubory, bude s každým zvukovým souborem zacházeno jako se samostatnou audioknihou.", - "NoteUploaderUnsupportedFiles": "Nepodporované soubory jsou ignorovány. Při výběru nebo přetažení složky jsou ostatní soubory, které nejsou ve složce položek, ignorovány.", + "NoteUploaderUnsupportedFiles": "Nepodporované soubory jsou ignorovány. Při výběru nebo přetažení složky jsou ostatní soubory, které nejsou ve složce, ignorovány.", "NotificationOnBackupCompletedDescription": "Spuštěno po dokončení zálohování", "NotificationOnBackupFailedDescription": "Spuštěno pokud zálohování selže", "NotificationOnEpisodeDownloadedDescription": "Spuštěno při automatickém stažení epizody podcastu", @@ -909,7 +914,7 @@ "StatsBooksFinishedThisYear": "Některé knihy dokončené tento rok…", "StatsBooksListenedTo": "knih poslechnuto", "StatsCollectionGrewTo": "Vaše kolekce knih se rozrostla na…", - "StatsSessions": "sezení", + "StatsSessions": "sezóna", "StatsSpentListening": "stráveno posloucháním", "StatsTopAuthor": "TOP AUTOR", "StatsTopAuthors": "TOP AUTOŘI", @@ -942,6 +947,8 @@ "ToastBackupUploadSuccess": "Záloha nahrána", "ToastBatchDeleteFailed": "Hromadné smazání selhalo", "ToastBatchDeleteSuccess": "Hromadné smazání proběhlo úspěšně", + "ToastBatchQuickMatchFailed": "Rychlá schoda dávky se nezdařila!", + "ToastBatchQuickMatchStarted": "Začala rychlá shoda {0} knih!", "ToastBatchUpdateFailed": "Dávková aktualizace se nezdařila", "ToastBatchUpdateSuccess": "Dávková aktualizace proběhla úspěšně", "ToastBookmarkCreateFailed": "Vytvoření záložky se nezdařilo", @@ -952,9 +959,12 @@ "ToastChaptersHaveErrors": "Kapitoly obsahují chyby", "ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy", "ToastChaptersRemoved": "Kapitoly odstraněny", + "ToastChaptersUpdated": "Kapitola aktualizována", + "ToastCollectionItemsAddFailed": "Přidávání položek do kolekce selhalo", "ToastCollectionRemoveSuccess": "Kolekce odstraněna", "ToastCollectionUpdateSuccess": "Kolekce aktualizována", "ToastCoverUpdateFailed": "Aktualizace obálky selhala", + "ToastDateTimeInvalidOrIncomplete": "Datum a čas jsou chybné nebo nekompletní", "ToastDeleteFileFailed": "Nepodařilo se smazat soubor", "ToastDeleteFileSuccess": "Soubor smazán", "ToastDeviceAddFailed": "Přidání zařízení selhalo", @@ -962,12 +972,18 @@ "ToastDeviceTestEmailFailed": "Odeslání testovacího emailu selhalo", "ToastDeviceTestEmailSuccess": "Testovací email byl odeslán", "ToastEmailSettingsUpdateSuccess": "Nastavení emailu aktualizována", + "ToastEncodeCancelFailed": "Chyba zrušení kódování", + "ToastEncodeCancelSucces": "Kódování zrušeno", "ToastEpisodeDownloadQueueClearFailed": "Vyčištění fronty selhalo", + "ToastEpisodeDownloadQueueClearSuccess": "Fronta stahování epizod je prázdná", + "ToastEpisodeUpdateSuccess": "{0} epizod aktualizováno", "ToastErrorCannotShare": "Na tomto zařízení nelze nativně sdílet", "ToastFailedToLoadData": "Nepodařilo se načíst data", + "ToastFailedToMatch": "Nepodařilo se spárovat", "ToastFailedToShare": "Sdílení selhalo", "ToastFailedToUpdate": "Aktualizace selhala", "ToastInvalidImageUrl": "Neplatná URL obrázku", + "ToastInvalidMaxEpisodesToDownload": "Neplatný maximální počet epizod ke stažení", "ToastInvalidUrl": "Neplatná URL", "ToastItemCoverUpdateSuccess": "Obálka předmětu byl aktualizována", "ToastItemDeletedFailed": "Smazání položky selhalo", @@ -985,28 +1001,84 @@ "ToastLibraryScanFailedToStart": "Nepodařilo se spustit kontrolu", "ToastLibraryScanStarted": "Kontrola knihovny spuštěna", "ToastLibraryUpdateSuccess": "Knihovna \"{0}\" aktualizována", + "ToastMatchAllAuthorsFailed": "Nepodařilo se přiřadit všechny autory", + "ToastMetadataFilesRemovedError": "Při odstraňování souborů metadat.{0} došlo k chybě", + "ToastMetadataFilesRemovedNoneFound": "Žádná metadata.{0} nebyla nalezena v knihovně", + "ToastMetadataFilesRemovedNoneRemoved": "Žádná metadata.{0} počet odstraněných souborů", + "ToastMetadataFilesRemovedSuccess": "{0} metadata.{1} soubor odstraněn", + "ToastMustHaveAtLeastOnePath": "Musí mít minimálně jednu cestu", + "ToastNameEmailRequired": "Jméno a email jsou vyžadovány", + "ToastNameRequired": "Jméno je vyžadováno", + "ToastNewEpisodesFound": "{0} nových epizod bylo nalezeno", + "ToastNewUserCreatedFailed": "Chyba při vytváření účtu: \"{0}\"", + "ToastNewUserCreatedSuccess": "Vytvořen nový účet", + "ToastNewUserLibraryError": "Musíte vybrat alespoň jednu knihovnu", + "ToastNewUserPasswordError": "Musí mít heslo, pouze uživatel root může mít prázdné heslo", + "ToastNewUserTagError": "Musíte vybrat alespoň jeden tag", + "ToastNewUserUsernameError": "Zadej uživatelské jméno", + "ToastNoNewEpisodesFound": "Nebyla nalezena žádná nová epizoda", + "ToastNoRSSFeed": "Podcast nemá RSS Feed", + "ToastNoUpdatesNecessary": "Nejsou potřeba žádné aktualizace", + "ToastNotificationCreateFailed": "Chyba při vytváření upozornění", + "ToastNotificationDeleteFailed": "Chyba při odstranění upozornění", + "ToastNotificationFailedMaximum": "Maximální počet chybných pokusů >= 0", + "ToastNotificationQueueMaximum": "Maximální počet upozornění ve frontě musí být >= 0", + "ToastNotificationSettingsUpdateSuccess": "Nastavení upozornění aktualizováno", + "ToastNotificationTestTriggerFailed": "Chyba při spuštění testovacího upozornění", + "ToastNotificationTestTriggerSuccess": "Spuštěno testovací upozornění", + "ToastNotificationUpdateSuccess": "Upozornění aktualizováno", "ToastPlaylistCreateFailed": "Vytvoření seznamu přehrávání se nezdařilo", "ToastPlaylistCreateSuccess": "Seznam přehrávání vytvořen", "ToastPlaylistRemoveSuccess": "Seznam přehrávání odstraněn", "ToastPlaylistUpdateSuccess": "Seznam přehrávání aktualizován", "ToastPodcastCreateFailed": "Vytvoření podcastu se nezdařilo", "ToastPodcastCreateSuccess": "Podcast byl úspěšně vytvořen", + "ToastPodcastGetFeedFailed": "Chyba při získání podcastového feedu", + "ToastPodcastNoEpisodesInFeed": "Žádné epizody nenalezeny v RSS feedu", + "ToastPodcastNoRssFeed": "Podcast nemá RSS feed", + "ToastProgressIsNotBeingSynced": "Progres není synchronizován, restartujte přehrávání", + "ToastProviderCreatedFailed": "Chyba při zadání poskytovatele", + "ToastProviderCreatedSuccess": "Nový poskytovatel přidán", + "ToastProviderNameAndUrlRequired": "Jméno a Url jsou vyžadovány", + "ToastProviderRemoveSuccess": "Poskytovatel odstraněn", "ToastRSSFeedCloseFailed": "Nepodařilo se zavřít RSS kanál", "ToastRSSFeedCloseSuccess": "RSS kanál uzavřen", + "ToastRemoveFailed": "Chyba při odstranění", "ToastRemoveItemFromCollectionFailed": "Nepodařilo se odebrat položku z kolekce", "ToastRemoveItemFromCollectionSuccess": "Položka odstraněna z kolekce", + "ToastRemoveItemsWithIssuesFailed": "Chyba při odstranění položek v knihovně s chybami", + "ToastRemoveItemsWithIssuesSuccess": "Odstraněny položky knihovny s chybami", + "ToastRenameFailed": "Chyba při přejmenování", + "ToastRescanFailed": "Znovu prohledání selhalo z důvodu {0}", + "ToastRescanRemoved": "Znova skenování komplení - položka byla odsraněna", + "ToastRescanUpToDate": "Znovu prohledání kompletní - položka aktualizována", + "ToastRescanUpdated": "Znovu skenování komplení - položka byla aktualizována", + "ToastScanFailed": "Prohledání položek knihovny selhalo", + "ToastSelectAtLeastOneUser": "Vyberte alespoň jednoho uživatele", "ToastSendEbookToDeviceFailed": "Odeslání e-knihy do zařízení se nezdařilo", "ToastSendEbookToDeviceSuccess": "E-kniha odeslána do zařízení \"{0}\"", "ToastSeriesUpdateFailed": "Aktualizace série se nezdařila", "ToastSeriesUpdateSuccess": "Aktualizace série byla úspěšná", "ToastServerSettingsUpdateSuccess": "Nastavení serveru aktualizováno", + "ToastSessionCloseFailed": "Chyba při ukončení", "ToastSessionDeleteFailed": "Nepodařilo se smazat relaci", "ToastSessionDeleteSuccess": "Relace smazána", + "ToastSleepTimerDone": "Uspání knížky ... zZzzZz", + "ToastSlugMustChange": "Slug (URL) obsahuje chybné znaky", + "ToastSlugRequired": "Slug (URL) je vyžadována", "ToastSocketConnected": "Socket připojen", "ToastSocketDisconnected": "Socket odpojen", "ToastSocketFailedToConnect": "Socket se nepodařilo připojit", "ToastSortingPrefixesEmptyError": "Musí mít alespoň 1 třídicí předponu", "ToastSortingPrefixesUpdateSuccess": "Aktualizovány předpony třídění ({0} položek)", + "ToastTitleRequired": "Titul je vyžadován", + "ToastUnknownError": "Neznámý error", + "ToastUnlinkOpenIdFailed": "Chyba při odpárování uživatele z OpenID", + "ToastUnlinkOpenIdSuccess": "Uživatel odpárován z uživatele z OpenID", "ToastUserDeleteFailed": "Nepodařilo se smazat uživatele", - "ToastUserDeleteSuccess": "Uživatel smazán" + "ToastUserDeleteSuccess": "Uživatel smazán", + "ToastUserPasswordChangeSuccess": "Heslo bylo změněno úspěšně", + "ToastUserPasswordMismatch": "Hesla se neschodují", + "ToastUserPasswordMustChange": "Nové heslo se musí lišit od předchozího", + "ToastUserRootRequireName": "Musíte zadat uživatelské jméno root" } From 1506589ec80d86681edba83d5d378e3e427012cb Mon Sep 17 00:00:00 2001 From: ugyes Date: Mon, 20 Jan 2025 11:56:25 +0000 Subject: [PATCH 136/509] Translated using Weblate (Hungarian) Currently translated at 97.8% (1059 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/ --- client/strings/hu.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/hu.json b/client/strings/hu.json index b36ab440..5d40c3bc 100644 --- a/client/strings/hu.json +++ b/client/strings/hu.json @@ -51,7 +51,7 @@ "ButtonNext": "Következő", "ButtonNextChapter": "Következő fejezet", "ButtonNextItemInQueue": "Következő elem a sorban", - "ButtonOk": "Oké", + "ButtonOk": "Ok", "ButtonOpenFeed": "Hírcsatorna megnyitása", "ButtonOpenManager": "Kezelő megnyitása", "ButtonPause": "Szünet", From 63b2c6a3ea9080befe5cd8eac47ffcd64019ea14 Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Mon, 20 Jan 2025 10:57:14 +0000 Subject: [PATCH 137/509] Translated using Weblate (Swedish) Currently translated at 83.4% (903 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index ec83a708..90020336 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -49,7 +49,7 @@ "ButtonNext": "Nästa", "ButtonNextChapter": "Nästa kapitel", "ButtonNextItemInQueue": "Nästa objekt i Kö", - "ButtonOk": "Ok", + "ButtonOk": "OK", "ButtonOpenFeed": "Öppna flöde", "ButtonOpenManager": "Öppna Manager", "ButtonPause": "Pausa", @@ -173,7 +173,7 @@ "HeaderSchedule": "Schema", "HeaderScheduleEpisodeDownloads": "Schemalägg automatiska avsnittsnedladdningar", "HeaderScheduleLibraryScans": "Schema för skanning av biblioteket", - "HeaderSession": "Session", + "HeaderSession": "Tillfälle", "HeaderSetBackupSchedule": "Ange schemaläggning för säkerhetskopia", "HeaderSettings": "Inställningar", "HeaderSettingsDisplay": "Visning", @@ -267,7 +267,7 @@ "LabelCustomCronExpression": "Anpassat Cron-uttryck:", "LabelDatetime": "Datum och klockslag", "LabelDays": "Dagar", - "LabelDeleteFromFileSystemCheckbox": "Ta bort från filsystem (avmarkera för att endast ta bort från databasen)", + "LabelDeleteFromFileSystemCheckbox": "Ta även bort från filsystem (avmarkera = raderar endast från databasen)", "LabelDescription": "Beskrivning", "LabelDeselectAll": "Avmarkera alla", "LabelDevice": "Enhet", @@ -498,7 +498,7 @@ "LabelSettingsExperimentalFeatures": "Experimentella funktioner", "LabelSettingsExperimentalFeaturesHelp": "Funktioner under utveckling som behöver din feedback och hjälp med testning. Klicka för att öppna diskussionen på GitHub.", "LabelSettingsFindCovers": "Hitta ett bokomslag", - "LabelSettingsFindCoversHelp": "Om din bok inte har ett bokomslag inbäddat i filen eller
en fil med bokomslaget i mappen kommer
skannern att försöka hitta ett omslag.
OBS: Detta kommer att förlänga inläsningstiden", + "LabelSettingsFindCoversHelp": "Om din bok inte har ett bokomslag inbäddat i filen eller en fil med bokomslaget i mappen kommer skannern att försöka hitta ett omslag. OBS: Detta kommer att förlänga inläsningstiden", "LabelSettingsHideSingleBookSeries": "Dölj serier som endast innehåller en bok", "LabelSettingsHideSingleBookSeriesHelp": "Serier som endast har en bok kommer att
döljas från sidan 'Serier' och hyllorna på startsidan.", "LabelSettingsHomePageBookshelfView": "Använd vy liknande en bokhylla på startsidan", @@ -592,6 +592,7 @@ "LabelUnabridged": "Oavkortad", "LabelUndo": "Ångra", "LabelUnknown": "Okänd", + "LabelUnknownPublishDate": "Okänt publiceringsdatum", "LabelUpdateCover": "Uppdatera bokomslag", "LabelUpdateCoverHelp": "Tillåt att befintliga bokomslag för de valda böckerna ersätts när en matchning hittas", "LabelUpdateDetails": "Uppdatera detaljer", @@ -646,7 +647,7 @@ "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?", + "MessageConfirmDeleteLibraryItem": "Detta kommer att radera objektet 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?", "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?", @@ -703,7 +704,7 @@ "MessageMarkAsFinished": "Markera som 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", + "MessageNoAudioTracks": "Inga ljudspår har hittats", "MessageNoAuthors": "Inga författare", "MessageNoBackups": "Inga säkerhetskopior", "MessageNoBookmarks": "Inga bokmärken", @@ -758,6 +759,10 @@ "MessageTaskFailed": "Misslyckades", "MessageTaskFailedToCreateCacheDirectory": "Misslyckades med att skapa bibliotek för cachen", "MessageTaskMatchingBooksInLibrary": "Matchar böcker i biblioteket \"{0}\"", + "MessageTaskScanItemsAdded": "{0} adderades", + "MessageTaskScanItemsUpdated": "{0} uppdaterades", + "MessageTaskScanNoChangesNeeded": "Inget adderades eller uppdaterades", + "MessageTaskScanningLibrary": "Biblioteket \"{0}\" har skannats", "MessageThinking": "Tänker...", "MessageUploaderItemFailed": "Misslyckades med att ladda upp", "MessageUploaderItemSuccess": "Uppladdning lyckades!", @@ -773,7 +778,7 @@ "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 sökväg till mappen", @@ -796,7 +801,7 @@ "StatsTopMonth": "Bästa månad", "StatsTopNarrator": "Populäraste uppläsare", "StatsTopNarrators": "Populäraste uppläsarna", - "StatsTotalDuration": "Med en total varaktighet…", + "StatsTotalDuration": "Med en total varaktighet av…", "StatsYearInReview": "- SAMMANSTÄLLNING AV ÅRET", "ToastAccountUpdateSuccess": "Kontot har uppdaterats", "ToastAsinRequired": "En ASIN-kod krävs", @@ -825,6 +830,7 @@ "ToastCachePurgeSuccess": "Rensning av cachen har genomförts", "ToastChaptersHaveErrors": "Kapitlen har fel", "ToastChaptersMustHaveTitles": "Kapitel måste ha titlar", + "ToastCollectionItemsAddFailed": "Misslyckades med att addera böcker till samlingen", "ToastCollectionRemoveSuccess": "Samlingen har raderats", "ToastCollectionUpdateSuccess": "Samlingen har uppdaterats", "ToastCoverUpdateFailed": "Uppdatering av bokomslag misslyckades", @@ -839,6 +845,8 @@ "ToastInvalidMaxEpisodesToDownload": "Ogiltigt maximalt antal avsnitt att ladda ner", "ToastInvalidUrl": "Felaktig URL-adress", "ToastItemCoverUpdateSuccess": "Objektets bokomslag har uppdaterats", + "ToastItemDeletedFailed": "Misslyckades med att radera objektet", + "ToastItemDeletedSuccess": "Objektet har raderats", "ToastItemDetailsUpdateSuccess": "Detaljerna om boken har uppdaterats", "ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera den som avslutad", "ToastItemMarkedAsFinishedSuccess": "Den har markerat som avslutad", From 3fd9721da6f36a8ba10d23b255029f942336d211 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Mon, 20 Jan 2025 03:18:03 +0000 Subject: [PATCH 138/509] Translated using Weblate (Chinese (Simplified Han script)) 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/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index bbd55ac3..1c536275 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -196,7 +196,7 @@ "HeaderSleepTimer": "睡眠计时", "HeaderStatsLargestItems": "最大的项目", "HeaderStatsLongestItems": "项目时长(小时)", - "HeaderStatsMinutesListeningChart": "收听分钟数(最近7天)", + "HeaderStatsMinutesListeningChart": "收听分钟数 (最近7天)", "HeaderStatsRecentSessions": "历史会话", "HeaderStatsTop10Authors": "前 10 位作者", "HeaderStatsTop5Genres": "前 5 种流派", From 6d5aeaa42f57a151cd9d0c6dff82e0bc13ff5136 Mon Sep 17 00:00:00 2001 From: Nicky Larstrup Date: Mon, 20 Jan 2025 14:35:50 +0000 Subject: [PATCH 139/509] Translated using Weblate (Danish) Currently translated at 69.4% (751 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/da/ --- client/strings/da.json | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/client/strings/da.json b/client/strings/da.json index 9850e8da..f678fc62 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -71,6 +71,8 @@ "ButtonQuickMatch": "Hurtig Match", "ButtonReScan": "Gen-scan", "ButtonRead": "Læs", + "ButtonReadLess": "Se mindre", + "ButtonReadMore": "Se mere", "ButtonRefresh": "Genindlæs", "ButtonRemove": "Fjern", "ButtonRemoveAll": "Fjern Alle", @@ -220,7 +222,8 @@ "LabelAddToCollectionBatch": "Tilføj {0} Bøger til Samling", "LabelAddToPlaylist": "Tilføj til Afspilningsliste", "LabelAddToPlaylistBatch": "Tilføj {0} Elementer til Afspilningsliste", - "LabelAddedAt": "Tilføjet Kl.", + "LabelAddedAt": "Tilføjet", + "LabelAddedDate": "Tilføjet {0}", "LabelAdminUsersOnly": "Kun Administratorbrugere", "LabelAll": "Alle", "LabelAllUsers": "Alle Brugere", @@ -241,22 +244,31 @@ "LabelAutoFetchMetadataHelp": "Henter metadata for titler, forfatter og serier for at strømligne uploading. Ekstra metadata har måske brug for at blive matchet efter upload.", "LabelAutoLaunch": "Åben Automatisk", "LabelAutoLaunchDescription": "Viderestil automatisk til login-udbyderen ved navigation til login-siden (manuel overstyring via /login?autoLaunch=0)", + "LabelAutoRegister": "Registrer Automatisk", + "LabelAutoRegisterDescription": "Automatisk oprettelse af nye brugere efter login", "LabelBackToUser": "Tilbage til Bruger", + "LabelBackupAudioFiles": "Sikkerhedskopier lydfiler", "LabelBackupLocation": "Backup Placering", "LabelBackupsEnableAutomaticBackups": "Aktivér automatisk sikkerhedskopiering", "LabelBackupsEnableAutomaticBackupsHelp": "Sikkerhedskopier gemt i /metadata/backups", - "LabelBackupsMaxBackupSize": "Maksimal sikkerhedskopistørrelse (i GB)", + "LabelBackupsMaxBackupSize": "Maksimal sikkerhedskopistørrelse (i GB) (0 for ubegrænset)", "LabelBackupsMaxBackupSizeHelp": "Som en beskyttelse mod fejlkonfiguration fejler sikkerhedskopier, hvis de overstiger den konfigurerede størrelse.", "LabelBackupsNumberToKeep": "Antal sikkerhedskopier at beholde", "LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhedskopi fjernes ad gangen, så hvis du allerede har flere sikkerhedskopier end dette, skal du fjerne dem manuelt.", + "LabelBitrate": "Bitrate", + "LabelBonus": "Bonus", "LabelBooks": "Bøger", "LabelByAuthor": "af {0}", "LabelChangePassword": "Ændre Adgangskode", "LabelChannels": "Kanaler", + "LabelChapterCount": "{0} Kapitler", "LabelChapterTitle": "Kapitel Titel", "LabelChapters": "Kapitler", "LabelChaptersFound": "fundne kapitler", + "LabelClickForMoreInfo": "Klik for mere info", + "LabelClickToUseCurrentValue": "Klik for at bruge nuværende værdi", "LabelClosePlayer": "Luk afspiller", + "LabelCodec": "Kodeks", "LabelCollapseSeries": "Fold Serier Sammen", "LabelCollection": "Samling", "LabelCollections": "Samlinger", @@ -273,6 +285,7 @@ "LabelCurrently": "Aktuelt:", "LabelCustomCronExpression": "Brugerdefineret Cron Udtryk:", "LabelDatetime": "Dato og Tid", + "LabelDays": "Dage", "LabelDescription": "Beskrivelse", "LabelDeselectAll": "Fravælg Alle", "LabelDevice": "Enheds", @@ -284,16 +297,22 @@ "LabelDownload": "Download", "LabelDownloadNEpisodes": "Download {0} episoder", "LabelDuration": "Varighed", + "LabelDurationComparisonLonger": "({0} længere)", + "LabelDurationComparisonShorter": "({0} kortere)", "LabelDurationFound": "Fundet varighed:", "LabelEbook": "E-bog", "LabelEbooks": "E-bøger", "LabelEdit": "Rediger", + "LabelEmail": "E-mail", "LabelEmailSettingsFromAddress": "Fra Adresse", + "LabelEmailSettingsRejectUnauthorized": "Afvis uautoriserede certifikater", "LabelEmailSettingsSecure": "Sikker", "LabelEmailSettingsSecureHelp": "Hvis sandt, vil forbindelsen bruge TLS ved tilslutning til serveren. Hvis falsk, bruges TLS, hvis serveren understøtter STARTTLS-udvidelsen. I de fleste tilfælde skal denne værdi sættes til sandt, hvis du tilslutter til port 465. Til port 587 eller 25 skal du holde det falsk. (fra nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Test Adresse", "LabelEmbeddedCover": "Indlejret Omslag", "LabelEnable": "Aktivér", + "LabelEncodingBackupLocation": "En sikkerhedskopi af dine originale lydfiler vil blive gemt under:", + "LabelEncodingChaptersNotEmbedded": "Kapitler er ikke indlejret i multi spors lydbøger.", "LabelEnd": "Slut", "LabelEndOfChapter": "Slutningen af kapitel", "LabelEpisode": "Episode", From e9d8b6285886c57df746fb57e0314b13763db37f Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Mon, 20 Jan 2025 18:06:28 +0000 Subject: [PATCH 140/509] Translated using Weblate (Swedish) Currently translated at 83.4% (903 of 1082 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index 90020336..908b2b3a 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -204,7 +204,7 @@ "LabelAccountTypeGuest": "Gäst", "LabelAccountTypeUser": "Användare", "LabelActivity": "Aktivitet", - "LabelAddToCollection": "Lägg till i en Samling", + "LabelAddToCollection": "Lägg till i en samling", "LabelAddToCollectionBatch": "Lägg till {0} böcker i en Samling", "LabelAddToPlaylist": "Lägg till i Spellista", "LabelAddToPlaylistBatch": "Lägg till {0} objekt i Spellistan", @@ -255,8 +255,8 @@ "LabelCollections": "Samlingar", "LabelComplete": "Komplett", "LabelConfirmPassword": "Bekräfta lösenord", - "LabelContinueListening": "Fortsätt läsa/lyssna", - "LabelContinueReading": "Fortsätt Läsa", + "LabelContinueListening": "Fortsätt att lyssna", + "LabelContinueReading": "Fortsätt att läsa", "LabelContinueSeries": "Fortsätt med serien", "LabelCover": "Bokomslag", "LabelCoverImageURL": "URL till omslagsbild", @@ -690,7 +690,7 @@ "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.", "MessageImportantNotice": "Viktig meddelande!", "MessageInsertChapterBelow": "Infoga kapitel nedanför", - "MessageItemsSelected": "{0} Objekt markerade", + "MessageItemsSelected": "{0} objekt markerade", "MessageItemsUpdated": "{0} Objekt uppdaterade", "MessageJoinUsOn": "Anslut dig till oss på", "MessageLoading": "Laddar...", From 66b90e0841f2b08a4a401fad202605c8fbaf3c48 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 20 Jan 2025 15:45:09 -0600 Subject: [PATCH 141/509] Version bump v2.18.1 --- 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 f896b938..cf6ed862 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.18.0", + "version": "2.18.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.18.0", + "version": "2.18.1", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 32d40890..ee50f38d 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.18.0", + "version": "2.18.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 962b7056..8d37a6d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.18.0", + "version": "2.18.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.18.0", + "version": "2.18.1", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 8612a17e..f194b675 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.18.0", + "version": "2.18.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From c3c846f82dd24e51c144f45e9c8e48aefcb690d8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 21 Jan 2025 17:58:10 -0600 Subject: [PATCH 142/509] Update rss feed copy to clipboard to show checkmark instead of toast --- client/components/modals/rssfeed/OpenCloseModal.vue | 12 ++++++++---- client/components/modals/rssfeed/ViewFeedModal.vue | 12 ++++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/client/components/modals/rssfeed/OpenCloseModal.vue b/client/components/modals/rssfeed/OpenCloseModal.vue index 4eff9401..86dfeccf 100644 --- a/client/components/modals/rssfeed/OpenCloseModal.vue +++ b/client/components/modals/rssfeed/OpenCloseModal.vue @@ -12,7 +12,7 @@
- content_copy + {{ copiedToClipboard ? 'check' : 'content_copy' }}
@@ -68,7 +68,8 @@ export default { preventIndexing: true, ownerName: '', ownerEmail: '' - } + }, + copiedToClipboard: false } }, watch: { @@ -160,8 +161,11 @@ export default { this.processing = false }) }, - copyToClipboard(str) { - this.$copyToClipboard(str, this) + async copyToClipboard(str) { + this.copiedToClipboard = await this.$copyToClipboard(str) + setTimeout(() => { + this.copiedToClipboard = false + }, 2000) }, closeFeed() { this.processing = true diff --git a/client/components/modals/rssfeed/ViewFeedModal.vue b/client/components/modals/rssfeed/ViewFeedModal.vue index 70412517..ac594ed7 100644 --- a/client/components/modals/rssfeed/ViewFeedModal.vue +++ b/client/components/modals/rssfeed/ViewFeedModal.vue @@ -6,7 +6,7 @@
- content_copy + {{ copiedToClipboard ? 'check' : 'content_copy' }}
@@ -56,7 +56,8 @@ export default { }, data() { return { - processing: false + processing: false, + copiedToClipboard: false } }, computed: { @@ -76,8 +77,11 @@ export default { } }, methods: { - copyToClipboard(str) { - this.$copyToClipboard(str, this) + async copyToClipboard(str) { + this.copiedToClipboard = await this.$copyToClipboard(str) + setTimeout(() => { + this.copiedToClipboard = false + }, 2000) } }, mounted() {} From 286185329d50695761653b87d2d8e54d2ad5431b Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 22 Jan 2025 08:53:23 +0200 Subject: [PATCH 143/509] Support rich text book descriptions --- client/assets/defaultStyles.css | 15 ++- client/assets/trix.css | 11 +- client/components/cards/BookMatchCard.vue | 2 +- client/components/modals/item/tabs/Match.vue | 4 +- .../components/modals/podcast/ViewEpisode.vue | 2 +- client/components/ui/RichTextEditor.vue | 51 ++------- client/components/ui/VueTrix.vue | 103 +++++++++++++----- client/components/widgets/BookDetailsEdit.vue | 2 +- client/pages/item/_id/index.vue | 5 +- server/finders/BookFinder.js | 7 ++ server/models/Book.js | 4 + server/providers/Audible.js | 3 +- server/providers/iTunes.js | 2 +- server/utils/htmlSanitizer.js | 8 +- 14 files changed, 136 insertions(+), 83 deletions(-) diff --git a/client/assets/defaultStyles.css b/client/assets/defaultStyles.css index 027ccdf2..e0ca79e2 100644 --- a/client/assets/defaultStyles.css +++ b/client/assets/defaultStyles.css @@ -52,4 +52,17 @@ text-indent: 0px !important; text-align: start !important; text-align-last: start !important; -} \ No newline at end of file +} + +.default-style.less-spacing p { + margin-block-start: 0; +} + +.default-style.less-spacing ul { + margin-block-start: 0; +} + +.default-style.less-spacing ol { + margin-block-start: 0; +} + diff --git a/client/assets/trix.css b/client/assets/trix.css index 8f88c61f..7432b25f 100644 --- a/client/assets/trix.css +++ b/client/assets/trix.css @@ -446,7 +446,7 @@ trix-editor .attachment__metadata .attachment__size { } .trix-content { - line-height: 1.5; + line-height: inherit; } .trix-content * { @@ -455,6 +455,13 @@ trix-editor .attachment__metadata .attachment__size { padding: 0; } +.trix-content p { + box-sizing: border-box; + margin-top: 0; + margin-bottom: 0.5em; + padding: 0; +} + .trix-content h1 { font-size: 1.2em; line-height: 1.2; @@ -560,4 +567,4 @@ trix-editor .attachment__metadata .attachment__size { .trix-content .attachment-gallery.attachment-gallery--4 .attachment { flex-basis: 50%; max-width: 50%; -} \ No newline at end of file +} diff --git a/client/components/cards/BookMatchCard.vue b/client/components/cards/BookMatchCard.vue index d5355e91..4fa24c1f 100644 --- a/client/components/cards/BookMatchCard.vue +++ b/client/components/cards/BookMatchCard.vue @@ -24,7 +24,7 @@
-

{{ book.description }}

+

{{ book.descriptionPlain }}

diff --git a/client/components/modals/item/tabs/Match.vue b/client/components/modals/item/tabs/Match.vue index c7247d51..623ef2a1 100644 --- a/client/components/modals/item/tabs/Match.vue +++ b/client/components/modals/item/tabs/Match.vue @@ -94,9 +94,9 @@ diff --git a/client/components/modals/podcast/ViewEpisode.vue b/client/components/modals/podcast/ViewEpisode.vue index af67242a..6c1a678c 100644 --- a/client/components/modals/podcast/ViewEpisode.vue +++ b/client/components/modals/podcast/ViewEpisode.vue @@ -16,7 +16,7 @@

{{ title }}

-
+

{{ $strings.MessageNoDescription }}

diff --git a/client/components/ui/RichTextEditor.vue b/client/components/ui/RichTextEditor.vue index c5ae6d83..51bdc5ae 100644 --- a/client/components/ui/RichTextEditor.vue +++ b/client/components/ui/RichTextEditor.vue @@ -1,9 +1,9 @@ @@ -12,7 +12,10 @@ export default { props: { value: String, label: String, - disabled: Boolean + disabled: { + type: Boolean, + default: false + } }, data() { return {} @@ -25,49 +28,19 @@ export default { set(val) { this.$emit('input', val) } - }, - config() { - return { - toolbar: { - getDefaultHTML: () => `
- - - - - - - - - - - - - - - - -
-
- -
` - } - } } }, methods: { trixFileAccept(e) { e.preventDefault() + }, + blur() { + if (this.$refs.input && this.$refs.input.blur) { + this.$refs.input.blur() + } } }, mounted() {}, beforeDestroy() {} } - \ No newline at end of file + diff --git a/client/components/ui/VueTrix.vue b/client/components/ui/VueTrix.vue index 5d351c72..8bbb42df 100644 --- a/client/components/ui/VueTrix.vue +++ b/client/components/ui/VueTrix.vue @@ -1,6 +1,37 @@ @@ -14,6 +45,30 @@ import Trix from 'trix' import '@/assets/trix.css' +function enableBreakParagraphOnReturn() { + // Trix works with divs by default, we want paragraphs instead + Trix.config.blockAttributes.default.tagName = 'p' + // Enable break paragraph on Enter (Shift + Enter will still create a line break) + Trix.config.blockAttributes.default.breakOnReturn = true + + // Hack to fix buggy paragraph breaks + // Copied from https://github.com/basecamp/trix/issues/680#issuecomment-735742942 + Trix.Block.prototype.breaksOnReturn = function () { + const attr = this.getLastAttribute() + const config = Trix.getBlockConfig(attr ? attr : 'default') + return config ? config.breakOnReturn : false + } + Trix.LineBreakInsertion.prototype.shouldInsertBlockBreak = function () { + if (this.block.hasAttributes() && this.block.isListItem() && !this.block.isEmpty()) { + return this.startLocation.offset > 0 + } else { + return !this.shouldBreakFormattedBlock() ? this.breaksOnReturn : false + } + } +} + +enableBreakParagraphOnReturn() + export default { name: 'vue-trix', model: { @@ -134,6 +189,9 @@ export default { * Compute a random id of hidden input * when it haven't been specified. */ + toolbarId() { + return `trix-toolbar-${this.generateId}` + }, generateId() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { var r = (Math.random() * 16) | 0 @@ -223,13 +281,17 @@ export default { decorateDisabledEditor(editorState) { /** Disable toolbar and editor by pointer events styling */ if (editorState) { - this.$refs.trix.toolbarElement.style['pointer-events'] = 'none' + this.$refs.trix.disabled = true this.$refs.trix.contentEditable = false - this.$refs.trix.style['background'] = '#e9ecef' + this.$refs.trix.style['pointer-events'] = 'none' + this.$refs.trix.style['background-color'] = '#444' + this.$refs.trix.style['color'] = '#bbb' } else { - this.$refs.trix.toolbarElement.style['pointer-events'] = 'unset' + this.$refs.trix.disabled = false + this.$refs.trix.contentEditable = true this.$refs.trix.style['pointer-events'] = 'unset' - this.$refs.trix.style['background'] = 'transparent' + this.$refs.trix.style['background-color'] = '' + this.$refs.trix.style['color'] = '' } }, overrideConfig(config) { @@ -250,32 +312,15 @@ export default { } return target }, - enableBreakParagraphOnReturn() { - // Trix works with divs by default, we want paragraphs instead - Trix.config.blockAttributes.default.tagName = 'p' - // Enable break paragraph on Enter (Shift + Enter will still create a line break) - Trix.config.blockAttributes.default.breakOnReturn = true - - // Hack to fix buggy paragraph breaks - // Copied from https://github.com/basecamp/trix/issues/680#issuecomment-735742942 - Trix.Block.prototype.breaksOnReturn = function () { - const attr = this.getLastAttribute() - const config = Trix.getBlockConfig(attr ? attr : 'default') - return config ? config.breakOnReturn : false - } - Trix.LineBreakInsertion.prototype.shouldInsertBlockBreak = function () { - if (this.block.hasAttributes() && this.block.isListItem() && !this.block.isEmpty()) { - return this.startLocation.offset > 0 - } else { - return !this.shouldBreakFormattedBlock() ? this.breaksOnReturn : false - } + blur() { + if (this.$refs.trix && this.$refs.trix.blur) { + this.$refs.trix.blur() } } }, mounted() { /** Override editor configuration */ this.overrideConfig(this.config) - this.enableBreakParagraphOnReturn() /** Check if editor read-only mode is required */ this.decorateDisabledEditor(this.disabledEditor) this.$nextTick(() => { @@ -305,4 +350,12 @@ export default { .trix_container .trix-content { background-color: white; } +trix-editor { + max-height: calc(4 * 1lh); + overflow-y: auto; +} + +trix-editor * { + pointer-events: inherit; +} diff --git a/client/components/widgets/BookDetailsEdit.vue b/client/components/widgets/BookDetailsEdit.vue index 5fbcaa20..fa26bcf5 100644 --- a/client/components/widgets/BookDetailsEdit.vue +++ b/client/components/widgets/BookDetailsEdit.vue @@ -26,7 +26,7 @@
- +
diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index a0cadc1d..714e326c 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -123,7 +123,7 @@
-

{{ description }}

+

@@ -804,8 +804,7 @@ export default { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 4; - max-height: 6.25rem; - transition: all 0.3s ease-in-out; + max-height: calc(6 * 1lh); } #item-description.show-full { -webkit-line-clamp: unset; diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index f4323094..8fde7bc4 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -8,6 +8,7 @@ const AudiobookCovers = require('../providers/AudiobookCovers') const CustomProviderAdapter = require('../providers/CustomProviderAdapter') const Logger = require('../Logger') const { levenshteinDistance, escapeRegExp } = require('../utils/index') +const htmlSanitizer = require('../utils/htmlSanitizer') class BookFinder { #providerResponseTimeout = 30000 @@ -463,6 +464,12 @@ class BookFinder { } else { books = await this.getGoogleBooksResults(title, author) } + books.forEach((book) => { + if (book.description) { + book.description = htmlSanitizer.sanitize(book.description) + book.descriptionPlain = htmlSanitizer.stripAllTags(book.description) + } + }) return books } diff --git a/server/models/Book.js b/server/models/Book.js index 5a4eee54..4f7d1269 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -2,6 +2,7 @@ const { DataTypes, Model } = require('sequelize') const Logger = require('../Logger') const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils') const parseNameString = require('../utils/parsers/parseNameString') +const htmlSanitizer = require('../utils/htmlSanitizer') /** * @typedef EBookFileObject @@ -343,6 +344,7 @@ class Book extends Model { publishedDate: this.publishedDate, publisher: this.publisher, description: this.description, + descriptionPlain: this.description ? htmlSanitizer.stripAllTags(this.description) : null, isbn: this.isbn, asin: this.asin, language: this.language, @@ -542,6 +544,7 @@ class Book extends Model { publishedDate: this.publishedDate, publisher: this.publisher, description: this.description, + descriptionPlain: this.description ? htmlSanitizer.stripAllTags(this.description) : null, isbn: this.isbn, asin: this.asin, language: this.language, @@ -564,6 +567,7 @@ class Book extends Model { publishedDate: this.publishedDate, publisher: this.publisher, description: this.description, + descriptionPlain: this.description ? htmlSanitizer.stripAllTags(this.description) : null, isbn: this.isbn, asin: this.asin, language: this.language, diff --git a/server/providers/Audible.js b/server/providers/Audible.js index 505b8f0e..e6816082 100644 --- a/server/providers/Audible.js +++ b/server/providers/Audible.js @@ -1,5 +1,4 @@ const axios = require('axios').default -const htmlSanitizer = require('../utils/htmlSanitizer') const Logger = require('../Logger') const { isValidASIN } = require('../utils/index') @@ -68,7 +67,7 @@ class Audible { narrator: narrators ? narrators.map(({ name }) => name).join(', ') : null, publisher: publisherName, publishedYear: releaseDate ? releaseDate.split('-')[0] : null, - description: summary ? htmlSanitizer.stripAllTags(summary) : null, + description: summary || null, cover: image, asin, genres: genresFiltered.length ? genresFiltered : null, diff --git a/server/providers/iTunes.js b/server/providers/iTunes.js index 1ec051d1..57a47d0d 100644 --- a/server/providers/iTunes.js +++ b/server/providers/iTunes.js @@ -112,7 +112,7 @@ class iTunes { artistId: data.artistId, title: data.collectionName, author, - description: htmlSanitizer.stripAllTags(data.description || ''), + description: data.description || null, publishedYear: data.releaseDate ? data.releaseDate.split('-')[0] : null, genres: data.primaryGenreName ? [data.primaryGenreName] : null, cover: this.getCoverArtwork(data) diff --git a/server/utils/htmlSanitizer.js b/server/utils/htmlSanitizer.js index 68d92c85..cab92392 100644 --- a/server/utils/htmlSanitizer.js +++ b/server/utils/htmlSanitizer.js @@ -1,11 +1,9 @@ const sanitizeHtml = require('../libs/sanitizeHtml') -const { entities } = require("./htmlEntities"); +const { entities } = require('./htmlEntities') function sanitize(html) { const sanitizerOptions = { - allowedTags: [ - 'p', 'ol', 'ul', 'li', 'a', 'strong', 'em', 'del', 'br' - ], + allowedTags: ['p', 'ol', 'ul', 'li', 'a', 'strong', 'em', 'del', 'br', 'b', 'i'], disallowedTagsMode: 'discard', allowedAttributes: { a: ['href', 'name', 'target'] @@ -34,6 +32,6 @@ function decodeHTMLEntities(strToDecode) { if (entity in entities) { return entities[entity] } - return entity; + return entity }) } From 598a93d224d4f94610cc321405f1b8ddbce447a4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 22 Jan 2025 17:56:46 -0600 Subject: [PATCH 144/509] Update copy to clipboard buttons to be standardized --- .../components/modals/AudioFileDataModal.vue | 16 +++++++++------ client/components/modals/ShareModal.vue | 2 +- .../modals/rssfeed/OpenCloseModal.vue | 13 ++---------- .../modals/rssfeed/ViewFeedModal.vue | 17 +++------------- client/components/ui/TextInput.vue | 20 +++++++++++-------- client/components/ui/TextInputWithLabel.vue | 5 +++-- client/pages/config/users/_id/index.vue | 9 +-------- client/plugins/init.client.js | 4 +--- 8 files changed, 33 insertions(+), 53 deletions(-) diff --git a/client/components/modals/AudioFileDataModal.vue b/client/components/modals/AudioFileDataModal.vue index 7e33a980..eb70f1c3 100644 --- a/client/components/modals/AudioFileDataModal.vue +++ b/client/components/modals/AudioFileDataModal.vue @@ -90,8 +90,8 @@
-
@@ -113,14 +113,13 @@ export default { return { probingFile: false, ffprobeData: null, - copiedToClipboard: false + hasCopied: null } }, watch: { show(newVal) { if (newVal) { this.ffprobeData = null - this.copiedToClipboard = false this.probingFile = false } } @@ -165,8 +164,13 @@ export default { this.probingFile = false }) }, - async copyFfprobeData() { - this.copiedToClipboard = await this.$copyToClipboard(this.prettyFfprobeData) + copyToClipboard() { + clearTimeout(this.hasCopied) + this.$copyToClipboard(this.prettyFfprobeData).then((success) => { + this.hasCopied = setTimeout(() => { + this.hasCopied = null + }, 2000) + }) } }, mounted() {} diff --git a/client/components/modals/ShareModal.vue b/client/components/modals/ShareModal.vue index 5b379884..0ae65ec6 100644 --- a/client/components/modals/ShareModal.vue +++ b/client/components/modals/ShareModal.vue @@ -16,7 +16,7 @@ @@ -47,7 +47,7 @@ export default { showPassword: false, isHovering: false, isFocused: false, - hasCopied: false, + hasCopied: null, isInvalidDate: false } }, @@ -62,7 +62,12 @@ export default { }, classList() { var _list = [] - _list.push(`px-${this.paddingX}`) + if (this.showCopy) { + _list.push('pl-3', 'pr-8') + } else { + _list.push(`px-${this.paddingX}`) + } + _list.push(`py-${this.paddingY}`) if (this.noSpinner) _list.push('no-spinner') if (this.textCenter) _list.push('text-center') @@ -80,11 +85,10 @@ export default { }, methods: { copyToClipboard() { - if (this.hasCopied) return + clearTimeout(this.hasCopied) this.$copyToClipboard(this.inputValue).then((success) => { - this.hasCopied = success - setTimeout(() => { - this.hasCopied = false + this.hasCopied = setTimeout(() => { + this.hasCopied = null }, 2000) }) }, diff --git a/client/components/ui/TextInputWithLabel.vue b/client/components/ui/TextInputWithLabel.vue index ee9ffb7a..a10394bd 100644 --- a/client/components/ui/TextInputWithLabel.vue +++ b/client/components/ui/TextInputWithLabel.vue @@ -6,7 +6,7 @@ {{ note }} - +
@@ -23,7 +23,8 @@ export default { }, readonly: Boolean, disabled: Boolean, - inputClass: String + inputClass: String, + showCopy: Boolean }, data() { return {} diff --git a/client/pages/config/users/_id/index.vue b/client/pages/config/users/_id/index.vue index d19337af..fbef359b 100644 --- a/client/pages/config/users/_id/index.vue +++ b/client/pages/config/users/_id/index.vue @@ -14,11 +14,7 @@

{{ username }}

- - -
- content_copy -
+
@@ -140,9 +136,6 @@ export default { } }, methods: { - copyToClipboard(str) { - this.$copyToClipboard(str, this) - }, async init() { this.listeningSessions = await this.$axios .$get(`/api/users/${this.user.id}/listening-sessions?page=0&itemsPerPage=10`) diff --git a/client/plugins/init.client.js b/client/plugins/init.client.js index 984ec9d0..015cd919 100644 --- a/client/plugins/init.client.js +++ b/client/plugins/init.client.js @@ -128,12 +128,11 @@ Vue.prototype.$sanitizeSlug = (str) => { return str } -Vue.prototype.$copyToClipboard = (str, ctx) => { +Vue.prototype.$copyToClipboard = (str) => { return new Promise((resolve) => { if (navigator.clipboard) { navigator.clipboard.writeText(str).then( () => { - if (ctx) ctx.$toast.success('Copied to clipboard') resolve(true) }, (err) => { @@ -152,7 +151,6 @@ Vue.prototype.$copyToClipboard = (str, ctx) => { document.execCommand('copy') document.body.removeChild(el) - if (ctx) ctx.$toast.success('Copied to clipboard') resolve(true) } }) From 9fbf57bbefeb0ed5074a392e0b86bf680ec5055c Mon Sep 17 00:00:00 2001 From: adjokic <15988225+adjokic@users.noreply.github.com> Date: Wed, 22 Jan 2025 22:10:38 -0600 Subject: [PATCH 145/509] Update README on using websockets with Apache as a reverse proxy --- readme.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/readme.md b/readme.md index 19ede3ce..34f770c5 100644 --- a/readme.md +++ b/readme.md @@ -165,6 +165,15 @@ For this to work you must enable at least the following mods using `a2enmod`: ``` +If using Apache >= 2.4.47 you can use the following, without having to use any of the `RewriteEngine`, `RewriteCond`, or `RewriteRule` directives. For example: +```xml + + ProxyPreserveHost on + ProxyPass http://localhost:/audiobookshelf upgrade=websocket + ProxyPassReverse http://localhost:/audiobookshelf + +``` + Some SSL certificates like those signed by Let's Encrypt require ACME validation. To allow Let's Encrypt to write and confirm the ACME challenge, edit your VirtualHost definition to prevent proxying traffic that queries `/.well-known` and instead serve that directly: ```bash From 79acc41d1617fd550fcfbeabe812b495c5009f20 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 23 Jan 2025 17:49:58 -0600 Subject: [PATCH 146/509] Add populate from buttons to batch edit --- client/pages/batch/index.vue | 94 +++++++++++++++++++++++++++++++++++- client/strings/en-us.json | 4 ++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/client/pages/batch/index.vue b/client/pages/batch/index.vue index 5cc83176..263dee58 100644 --- a/client/pages/batch/index.vue +++ b/client/pages/batch/index.vue @@ -86,7 +86,12 @@
-
+
+ {{ $strings.ButtonReset }} + + {{ $strings.ButtonBatchEditPopulateFromExisting }} + +
{{ $strings.ButtonApply }}
@@ -97,6 +102,11 @@
@@ -35,7 +38,9 @@ export default { { text: this.$getString('LabelTimeDurationXMinutes', ['5']), value: 300 } ], jumpForwardAmount: 10, - jumpBackwardAmount: 10 + jumpBackwardAmount: 10, + playbackRateIncrementDecrementValues: [0.1, 0.05], + playbackRateIncrementDecrement: 0.1 } }, computed: { @@ -60,10 +65,15 @@ export default { this.jumpBackwardAmount = val this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val }) }, + setPlaybackRateIncrementDecrementAmount(val) { + this.playbackRateIncrementDecrement = val + this.$store.dispatch('user/updateUserSettings', { playbackRateIncrementDecrement: val }) + }, settingsUpdated() { this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') + this.playbackRateIncrementDecrement = this.$store.getters['user/getUserSetting']('playbackRateIncrementDecrement') } }, mounted() { From 1ea1e60d4bcb088279eb5a2e762d1ca804605c13 Mon Sep 17 00:00:00 2001 From: Greg Lorenzen Date: Sat, 25 Jan 2025 01:58:48 +0000 Subject: [PATCH 168/509] Add string for LabelPlaybackRateIncrementDecrement --- client/strings/en-us.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index b4ac1389..01d94ac6 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -485,6 +485,7 @@ "LabelPermissionsUpload": "Can Upload", "LabelPersonalYearReview": "Your Year in Review ({0})", "LabelPhotoPathURL": "Photo Path/URL", + "LabelPlaybackRateIncrementDecrement": "Playback Rate Increment/Decrement Amount", "LabelPlayMethod": "Play Method", "LabelPlayerChapterNumberMarker": "{0} of {1}", "LabelPlaylists": "Playlists", From f258782e2e00fc8c68a901bb93d0ffab37f3c7eb Mon Sep 17 00:00:00 2001 From: Greg Lorenzen Date: Sat, 25 Jan 2025 01:59:24 +0000 Subject: [PATCH 169/509] Handle playback rate increment and decrmenet value in UI --- .../controls/PlaybackSpeedControl.vue | 20 +++++++++++-------- client/components/player/PlayerUi.vue | 9 ++++++--- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/client/components/controls/PlaybackSpeedControl.vue b/client/components/controls/PlaybackSpeedControl.vue index 9e9f0d54..2c910547 100644 --- a/client/components/controls/PlaybackSpeedControl.vue +++ b/client/components/controls/PlaybackSpeedControl.vue @@ -33,6 +33,10 @@ export default { value: { type: [String, Number], default: 1 + }, + playbackRateIncrementDecrement: { + type: Number, + default: 0.1 } }, data() { @@ -58,10 +62,10 @@ export default { return [0.5, 1, 1.2, 1.5, 2] }, canIncrement() { - return this.playbackRate + 0.1 <= this.MAX_SPEED + return this.playbackRate + this.playbackRateIncrementDecrement <= this.MAX_SPEED }, canDecrement() { - return this.playbackRate - 0.1 >= this.MIN_SPEED + return this.playbackRate - this.playbackRateIncrementDecrement >= this.MIN_SPEED } }, methods: { @@ -73,14 +77,14 @@ export default { this.$nextTick(() => this.setShowMenu(false)) }, increment() { - if (this.playbackRate + 0.1 > this.MAX_SPEED) return - var newPlaybackRate = this.playbackRate + 0.1 - this.playbackRate = Number(newPlaybackRate.toFixed(1)) + if (this.playbackRate + this.playbackRateIncrementDecrement > this.MAX_SPEED) return + var newPlaybackRate = this.playbackRate + this.playbackRateIncrementDecrement + this.playbackRate = Number(newPlaybackRate.toFixed(2)) }, decrement() { - if (this.playbackRate - 0.1 < this.MIN_SPEED) return - var newPlaybackRate = this.playbackRate - 0.1 - this.playbackRate = Number(newPlaybackRate.toFixed(1)) + if (this.playbackRate - this.playbackRateIncrementDecrement < this.MIN_SPEED) return + var newPlaybackRate = this.playbackRate - this.playbackRateIncrementDecrement + this.playbackRate = Number(newPlaybackRate.toFixed(2)) }, updateMenuPositions() { if (!this.$refs.wrapper) return diff --git a/client/components/player/PlayerUi.vue b/client/components/player/PlayerUi.vue index 31267c7a..f4ad59d1 100644 --- a/client/components/player/PlayerUi.vue +++ b/client/components/player/PlayerUi.vue @@ -2,7 +2,7 @@
- +
-

+

+
From 9b4732c207c092711137afb13477181d9cac7b1a Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 26 Jan 2025 12:21:54 +0200 Subject: [PATCH 172/509] Add bookSeries id attribute to findAllExpandedWhere --- server/models/LibraryItem.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index d581c309..3ed4e31e 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -155,7 +155,7 @@ class LibraryItem extends Model { { model: this.sequelize.models.series, through: { - attributes: ['sequence'] + attributes: ['id', 'sequence'] } } ] From 23067e1818751dcc8aed9087745db266e846b0be Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 26 Jan 2025 13:44:57 +0200 Subject: [PATCH 173/509] Allows setting of some pragma values through environment variables --- server/Database.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/server/Database.js b/server/Database.js index 82a8fbd1..c9e2d52a 100644 --- a/server/Database.js +++ b/server/Database.js @@ -226,6 +226,28 @@ class Database { try { await this.sequelize.authenticate() + + // Set SQLite pragmas from environment variables + const allowedPragmas = [ + { name: 'mmap_size', env: 'SQLITE_MMAP_SIZE' }, + { name: 'cache_size', env: 'SQLITE_CACHE_SIZE' }, + { name: 'temp_store', env: 'SQLITE_TEMP_STORE' } + ] + + for (const pragma of allowedPragmas) { + const value = process.env[pragma.env] + if (value !== undefined) { + try { + Logger.info(`[Database] Running "PRAGMA ${pragma.name} = ${value}"`) + await this.sequelize.query(`PRAGMA ${pragma.name} = ${value}`) + const [result] = await this.sequelize.query(`PRAGMA ${pragma.name}`) + Logger.debug(`[Database] "PRAGMA ${pragma.name}" query result:`, result) + } catch (error) { + Logger.error(`[Database] Failed to set SQLite pragma ${pragma.name}`, error) + } + } + } + if (process.env.NUSQLITE3_PATH) { await this.loadExtension(process.env.NUSQLITE3_PATH) Logger.info(`[Database] Db supports unaccent and unicode foldings`) From 558173e086298d63dd99e39cfeb8a57109f14c52 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 26 Jan 2025 10:51:18 -0600 Subject: [PATCH 174/509] Update custom metadata provider results to sanitize html descriptions #3880 --- server/libs/sanitizeHtml/index.js | 112 ---------------------- server/providers/CustomProviderAdapter.js | 3 +- server/utils/htmlSanitizer.js | 10 ++ 3 files changed, 12 insertions(+), 113 deletions(-) diff --git a/server/libs/sanitizeHtml/index.js b/server/libs/sanitizeHtml/index.js index 3fee985e..701a36f2 100644 --- a/server/libs/sanitizeHtml/index.js +++ b/server/libs/sanitizeHtml/index.js @@ -7,12 +7,6 @@ */ const htmlparser = require('htmlparser2'); -// const escapeStringRegexp = require('escape-string-regexp'); -// const { isPlainObject } = require('is-plain-object'); -// const deepmerge = require('deepmerge'); -// const parseSrcset = require('parse-srcset'); -// const { parse: postcssParse } = require('postcss'); -// Tags that can conceivably represent stand-alone media. // ABS UPDATE: Packages not necessary // SOURCE: https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js @@ -76,17 +70,6 @@ function has(obj, key) { return ({}).hasOwnProperty.call(obj, key); } -// Returns those elements of `a` for which `cb(a)` returns truthy -function filter(a, cb) { - const n = []; - each(a, function (v) { - if (cb(v)) { - n.push(v); - } - }); - return n; -} - function isEmptyObject(obj) { for (const key in obj) { if (has(obj, key)) { @@ -96,21 +79,6 @@ function isEmptyObject(obj) { return true; } -function stringifySrcset(parsedSrcset) { - return parsedSrcset.map(function (part) { - if (!part.url) { - throw new Error('URL missing'); - } - - return ( - part.url + - (part.w ? ` ${part.w}w` : '') + - (part.h ? ` ${part.h}h` : '') + - (part.d ? ` ${part.d}x` : '') - ); - }).join(', '); -} - module.exports = sanitizeHtml; // A valid attribute name. @@ -714,86 +682,6 @@ function sanitizeHtml(html, options, _recursing) { return !options.allowedSchemes || options.allowedSchemes.indexOf(scheme) === -1; } - /** - * Filters user input css properties by allowlisted regex attributes. - * Modifies the abstractSyntaxTree object. - * - * @param {object} abstractSyntaxTree - Object representation of CSS attributes. - * @property {array[Declaration]} abstractSyntaxTree.nodes[0] - Each object cointains prop and value key, i.e { prop: 'color', value: 'red' }. - * @param {object} allowedStyles - Keys are properties (i.e color), value is list of permitted regex rules (i.e /green/i). - * @return {object} - The modified tree. - */ - // function filterCss(abstractSyntaxTree, allowedStyles) { - // if (!allowedStyles) { - // return abstractSyntaxTree; - // } - - // const astRules = abstractSyntaxTree.nodes[0]; - // let selectedRule; - - // // Merge global and tag-specific styles into new AST. - // if (allowedStyles[astRules.selector] && allowedStyles['*']) { - // selectedRule = deepmerge( - // allowedStyles[astRules.selector], - // allowedStyles['*'] - // ); - // } else { - // selectedRule = allowedStyles[astRules.selector] || allowedStyles['*']; - // } - - // if (selectedRule) { - // abstractSyntaxTree.nodes[0].nodes = astRules.nodes.reduce(filterDeclarations(selectedRule), []); - // } - - // return abstractSyntaxTree; - // } - - /** - * Extracts the style attributes from an AbstractSyntaxTree and formats those - * values in the inline style attribute format. - * - * @param {AbstractSyntaxTree} filteredAST - * @return {string} - Example: "color:yellow;text-align:center !important;font-family:helvetica;" - */ - function stringifyStyleAttributes(filteredAST) { - return filteredAST.nodes[0].nodes - .reduce(function (extractedAttributes, attrObject) { - extractedAttributes.push( - `${attrObject.prop}:${attrObject.value}${attrObject.important ? ' !important' : ''}` - ); - return extractedAttributes; - }, []) - .join(';'); - } - - /** - * Filters the existing attributes for the given property. Discards any attributes - * which don't match the allowlist. - * - * @param {object} selectedRule - Example: { color: red, font-family: helvetica } - * @param {array} allowedDeclarationsList - List of declarations which pass the allowlist. - * @param {object} attributeObject - Object representing the current css property. - * @property {string} attributeObject.type - Typically 'declaration'. - * @property {string} attributeObject.prop - The CSS property, i.e 'color'. - * @property {string} attributeObject.value - The corresponding value to the css property, i.e 'red'. - * @return {function} - When used in Array.reduce, will return an array of Declaration objects - */ - function filterDeclarations(selectedRule) { - return function (allowedDeclarationsList, attributeObject) { - // If this property is allowlisted... - if (has(selectedRule, attributeObject.prop)) { - const matchesRegex = selectedRule[attributeObject.prop].some(function (regularExpression) { - return regularExpression.test(attributeObject.value); - }); - - if (matchesRegex) { - allowedDeclarationsList.push(attributeObject); - } - } - return allowedDeclarationsList; - }; - } - function filterClasses(classes, allowed, allowedGlobs) { if (!allowed) { // The class attribute is allowed without filtering on this tag diff --git a/server/providers/CustomProviderAdapter.js b/server/providers/CustomProviderAdapter.js index fe6537fd..911a09e9 100644 --- a/server/providers/CustomProviderAdapter.js +++ b/server/providers/CustomProviderAdapter.js @@ -1,6 +1,7 @@ const axios = require('axios').default const Database = require('../Database') const Logger = require('../Logger') +const htmlSanitizer = require('../utils/htmlSanitizer') class CustomProviderAdapter { #responseTimeout = 30000 @@ -74,7 +75,7 @@ class CustomProviderAdapter { narrator, publisher, publishedYear, - description, + description: htmlSanitizer.sanitize(description), cover, isbn, asin, diff --git a/server/utils/htmlSanitizer.js b/server/utils/htmlSanitizer.js index cab92392..4ed30e72 100644 --- a/server/utils/htmlSanitizer.js +++ b/server/utils/htmlSanitizer.js @@ -1,7 +1,17 @@ const sanitizeHtml = require('../libs/sanitizeHtml') const { entities } = require('./htmlEntities') +/** + * + * @param {string} html + * @returns {string} + * @throws {Error} if input is not a string + */ function sanitize(html) { + if (typeof html !== 'string') { + throw new Error('sanitizeHtml: input must be a string') + } + const sanitizerOptions = { allowedTags: ['p', 'ol', 'ul', 'li', 'a', 'strong', 'em', 'del', 'br', 'b', 'i'], disallowedTagsMode: 'discard', From e701a0a32e6d2ab1d51fbedd73c4bedcd12e90bf Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 27 Jan 2025 16:46:32 -0600 Subject: [PATCH 175/509] Update playback rate display value number of decimals --- client/components/controls/PlaybackSpeedControl.vue | 11 +++++++++-- client/strings/en-us.json | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/client/components/controls/PlaybackSpeedControl.vue b/client/components/controls/PlaybackSpeedControl.vue index 2c910547..98f40866 100644 --- a/client/components/controls/PlaybackSpeedControl.vue +++ b/client/components/controls/PlaybackSpeedControl.vue @@ -1,7 +1,7 @@
-
-

{{ $strings.MessageNoCollections }}

+
+
+

{{ $strings.MessageNoUserPlaylists }}

+

+ {{ $strings.MessageBookshelfNoCollectionsHelp }} + + + help_outline + + +

+
+
diff --git a/client/components/modals/playlists/AddCreateModal.vue b/client/components/modals/playlists/AddCreateModal.vue index d1f910b8..51f7e475 100644 --- a/client/components/modals/playlists/AddCreateModal.vue +++ b/client/components/modals/playlists/AddCreateModal.vue @@ -19,8 +19,18 @@
-
-

{{ $strings.MessageNoUserPlaylists }}

+
+
+

{{ $strings.MessageNoUserPlaylists }}

+

+ {{ $strings.MessageNoUserPlaylistsHelp }} + + + help_outline + + +

+
From c3aad9486c6036c6fcc30102a2e988a8ae198fbd Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 30 Jan 2025 17:27:32 -0600 Subject: [PATCH 184/509] Fix Logger.fatal to be a promise to ensure crash_logs.txt write --- server/Logger.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Logger.js b/server/Logger.js index 5d1a7fa5..e4487f0a 100644 --- a/server/Logger.js +++ b/server/Logger.js @@ -117,7 +117,7 @@ class Logger { if (level < LogLevel.FATAL && level < this.logLevel) return const consoleMethod = Logger.ConsoleMethods[levelName] console[consoleMethod](`[${this.timestamp}] ${levelName}:`, ...args) - this.#logToFileAndListeners(level, levelName, args, source) + return this.#logToFileAndListeners(level, levelName, args, source) } trace(...args) { @@ -141,7 +141,7 @@ class Logger { } fatal(...args) { - this.#log('FATAL', this.source, ...args) + return this.#log('FATAL', this.source, ...args) } note(...args) { From 2e13c5bd5021adf37ba63e3c16e2e5647996240d Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 30 Jan 2025 17:47:41 -0600 Subject: [PATCH 185/509] Fix no collections message, ui updates --- .../components/modals/collections/AddCreateModal.vue | 12 ++++++------ .../components/modals/playlists/AddCreateModal.vue | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/client/components/modals/collections/AddCreateModal.vue b/client/components/modals/collections/AddCreateModal.vue index ee465187..c4878adc 100644 --- a/client/components/modals/collections/AddCreateModal.vue +++ b/client/components/modals/collections/AddCreateModal.vue @@ -19,20 +19,20 @@
-
+
-

{{ $strings.MessageNoUserPlaylists }}

-

- {{ $strings.MessageBookshelfNoCollectionsHelp }} +

{{ $strings.MessageNoCollections }}

+
+

{{ $strings.MessageBookshelfNoCollectionsHelp }}

help_outline -

+
-
+
diff --git a/client/components/modals/playlists/AddCreateModal.vue b/client/components/modals/playlists/AddCreateModal.vue index 51f7e475..4a7daad9 100644 --- a/client/components/modals/playlists/AddCreateModal.vue +++ b/client/components/modals/playlists/AddCreateModal.vue @@ -19,17 +19,17 @@
-
+
-

{{ $strings.MessageNoUserPlaylists }}

-

- {{ $strings.MessageNoUserPlaylistsHelp }} +

{{ $strings.MessageNoUserPlaylists }}

+
+

{{ $strings.MessageNoUserPlaylistsHelp }}

help_outline -

+
From 4a76ba0226a37fe57531740acf2414c2d74fbbb9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 31 Jan 2025 17:11:57 -0600 Subject: [PATCH 186/509] Remove copy of series numbers on book cards --- client/components/cards/LazyBookCard.vue | 7 ------- 1 file changed, 7 deletions(-) diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index 544b7fec..91e7d9c8 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -31,13 +31,6 @@
-
-

#{{ seriesSequenceList }}

-
-
-

{{ booksInSeries }}

-
-
From a58d486c444eebc61983bbe089acddba7ea4553d Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 31 Jan 2025 17:18:23 -0600 Subject: [PATCH 187/509] Fix:Collapsed subseries showing parent series name on hover #3713 --- client/components/cards/LazyBookCard.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index 91e7d9c8..d506a1b7 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -237,6 +237,7 @@ export default { return this.mediaMetadata.series }, seriesName() { + if (this.collapsedSeries?.name) return this.collapsedSeries.name return this.series?.name || null }, seriesSequence() { From aebb3ff4137793ecb1ebaa9c9c6e667a35ba2edd Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 1 Feb 2025 16:47:36 -0600 Subject: [PATCH 188/509] Fix API including basepath in tracks contentUrl --- client/pages/audiobook/_id/chapters.vue | 5 +---- client/players/AudioTrack.js | 13 ++++++++++--- client/players/PlayerHandler.js | 2 +- server/models/Book.js | 2 +- server/models/PodcastEpisode.js | 2 +- server/objects/files/AudioTrack.js | 4 ++-- 6 files changed, 16 insertions(+), 12 deletions(-) diff --git a/client/pages/audiobook/_id/chapters.vue b/client/pages/audiobook/_id/chapters.vue index d7a8f9e1..9fe5773c 100644 --- a/client/pages/audiobook/_id/chapters.vue +++ b/client/pages/audiobook/_id/chapters.vue @@ -414,11 +414,8 @@ export default { const audioEl = this.audioEl || document.createElement('audio') var src = audioTrack.contentUrl + `?token=${this.userToken}` - if (this.$isDev) { - src = `${process.env.serverUrl}${src}` - } - audioEl.src = src + audioEl.src = `${process.env.serverUrl}${src}` audioEl.id = 'chapter-audio' document.body.appendChild(audioEl) diff --git a/client/players/AudioTrack.js b/client/players/AudioTrack.js index 9627d3cd..05f11ad9 100644 --- a/client/players/AudioTrack.js +++ b/client/players/AudioTrack.js @@ -1,5 +1,5 @@ export default class AudioTrack { - constructor(track, userToken) { + constructor(track, userToken, routerBasePath) { this.index = track.index || 0 this.startOffset = track.startOffset || 0 // Total time of all previous tracks this.duration = track.duration || 0 @@ -9,20 +9,27 @@ export default class AudioTrack { this.metadata = track.metadata || {} this.userToken = userToken + this.routerBasePath = routerBasePath || '' } + /** + * Used for CastPlayer + */ get fullContentUrl() { if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl if (process.env.NODE_ENV === 'development') { return `${process.env.serverUrl}${this.contentUrl}?token=${this.userToken}` } - return `${window.location.origin}${this.contentUrl}?token=${this.userToken}` + return `${window.location.origin}${this.routerBasePath}${this.contentUrl}?token=${this.userToken}` } + /** + * Used for LocalPlayer + */ get relativeContentUrl() { if (!this.contentUrl || this.contentUrl.startsWith('http')) return this.contentUrl - return this.contentUrl + `?token=${this.userToken}` + return `${this.routerBasePath}${this.contentUrl}?token=${this.userToken}` } } diff --git a/client/players/PlayerHandler.js b/client/players/PlayerHandler.js index ba71fc6c..6e4baa45 100644 --- a/client/players/PlayerHandler.js +++ b/client/players/PlayerHandler.js @@ -226,7 +226,7 @@ export default class PlayerHandler { console.log('[PlayerHandler] Preparing Session', session) - var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, this.userToken)) + var audioTracks = session.audioTracks.map((at) => new AudioTrack(at, this.userToken, this.ctx.$config.routerBasePath)) this.ctx.playerLoading = true this.isHlsTranscode = true diff --git a/server/models/Book.js b/server/models/Book.js index 527960ea..94e017f5 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -286,7 +286,7 @@ class Book extends Model { const track = structuredClone(af) track.title = af.metadata.filename track.startOffset = startOffset - track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}` + track.contentUrl = `/api/items/${libraryItemId}/file/${track.ino}` startOffset += track.duration return track }) diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 9eb14632..08baa4be 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -169,7 +169,7 @@ class PodcastEpisode extends Model { const track = structuredClone(this.audioFile) track.startOffset = 0 track.title = this.audioFile.metadata.filename - track.contentUrl = `${global.RouterBasePath}/api/items/${libraryItemId}/file/${track.ino}` + track.contentUrl = `/api/items/${libraryItemId}/file/${track.ino}` return track } diff --git a/server/objects/files/AudioTrack.js b/server/objects/files/AudioTrack.js index b50d3e3f..a27c2a01 100644 --- a/server/objects/files/AudioTrack.js +++ b/server/objects/files/AudioTrack.js @@ -29,7 +29,7 @@ class AudioTrack { this.duration = audioFile.duration this.title = audioFile.metadata.filename || '' - this.contentUrl = `${global.RouterBasePath}/api/items/${itemId}/file/${audioFile.ino}` + this.contentUrl = `/api/items/${itemId}/file/${audioFile.ino}` this.mimeType = audioFile.mimeType this.codec = audioFile.codec || null this.metadata = audioFile.metadata.clone() @@ -44,4 +44,4 @@ class AudioTrack { this.mimeType = 'application/vnd.apple.mpegurl' } } -module.exports = AudioTrack \ No newline at end of file +module.exports = AudioTrack From bcf8f6b732032a12a46ce94a9c0c111c7a83daf1 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: Sat, 25 Jan 2025 11:45:09 +0000 Subject: [PATCH 189/509] Translated using Weblate (Russian) Currently translated at 100.0% (1086 of 1086 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/ --- client/strings/ru.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/strings/ru.json b/client/strings/ru.json index 84a176f2..853052a4 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -10,6 +10,8 @@ "ButtonApplyChapters": "Применить главы", "ButtonAuthors": "Авторы", "ButtonBack": "Назад", + "ButtonBatchEditPopulateFromExisting": "Заполнить из существующих", + "ButtonBatchEditPopulateMapDetails": "Заполнить данные карты", "ButtonBrowseForFolder": "Выбрать папку", "ButtonCancel": "Отмена", "ButtonCancelEncode": "Отменить кодирование", @@ -704,6 +706,8 @@ "MessageBackupsLocationEditNote": "Примечание: Обновление местоположения резервной копии не приведет к перемещению или изменению существующих резервных копий", "MessageBackupsLocationNoEditNote": "Примечание: Местоположение резервного копирования задается с помощью переменной среды и не может быть изменено здесь.", "MessageBackupsLocationPathEmpty": "Путь к расположению резервной копии не может быть пустым", + "MessageBatchEditPopulateMapDetailsAllHelp": "Заполнить включенные поля данными из всех элементов. Поля с несколькими значениями будут объединены", + "MessageBatchEditPopulateMapDetailsItemHelp": "Заполнить активированные поля сведений о карте данными из этого элемента", "MessageBatchQuickMatchDescription": "Быстрый Поиск попытается добавить отсутствующие обложки и метаданные для выбранных элементов. Включите параметры ниже, чтобы разрешить Быстрому Поиску перезаписывать существующие обложки и/или метаданные.", "MessageBookshelfNoCollections": "Вы еще не создали ни одной коллекции", "MessageBookshelfNoRSSFeeds": "Нет открытых RSS-каналов", From d245c93da47e7140c3ab3a3c2432e92983f84bd2 Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Sat, 25 Jan 2025 11:12:32 +0000 Subject: [PATCH 190/509] Translated using Weblate (Swedish) Currently translated at 85.1% (925 of 1086 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index a5fb27ad..b4a12982 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -110,7 +110,7 @@ "HeaderAccount": "Konto", "HeaderAddCustomMetadataProvider": "Addera egen källa för metadata", "HeaderAdvanced": "Avancerad", - "HeaderAppriseNotificationSettings": "Apprise Meddelandeinställningar", + "HeaderAppriseNotificationSettings": "Inställningar av meddelanden med Apprise", "HeaderAudioTracks": "Ljudspår", "HeaderAudiobookTools": "Hantering av ljudboksfil", "HeaderAuthentication": "Autentisering", @@ -483,7 +483,7 @@ "LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas", "LabelSelectUsers": "Välj användare", "LabelSendEbookToDevice": "Skicka e-bok till...", - "LabelSequence": "Sekvens", + "LabelSequence": "Sekvensnummer", "LabelSeries": "Serier", "LabelSeriesName": "Serienamn", "LabelSeriesProgress": "Status för serier", @@ -851,7 +851,9 @@ "ToastDeviceTestEmailFailed": "Misslyckades med att skicka ett testmail", "ToastDeviceTestEmailSuccess": "Ett testmail har skickats", "ToastEmailSettingsUpdateSuccess": "Inställningarna av e-post har uppdaterats", + "ToastEncodeCancelSucces": "Omkodningen avbruten", "ToastFailedToLoadData": "Misslyckades med att ladda data", + "ToastFailedToUpdate": "Misslyckades med att uppdatera", "ToastInvalidImageUrl": "Felaktig URL-adress till omslagsbilden", "ToastInvalidMaxEpisodesToDownload": "Ogiltigt maximalt antal avsnitt att ladda ner", "ToastInvalidUrl": "Felaktig URL-adress", @@ -878,7 +880,12 @@ "ToastNameRequired": "Ett namn måste anges", "ToastNewUserCreatedFailed": "Misslyckades med att skapa kontot \"{0}\"", "ToastNewUserCreatedSuccess": "Ett nytt konto har skapats", + "ToastNewUserLibraryError": "Minst ett bibliotek måste anges", + "ToastNewUserPasswordError": "Ett lösenord måste anges. Endast användaren 'root' kan vara utan lösenord.", + "ToastNewUserUsernameError": "Ange ett användarnamn", "ToastNoUpdatesNecessary": "Inga uppdateringar var nödvändiga", + "ToastNotificationCreateFailed": "Misslyckades med att skapa meddelandet", + "ToastNotificationDeleteFailed": "Misslyckades med att radera meddelandet", "ToastPlaylistCreateFailed": "Det gick inte att skapa spellistan", "ToastPlaylistCreateSuccess": "Spellistan skapad", "ToastPlaylistRemoveSuccess": "Spellistan har tagits bort", @@ -887,11 +894,14 @@ "ToastPodcastCreateSuccess": "Podcasten skapad framgångsrikt", "ToastProviderCreatedFailed": "Misslyckades med att addera en källa", "ToastProviderCreatedSuccess": "En ny källa har adderats", + "ToastProviderNameAndUrlRequired": "Ett namn och en URL-adress krävs", "ToastProviderRemoveSuccess": "Källan har tagits bort", "ToastRSSFeedCloseFailed": "Misslyckades med att stänga RSS-flödet", "ToastRSSFeedCloseSuccess": "RSS-flödet stängt", + "ToastRemoveFailed": "Misslyckades med att radera", "ToastRemoveItemFromCollectionFailed": "Misslyckades med att ta bort objektet från samlingen", "ToastRemoveItemFromCollectionSuccess": "Objektet borttaget från samlingen", + "ToastRenameFailed": "Misslyckades med att ändra namn", "ToastSelectAtLeastOneUser": "Åtminstone en användare måste väljas", "ToastSendEbookToDeviceFailed": "Misslyckades med att skicka e-boken till enheten", "ToastSendEbookToDeviceSuccess": "E-boken skickad till enheten \"{0}\"", @@ -912,5 +922,6 @@ "ToastUserDeleteSuccess": "Användaren borttagen", "ToastUserPasswordChangeSuccess": "Lösenordet har ändrats", "ToastUserPasswordMismatch": "Lösenorden är inte identiska", - "ToastUserPasswordMustChange": "Det nya lösenordet kan inte vara samma som det gamla" + "ToastUserPasswordMustChange": "Det nya lösenordet kan inte vara samma som det gamla", + "ToastUserRootRequireName": "Ett användarnamn för 'root' måste anges" } From a38a92b948a6846a806b1a3d7613d36c445eb99f Mon Sep 17 00:00:00 2001 From: SunSpring Date: Sat, 25 Jan 2025 10:46:07 +0000 Subject: [PATCH 191/509] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1086 of 1086 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 1c536275..53c8484e 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -10,6 +10,8 @@ "ButtonApplyChapters": "应用到章节", "ButtonAuthors": "作者", "ButtonBack": "返回", + "ButtonBatchEditPopulateFromExisting": "用现有内容填充", + "ButtonBatchEditPopulateMapDetails": "填充地图详细信息", "ButtonBrowseForFolder": "浏览文件夹", "ButtonCancel": "取消", "ButtonCancelEncode": "取消编码", @@ -704,6 +706,8 @@ "MessageBackupsLocationEditNote": "注意: 更新备份位置不会移动或修改现有备份", "MessageBackupsLocationNoEditNote": "注意: 备份位置是通过环境变量设置的, 不能在此处更改.", "MessageBackupsLocationPathEmpty": "备份位置路径不能为空", + "MessageBatchEditPopulateMapDetailsAllHelp": "使用所有项目的数据填充已启用的字段. 具有多个值的字段将被合并", + "MessageBatchEditPopulateMapDetailsItemHelp": "使用此项目的数据填充已启用的地图详细信息字段", "MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.", "MessageBookshelfNoCollections": "你尚未进行任何收藏", "MessageBookshelfNoRSSFeeds": "没有打开的 RSS 源", From 0a29b549df18033c9fc5a56c06bd5c3015725281 Mon Sep 17 00:00:00 2001 From: Simple16 Date: Sat, 25 Jan 2025 11:59:03 +0000 Subject: [PATCH 192/509] Translated using Weblate (Russian) Currently translated at 100.0% (1086 of 1086 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 853052a4..644621b9 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -465,7 +465,7 @@ "LabelNotificationsMaxQueueSize": "Макс. размер очереди для событий уведомлений", "LabelNotificationsMaxQueueSizeHelp": "События ограничены 1 в секунду. События будут игнорированы если в очереди максимальное количество. Это предотвращает спам сообщениями.", "LabelNumberOfBooks": "Количество книг", - "LabelNumberOfEpisodes": "# Эпизодов", + "LabelNumberOfEpisodes": "# из эпизодов", "LabelOpenIDAdvancedPermsClaimDescription": "Имя утверждения OpenID, содержащего расширенные разрешения на действия пользователя в приложении, которые будут применяться к ролям, не являющимся администраторами (если они настроены). Если утверждение отсутствует в ответе, в доступе к ABS будет отказано. Если одна опция отсутствует, она будет рассматриваться как false. Убедитесь, что утверждение поставщика удостоверений соответствует ожидаемой структуре:", "LabelOpenIDClaims": "Оставьте следующие параметры пустыми, чтобы отключить расширенное назначение групп и разрешений, будет автоматически присвоена группа «Пользователь».", "LabelOpenIDGroupClaimDescription": "Имя утверждения OpenID, содержащего список групп пользователя. Обычно их называют groups. Если эта настройка настроена, приложение будет автоматически назначать роли на основе членства пользователя в группах при условии, что эти группы названы в утверждении без учета регистра \"admin\", \"user\" или \"guest\". Утверждение должно содержать список, и если пользователь принадлежит к нескольким группам, то приложение назначит роль, соответствующую самому высокому уровню доступа. Если ни одна из групп не совпадает, доступ будет запрещен.", From 35eb5bcfc0686d1238b3e8dfa1d34737aca8e710 Mon Sep 17 00:00:00 2001 From: biuklija Date: Sat, 25 Jan 2025 20:56:42 +0000 Subject: [PATCH 193/509] Translated using Weblate (Croatian) Currently translated at 100.0% (1086 of 1086 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index 84912f9a..f65a8d4b 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -10,6 +10,8 @@ "ButtonApplyChapters": "Primijeni poglavlja", "ButtonAuthors": "Autori", "ButtonBack": "Natrag", + "ButtonBatchEditPopulateFromExisting": "Popuni iz postojećeg", + "ButtonBatchEditPopulateMapDetails": "Popuni mapirane pojedinosti", "ButtonBrowseForFolder": "Pronađi mapu", "ButtonCancel": "Odustani", "ButtonCancelEncode": "Otkaži kodiranje", @@ -288,7 +290,7 @@ "LabelCustomCronExpression": "Prilagođeni CRON izraz:", "LabelDatetime": "Datum i vrijeme", "LabelDays": "Dani", - "LabelDeleteFromFileSystemCheckbox": "Izbriši datoteke (uklonite oznaku ako stavku želite izbrisati samo iz baze podataka)", + "LabelDeleteFromFileSystemCheckbox": "Izbriši datoteke (uklonite kvačicu ako stavku želite izbrisati samo iz baze podataka)", "LabelDescription": "Opis", "LabelDeselectAll": "Odznači sve", "LabelDevice": "Uređaj", @@ -704,6 +706,8 @@ "MessageBackupsLocationEditNote": "Napomena: Uređivanje lokacije za sigurnosne kopije ne premješta ili mijenja postojeće sigurnosne kopije", "MessageBackupsLocationNoEditNote": "Napomena: Lokacija za sigurnosne kopije zadana je kroz varijablu okoline i ovdje se ne može izmijeniti.", "MessageBackupsLocationPathEmpty": "Putanja do lokacije za sigurnosne kopije ne može ostati prazna", + "MessageBatchEditPopulateMapDetailsAllHelp": "Nadopunjuje omogućena polja podatcima iz svih stavki. Polja s višestrukim podatcima će se spojiti", + "MessageBatchEditPopulateMapDetailsItemHelp": "Popuni omogućena polja mapiranih pojedinosti s podatcima iz ove stavke", "MessageBatchQuickMatchDescription": "Brzo prepoznavanje za odabrane će stavke pokušati dodati naslovnice i meta-podatke koji nedostaju. Uključite donje opcije ako želite da Brzo prepoznavanje prepiše postojeće naslovnice i/ili meta-podatke.", "MessageBookshelfNoCollections": "Niste izradili niti jednu zbirku", "MessageBookshelfNoRSSFeeds": "Nema otvorenih RSS izvora", @@ -721,7 +725,7 @@ "MessageConfirmDeleteDevice": "Sigurno želite izbrisati e-čitač \"{0}\"?", "MessageConfirmDeleteFile": "Ovo će izbrisati datoteke s datotečnog sustava. Jeste li sigurni?", "MessageConfirmDeleteLibrary": "Sigurno želite trajno izbrisati knjižnicu \"{0}\"?", - "MessageConfirmDeleteLibraryItem": "Ovo će izbrisati knjižničku stavku iz datoteke i vašeg datotečnog sustava. Jeste li sigurni?", + "MessageConfirmDeleteLibraryItem": "Ovo će izbrisati knjižničku stavku iz baze podataka i s datotečnog sustava. Jeste li sigurni?", "MessageConfirmDeleteLibraryItems": "Ovo će izbrisati {0} knjižničkih stavki iz baze podataka i datotečnog sustava. Jeste li sigurni?", "MessageConfirmDeleteMetadataProvider": "Sigurno želite izbrisati prilagođenog pružatelja meta-podataka \"{0}\"?", "MessageConfirmDeleteNotification": "Sigurno želite izbrisati ovu obavijest?", From 74c87a0bbd2c3f8b9ed1ca02cee4c0de1cb73a1a Mon Sep 17 00:00:00 2001 From: Andreas Morell-Reng Date: Mon, 27 Jan 2025 07:07:40 +0000 Subject: [PATCH 194/509] Translated using Weblate (Danish) Currently translated at 100.0% (1086 of 1086 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/da/ --- client/strings/da.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/strings/da.json b/client/strings/da.json index d36011fd..9816ab11 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -10,6 +10,8 @@ "ButtonApplyChapters": "Anvend kapitler", "ButtonAuthors": "Forfattere", "ButtonBack": "Tilbage", + "ButtonBatchEditPopulateFromExisting": "Opret fra eksisterende", + "ButtonBatchEditPopulateMapDetails": "Opret fra kortlægnings detaljer", "ButtonBrowseForFolder": "Gennemse mappe", "ButtonCancel": "Annuller", "ButtonCancelEncode": "Annuller kodning", @@ -465,7 +467,7 @@ "LabelNumberOfBooks": "Antal bøger", "LabelNumberOfEpisodes": "# afsnit", "LabelOpenIDAdvancedPermsClaimDescription": "Navnet af OpenID claimet som indeholder avancerede brugerhandlinger inden i applikationen som vil gælde for ikke administrative roller (hvis konfigureret). Hvis et claim mangler fra svaret vil adgang til ABS blive nægtet. Hvis en enkelt indstilling/option mangler, vil det bliver behandlet som false. Sørg for at identity provider's claim matcher den forventede struktur:", - "LabelOpenIDClaims": "Efterlad de følgende indstillinger tomme for at deaktivere avancerede grupper og adgangsstyring for automatisk at tilføje dem til 'User' gruppen.", + "LabelOpenIDClaims": "Efterlad de følgende indstillinger tomme for at deaktivere avanceret gruppe og adgangsindstilling, ved automatisk at assigne 'Bruger' grupper.", "LabelOpenIDGroupClaimDescription": "Navnet af det OpenID claim som skal indeholde brugerens grupper. Mest kendt som groups. hvis konfigureret, vil applikationen automatiske tildele roller baseret p[ brugerens gruppemedlemsskaber, givet disse grupper er navngivet (uden forbehold for store og små bogstaver) 'admin', 'user' eller 'guest' i claimet. Claimet burde indeholde en liste (og hvis brugeren tilhøre flere grupper) som applikationen vil tildele roller med højeste adgangsnvieau. Hvis ingen grupper matcher vil adgang blive nægtet.", "LabelOpenRSSFeed": "Åbn RSS-feed", "LabelOverwrite": "Overskriv", @@ -662,7 +664,7 @@ "LabelTrailer": "Trailer", "LabelType": "Type", "LabelUnabridged": "Uforkortet", - "LabelUndo": "Undo", + "LabelUndo": "Fortryd", "LabelUnknown": "Ukendt", "LabelUnknownPublishDate": "Ukendt publiceringsdato", "LabelUpdateCover": "Opdater omslag", @@ -704,6 +706,8 @@ "MessageBackupsLocationEditNote": "Note: Opdatering af backup sti vil ikke fjerne eller modificere eksisterende backups", "MessageBackupsLocationNoEditNote": "Note: Backup sti er sat igennem miljøvariabel og kan ikke ændres her.", "MessageBackupsLocationPathEmpty": "Backup sti kan ikke være tom", + "MessageBatchEditPopulateMapDetailsAllHelp": "Opret felter slået til med data fra alle genstande. Felter med flere værdier vil blive sammenflettet", + "MessageBatchEditPopulateMapDetailsItemHelp": "Opret kort med værdier der er slået til fra felter med data fra denne genstand", "MessageBatchQuickMatchDescription": "Quick Match vil forsøge at tilføje manglende omslag og metadata til de valgte elementer. Aktivér indstillingerne nedenfor for at tillade Quick Match at overskrive eksisterende omslag og/eller metadata.", "MessageBookshelfNoCollections": "Du har ikke oprettet nogen samlinger endnu", "MessageBookshelfNoRSSFeeds": "Ingen RSS-feeds er åbne", From f82697cbbfac88b7ac9f62ef793d1c8863c9b214 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: Mon, 27 Jan 2025 11:06:11 +0000 Subject: [PATCH 195/509] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1086 of 1086 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 ebdc3790..d09ea1ba 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -434,7 +434,7 @@ "LabelMetadataProvider": "Джерело метаданих", "LabelMinute": "Хвилина", "LabelMinutes": "Хвилини", - "LabelMissing": "Бракує", + "LabelMissing": "Відсутня", "LabelMissingEbook": "Без електронної книги", "LabelMissingSupplementaryEbook": "Без додаткової електронної книги", "LabelMobileRedirectURIs": "Дозволені адреси перенаправлення", From 437c8dd09c6101329b34431cd7da71f7d31c1500 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Mon, 27 Jan 2025 06:22:23 +0000 Subject: [PATCH 196/509] Translated using Weblate (Slovenian) Currently translated at 100.0% (1086 of 1086 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 47cbb49e..7d6bb93d 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -434,7 +434,7 @@ "LabelMetadataProvider": "Ponudnik metapodatkov", "LabelMinute": "Minuta", "LabelMinutes": "Minute", - "LabelMissing": "Manjkajoče", + "LabelMissing": "Manjka", "LabelMissingEbook": "Nima nobene e-knjige", "LabelMissingSupplementaryEbook": "Nima nobene dodatne e-knjige", "LabelMobileRedirectURIs": "Dovoljeni mobilni preusmeritveni URI-ji", From 8bd336a4bac0952a1cf8d55a73bf00f9f2f8a1d4 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: Tue, 28 Jan 2025 11:47:18 +0000 Subject: [PATCH 197/509] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1087 of 1087 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/uk.json b/client/strings/uk.json index d09ea1ba..d630bb8f 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -486,6 +486,7 @@ "LabelPersonalYearReview": "Ваші підсумки року ({0})", "LabelPhotoPathURL": "Шлях/URL фото", "LabelPlayMethod": "Метод відтворення", + "LabelPlaybackRateIncrementDecrement": "Величина збільшення/зменшення швидкості відтворення", "LabelPlayerChapterNumberMarker": "{0} з {1}", "LabelPlaylists": "Списки відтворення", "LabelPodcast": "Подкаст", From 4cc97a22f63bec229747d1e30a24406d9a4d1279 Mon Sep 17 00:00:00 2001 From: Will Forde Date: Tue, 28 Jan 2025 00:18:21 +0000 Subject: [PATCH 198/509] Translated using Weblate (Japanese) Currently translated at 0.1% (1 of 1087 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ja/ --- client/strings/ja.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/strings/ja.json b/client/strings/ja.json index 0967ef42..80af12d8 100644 --- a/client/strings/ja.json +++ b/client/strings/ja.json @@ -1 +1,3 @@ -{} +{ + "ButtonAdd": "追加" +} From 989388d3ed21508363fc6b075ac40e9b44889751 Mon Sep 17 00:00:00 2001 From: Michel Neuba Date: Wed, 29 Jan 2025 15:01:30 +0000 Subject: [PATCH 199/509] Translated using Weblate (French) Currently translated at 99.7% (1084 of 1087 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/strings/fr.json b/client/strings/fr.json index 8b640a6b..dbefb555 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -10,6 +10,8 @@ "ButtonApplyChapters": "Appliquer aux chapitres", "ButtonAuthors": "Auteurs", "ButtonBack": "Retour", + "ButtonBatchEditPopulateFromExisting": "Remplir à partir de l'existant", + "ButtonBatchEditPopulateMapDetails": "Remplir les détails de la carte", "ButtonBrowseForFolder": "Naviguer vers le répertoire", "ButtonCancel": "Annuler", "ButtonCancelEncode": "Annuler l’encodage", @@ -484,6 +486,7 @@ "LabelPersonalYearReview": "Bilan de l’année ({0})", "LabelPhotoPathURL": "Chemin / URL des photos", "LabelPlayMethod": "Méthode d’écoute", + "LabelPlaybackRateIncrementDecrement": "Augmentation/Diminition de la vitesse de lecture", "LabelPlayerChapterNumberMarker": "{0} sur {1}", "LabelPlaylists": "Listes de lecture", "LabelPodcast": "Podcast", @@ -704,6 +707,7 @@ "MessageBackupsLocationEditNote": "Remarque : Mettre à jour l'emplacement de sauvegarde ne déplacera pas ou ne modifiera pas les sauvegardes existantes", "MessageBackupsLocationNoEditNote": "Remarque : l’emplacement de sauvegarde est défini via une variable d’environnement et ne peut pas être modifié ici.", "MessageBackupsLocationPathEmpty": "L'emplacement de secours ne peut pas être vide", + "MessageBatchEditPopulateMapDetailsAllHelp": "Remplir les champs disponibles avec les données de tous les éléments. les champs avec des valeurs multiples seront fusionnés", "MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera d’ajouter les couvertures et métadonnées manquantes pour les éléments sélectionnés. Activez les options ci-dessous pour permettre la Recherche par correspondance d’écraser les couvertures et/ou métadonnées existantes.", "MessageBookshelfNoCollections": "Vous n’avez pas encore de collections", "MessageBookshelfNoRSSFeeds": "Aucun flux RSS n’est ouvert", From c62a6fbffd7a37b775ada947baf8c1a0e2b97ba8 Mon Sep 17 00:00:00 2001 From: biuklija Date: Wed, 29 Jan 2025 11:53:11 +0000 Subject: [PATCH 200/509] Translated using Weblate (Croatian) Currently translated at 100.0% (1087 of 1087 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/hr.json b/client/strings/hr.json index f65a8d4b..2871c0c8 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -486,6 +486,7 @@ "LabelPersonalYearReview": "Vaš godišnji pregled ({0})", "LabelPhotoPathURL": "Putanja ili URL fotografije", "LabelPlayMethod": "Način reprodukcije", + "LabelPlaybackRateIncrementDecrement": "Korak povećanja/smanjenja brzine reprodukcije", "LabelPlayerChapterNumberMarker": "{0} od {1}", "LabelPlaylists": "Popisi za izvođenje", "LabelPodcast": "Podcast", From 88c10ad61987b3accb62351079dd3ab024156e49 Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Thu, 30 Jan 2025 07:58:19 +0000 Subject: [PATCH 201/509] Translated using Weblate (Swedish) Currently translated at 85.4% (929 of 1087 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index b4a12982..6180652f 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -153,6 +153,7 @@ "HeaderMetadataToEmbed": "Metadata som kommer att adderas", "HeaderNewAccount": "Nytt konto", "HeaderNewLibrary": "Nytt bibliotek", + "HeaderNotificationCreate": "Addera ett meddelande", "HeaderNotifications": "Meddelanden", "HeaderOpenRSSFeed": "Öppna RSS-flöde", "HeaderOtherFiles": "Andra filer", @@ -205,7 +206,7 @@ "LabelAccountTypeUser": "Användare", "LabelActivity": "Aktivitet", "LabelAddToCollection": "Lägg till i en samling", - "LabelAddToCollectionBatch": "Lägg till {0} böcker i en Samling", + "LabelAddToCollectionBatch": "Lägg till {0} böcker i samlingen", "LabelAddToPlaylist": "Lägg till i en spellista", "LabelAddToPlaylistBatch": "Lägg till {0} objekt i Spellistan", "LabelAddedAt": "Datum adderad", @@ -215,7 +216,7 @@ "LabelAllUsers": "Alla användare", "LabelAllUsersExcludingGuests": "Alla användare utom gäster", "LabelAllUsersIncludingGuests": "Alla användare inklusive gäster", - "LabelAlreadyInYourLibrary": "Redan i din samling", + "LabelAlreadyInYourLibrary": "Finns redan i samlingen", "LabelApiToken": "API-token", "LabelAppend": "Lägg till", "LabelAudioBitrate": "Bitrate för ljud (t.ex. 128k)", @@ -637,7 +638,7 @@ "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", + "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.", @@ -660,8 +661,10 @@ "MessageConfirmDeleteLibraryItem": "Detta kommer att radera objektet 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?", "MessageConfirmDeleteMetadataProvider": "Är du säker på att du vill radera din egen källa för metadata \"{0}\"?", + "MessageConfirmDeleteNotification": "Är du säker på att du vill radera detta meddelande?", "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?", + "MessageConfirmEmbedMetadataInAudioFiles": "Är du säker på att du vill infoga metadata i {0} ljudfiler?", + "MessageConfirmForceReScan": "Är du säker på att du vill starta en ny skanning?", "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?", "MessageConfirmMarkItemFinished": "Är du säker på att du vill markera \"{0}\" som avslutad?", @@ -678,7 +681,7 @@ "MessageConfirmRemoveEpisode": "Är du säker på att du vill ta bort avsnittet \"{0}\"?", "MessageConfirmRemoveEpisodes": "Är du säker på att du vill ta bort {0} avsnitt?", "MessageConfirmRemoveListeningSessions": "Är du säker på att du vill radera {0} lyssningstillfällen?", - "MessageConfirmRemoveMetadataFiles": "Är du säker på att du vill radera 'metadata.{0}' filerna i alla mappar i ditt bibliotek?", + "MessageConfirmRemoveMetadataFiles": "Är du säker på att du vill radera filerna 'metadata.{0}' i alla mappar i ditt bibliotek?", "MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort uppläsaren \"{0}\"?", "MessageConfirmRemovePlaylist": "Är du säker på att du vill ta bort din spellista \"{0}\"?", "MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på kategorin \"{0}\" till \"{1}\" för alla objekt?", @@ -748,7 +751,7 @@ "MessageOr": "eller", "MessagePauseChapter": "Pausa kapiteluppspelning", "MessagePlayChapter": "Lyssna på kapitlets början", - "MessagePlaylistCreateFromCollection": "Skapa spellista från samling", + "MessagePlaylistCreateFromCollection": "Skapa en spellista från samlingen", "MessagePleaseWait": "Vänta ett ögonblick...", "MessagePodcastHasNoRSSFeedForMatching": "Podcasten har ingen RSS-flödes-URL att använda för matchning", "MessageQuickMatchDescription": "Adderar uppgifter som saknas samt en omslagsbild från
första träffen i resultatet vid sökningen från '{0}'.
Skriver inte över befintliga uppgifter om inte
inställningen 'Prioritera matchad metadata' är aktiverad.", @@ -760,6 +763,7 @@ "MessageResetChaptersConfirm": "Är du säker på att du vill återställa alla kapitel och ångra de ändringarna du gjort?", "MessageRestoreBackupConfirm": "Är du säker på att du vill läsa in säkerhetskopian som skapades den", "MessageRestoreBackupWarning": "Att återställa en säkerhetskopia kommer att skriva över hela databasen som finns i /config och omslagsbilder i /metadata/items & /metadata/authors.

Säkerhetskopior ändrar inte några filer i dina biblioteksmappar. Om du har aktiverat serverinställningar för att lagra omslagskonst och metadata i dina biblioteksmappar säkerhetskopieras eller skrivs de inte över.

Alla klienter som använder din server kommer att uppdateras automatiskt.", + "MessageScheduleLibraryScanNote": "För de flesta användare rekommenderas att denna funktion ej aktiveras. Istället bör funktionen 'Watcher' vara aktiverad. Watcher kommer då automatiskt identifiera förändringar i biblioteket. För vissa filsystem (som t.ex. NFS) fungerar inte Watcher. Då kan schemalagda skanningar av biblioteken användas istället.", "MessageSearchResultsFor": "Sökresultat för", "MessageSelected": "{0} valda", "MessageServerCouldNotBeReached": "Servern kunde inte nås", @@ -783,15 +787,15 @@ "MessageXLibraryIsEmpty": "Biblioteket {0} är tomt!", "MessageYourAudiobookDurationIsLonger": "Varaktigheten på din ljudbok är längre än den hittade varaktigheten", "MessageYourAudiobookDurationIsShorter": "Varaktigheten på din ljudbok är kortare än den hittade varaktigheten", - "NoteChangeRootPassword": "Rotanvändaren är den enda användaren som kan ha ett tomt lösenord", + "NoteChangeRootPassword": "Användaren 'root' är den enda användaren som kan vara utan lösenord", "NoteChapterEditorTimes": "OBS: Starttiden för första kapitlet måste vara 0:00 och starttiden för det sista kapitlet får inte överstiga ljudbokens totala varaktighet.", - "NoteFolderPicker": "Obs: Mappar som redan är kartlagda kommer inte att visas", - "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.", + "NoteFolderPicker": "OBS: Mappar som redan är kopplade kommer inte att visas", + "NoteRSSFeedPodcastAppsHttps": "VARNING: De flesta applikationer för podcasts kräver att URL:en för RSS-flödet använder HTTPS", + "NoteRSSFeedPodcastAppsPubDate": "VARNING: Ett eller flera av dina avsnitt har inte ett publiceringsdatum. Vissa applikationer för podcasts kräver detta.", + "NoteUploaderFoldersWithMediaFiles": "Mappar som innehåller mediefiler hanteras som separata objekt i biblioteket.", "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", + "PlaceholderNewCollection": "Nytt namn på samlingen", "PlaceholderNewFolderPath": "Nytt sökväg till mappen", "PlaceholderNewPlaylist": "Nytt namn på spellistan", "PlaceholderSearch": "Sök...", From 9abd6698aef4d5ef37ef183e73d9cacda01e8201 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Thu, 30 Jan 2025 06:52:41 +0000 Subject: [PATCH 202/509] Translated using Weblate (Slovenian) Currently translated at 100.0% (1087 of 1087 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/sl.json b/client/strings/sl.json index 7d6bb93d..d991150b 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -486,6 +486,7 @@ "LabelPersonalYearReview": "Pregled tvojega leta ({0})", "LabelPhotoPathURL": "Slika pot/URL", "LabelPlayMethod": "Metoda predvajanja", + "LabelPlaybackRateIncrementDecrement": "Korak povečanja/zmanjšanja hitrosti predvajanja", "LabelPlayerChapterNumberMarker": "{0} od {1}", "LabelPlaylists": "Seznami predvajanja", "LabelPodcast": "Podcast", From 1fce94ad4a39ae0d462b4e2d512a09d2461aebff Mon Sep 17 00:00:00 2001 From: Andreas Morell-Reng Date: Fri, 31 Jan 2025 09:36:09 +0000 Subject: [PATCH 203/509] Translated using Weblate (Danish) Currently translated at 100.0% (1089 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/da/ --- client/strings/da.json | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/client/strings/da.json b/client/strings/da.json index 9816ab11..226da655 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -5,7 +5,7 @@ "ButtonAddLibrary": "Tilføj Bibliotek", "ButtonAddPodcasts": "Tilføj podcasts", "ButtonAddUser": "Tilføj bruger", - "ButtonAddYourFirstLibrary": "Tilføj din første bibliotek", + "ButtonAddYourFirstLibrary": "Tilføj dit første bibliotek", "ButtonApply": "Anvend", "ButtonApplyChapters": "Anvend kapitler", "ButtonAuthors": "Forfattere", @@ -93,7 +93,7 @@ "ButtonScrollLeft": "Rul til Venstre", "ButtonScrollRight": "Rul til Højre", "ButtonSearch": "Søg", - "ButtonSelectFolderPath": "Vælg Mappen Sti", + "ButtonSelectFolderPath": "Vælg Mappe Sti", "ButtonSeries": "Serier", "ButtonSetChaptersFromTracks": "Sæt kapitler fra spor", "ButtonShare": "Del", @@ -215,7 +215,7 @@ "LabelAbridgedChecked": "Forkortet (kontrolleret)", "LabelAbridgedUnchecked": "Uforkortet (ikke kontrolleret)", "LabelAccessibleBy": "Tilgængelig af", - "LabelAccountType": "Kontotype", + "LabelAccountType": "Brugertype", "LabelAccountTypeAdmin": "Administrator", "LabelAccountTypeGuest": "Gæst", "LabelAccountTypeUser": "Bruger", @@ -226,7 +226,7 @@ "LabelAddToPlaylistBatch": "Tilføj {0} Elementer til Afspilningsliste", "LabelAddedAt": "Tilføjet", "LabelAddedDate": "Tilføjet {0}", - "LabelAdminUsersOnly": "Kun Administratorbrugere", + "LabelAdminUsersOnly": "Kun Administratorer", "LabelAll": "Alle", "LabelAllUsers": "Alle Brugere", "LabelAllUsersExcludingGuests": "Alle bruger eksklusiv gæster", @@ -445,7 +445,7 @@ "LabelNarrator": "Fortæller", "LabelNarrators": "Fortællere", "LabelNew": "Ny", - "LabelNewPassword": "Nyt kodeord", + "LabelNewPassword": "Ny adgangskode", "LabelNewestAuthors": "Nyeste forfattere", "LabelNewestEpisodes": "Nyeste episoder", "LabelNextBackupDate": "Næste sikkerhedskopi dato", @@ -472,7 +472,7 @@ "LabelOpenRSSFeed": "Åbn RSS-feed", "LabelOverwrite": "Overskriv", "LabelPaginationPageXOfY": "Side {0} af {1}", - "LabelPassword": "Kodeord", + "LabelPassword": "Adgangskode", "LabelPath": "Sti", "LabelPermanent": "Permanent", "LabelPermissionsAccessAllLibraries": "Kan få adgang til alle biblioteker", @@ -486,6 +486,7 @@ "LabelPersonalYearReview": "Dit år i review ({0})", "LabelPhotoPathURL": "Foto sti/URL", "LabelPlayMethod": "Afspilningsmetode", + "LabelPlaybackRateIncrementDecrement": "Afspilningshastighed øges/sænkes med", "LabelPlayerChapterNumberMarker": "{0} af {1}", "LabelPlaylists": "Afspilningslister", "LabelPodcast": "Podcast", @@ -575,12 +576,12 @@ "LabelSettingsLibraryMarkAsFinishedWhen": "Marker medie indhold som færdigt når", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Spring til tidligere bøger i Fortsæt serie", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Fortsæt Serien siden hylde viser de første bøger som ikke er startet i serier med mindst en bog som ikke er startet og ingen bøger i gang. Aktivering af denne indstilling vil fortsætte serien fra den sidst gennemførte bog modsat den først ikke startede bog.", - "LabelSettingsParseSubtitles": "Fortolk undertekster", + "LabelSettingsParseSubtitles": "Fortolk undertitler", "LabelSettingsParseSubtitlesHelp": "Udtræk undertekster fra lydbogsmappenavne.
Undertitler skal adskilles af \" - \"
f.eks. \"Bogtitel - En undertitel her\" har undertitlen \"En undertitel her\"", "LabelSettingsPreferMatchedMetadata": "Foretræk matchede metadata", "LabelSettingsPreferMatchedMetadataHelp": "Matchede data vil tilsidesætte elementdetaljer ved brug af Hurtig Match. Som standard udfylder Hurtig Match kun manglende detaljer.", "LabelSettingsSkipMatchingBooksWithASIN": "Spring over matchende bøger, der allerede har en ASIN", - "LabelSettingsSkipMatchingBooksWithISBN": "Spring over matchende bøger, der allerede har en ISBN", + "LabelSettingsSkipMatchingBooksWithISBN": "Spring matchende bøger over, som allerede har et ISBN-nummer", "LabelSettingsSortingIgnorePrefixes": "Ignorer præfikser ved sortering", "LabelSettingsSortingIgnorePrefixesHelp": "f.eks. for præfikset \"the\" vil bogtitlen \"The Book Title\" blive sorteret som \"Book Title, The\"", "LabelSettingsSquareBookCovers": "Brug kvadratiske bogomslag", @@ -710,6 +711,7 @@ "MessageBatchEditPopulateMapDetailsItemHelp": "Opret kort med værdier der er slået til fra felter med data fra denne genstand", "MessageBatchQuickMatchDescription": "Quick Match vil forsøge at tilføje manglende omslag og metadata til de valgte elementer. Aktivér indstillingerne nedenfor for at tillade Quick Match at overskrive eksisterende omslag og/eller metadata.", "MessageBookshelfNoCollections": "Du har ikke oprettet nogen samlinger endnu", + "MessageBookshelfNoCollectionsHelp": "Samlinger er offentlige. Alle brugere med adgang til biblioteket kan se dem.", "MessageBookshelfNoRSSFeeds": "Ingen RSS-feeds er åbne", "MessageBookshelfNoResultsForFilter": "Ingen resultater for filter \"{0}: {1}\"", "MessageBookshelfNoResultsForQuery": "Intet resultat for query", @@ -820,6 +822,7 @@ "MessageNoTasksRunning": "Ingen opgaver kører", "MessageNoUpdatesWereNecessary": "Ingen opdateringer var nødvendige", "MessageNoUserPlaylists": "Du har ingen afspilningslister", + "MessageNoUserPlaylistsHelp": "Playlister er private. Kun brugere som opretter dem kan se dem.", "MessageNotYetImplemented": "Endnu ikke implementeret", "MessageOpmlPreviewNote": "Note: Dette er en forhåndsvisning af den indlæste OPML fil. Podcast titel vil blive taget fra RSS feedet.", "MessageOr": "eller", From b62309ead23df4bdf57903af3bfe544e056167f4 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: Fri, 31 Jan 2025 05:50:24 +0000 Subject: [PATCH 204/509] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1089 of 1089 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, 2 insertions(+) diff --git a/client/strings/uk.json b/client/strings/uk.json index d630bb8f..ba1a2d21 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -711,6 +711,7 @@ "MessageBatchEditPopulateMapDetailsItemHelp": "Заповніть увімкнені поля деталей карти даними з цього елемента", "MessageBatchQuickMatchDescription": "Швидкий пошук спробує знайти відсутні обкладинки та метадані обраних елементів. Увімкніть налаштування нижче, аби дозволити заміну наявних обкладинок та/або метаданих під час швидкого пошуку.", "MessageBookshelfNoCollections": "Ви не створили жодної добірки", + "MessageBookshelfNoCollectionsHelp": "Колекції публічні. Їх можуть бачити всі користувачі, які мають доступ до бібліотеки.", "MessageBookshelfNoRSSFeeds": "Немає відкритих RSS-каналів", "MessageBookshelfNoResultsForFilter": "Немає результатів з фільтром \"{0}: {1}\"", "MessageBookshelfNoResultsForQuery": "Немає результатів за запитом", @@ -821,6 +822,7 @@ "MessageNoTasksRunning": "Немає активних завдань", "MessageNoUpdatesWereNecessary": "Оновлень не потрібно", "MessageNoUserPlaylists": "У вас немає списків відтворення", + "MessageNoUserPlaylistsHelp": "Списки відтворення приватні. Лише користувач, який їх створює, може бачити їх.", "MessageNotYetImplemented": "Ще не реалізовано", "MessageOpmlPreviewNote": "Примітка: це попередній перегляд OPML-файлу. Актуальна назва подкасту буде завантажена з RSS-каналу.", "MessageOr": "або", From 3d9100e5b84b4399c2b94f4ba789812c61f22654 Mon Sep 17 00:00:00 2001 From: Simple16 Date: Sat, 1 Feb 2025 03:16:01 +0000 Subject: [PATCH 205/509] Translated using Weblate (Russian) Currently translated at 100.0% (1089 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/ --- client/strings/ru.json | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/client/strings/ru.json b/client/strings/ru.json index 644621b9..a2c66d8c 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -303,7 +303,7 @@ "LabelDownload": "Скачать", "LabelDownloadNEpisodes": "Скачать {0} эпизодов", "LabelDownloadable": "Загружаемый", - "LabelDuration": "Длина", + "LabelDuration": "Продолжительность", "LabelDurationComparisonExactMatch": "(точное совпадение)", "LabelDurationComparisonLonger": "({0} дольше)", "LabelDurationComparisonShorter": "({0} короче)", @@ -434,7 +434,7 @@ "LabelMetadataProvider": "Провайдер", "LabelMinute": "Минуты", "LabelMinutes": "Минуты", - "LabelMissing": "Потеряно", + "LabelMissing": "Отсутствует", "LabelMissingEbook": "Нет e-книги", "LabelMissingSupplementaryEbook": "Нет дополнительной e-книги", "LabelMobileRedirectURIs": "Разрешенные URI перенаправления с мобильных устройств", @@ -486,6 +486,7 @@ "LabelPersonalYearReview": "Итоги прошедшего года ({0})", "LabelPhotoPathURL": "Путь к фото/URL", "LabelPlayMethod": "Метод воспроизведения", + "LabelPlaybackRateIncrementDecrement": "Величина увеличения/уменьшения скорости воспроизведения", "LabelPlayerChapterNumberMarker": "{0} из {1}", "LabelPlaylists": "Плейлисты", "LabelPodcast": "Подкаст", @@ -653,7 +654,7 @@ "LabelToolsMakeM4bDescription": "Создает .M4B файл аудиокниги с встроенными метаданными, обложкой и главами.", "LabelToolsSplitM4b": "Разделить M4B на MP3 файлы", "LabelToolsSplitM4bDescription": "Создает MP3 файла из M4B, разделяет на главы с встроенными метаданными, обложкой и главами.", - "LabelTotalDuration": "Общая длина", + "LabelTotalDuration": "Общая продолжительность", "LabelTotalTimeListened": "Всего прослушано", "LabelTrackFromFilename": "Трек из Имени файла", "LabelTrackFromMetadata": "Трек из Метаданных", @@ -710,6 +711,7 @@ "MessageBatchEditPopulateMapDetailsItemHelp": "Заполнить активированные поля сведений о карте данными из этого элемента", "MessageBatchQuickMatchDescription": "Быстрый Поиск попытается добавить отсутствующие обложки и метаданные для выбранных элементов. Включите параметры ниже, чтобы разрешить Быстрому Поиску перезаписывать существующие обложки и/или метаданные.", "MessageBookshelfNoCollections": "Вы еще не создали ни одной коллекции", + "MessageBookshelfNoCollectionsHelp": "Коллекции являются общедоступными. Все пользователи, имеющие доступ к библиотеке, могут их просматривать.", "MessageBookshelfNoRSSFeeds": "Нет открытых RSS-каналов", "MessageBookshelfNoResultsForFilter": "Нет Результатов для фильтра \"{0}: {1}\"", "MessageBookshelfNoResultsForQuery": "Нет результатов для запроса", @@ -820,6 +822,7 @@ "MessageNoTasksRunning": "Нет выполняемых задач", "MessageNoUpdatesWereNecessary": "Обновления не требовались", "MessageNoUserPlaylists": "У вас нет плейлистов", + "MessageNoUserPlaylistsHelp": "Списки воспроизведения являются конфиденциальными. Только пользователь, который их создает, может их видеть.", "MessageNotYetImplemented": "Пока не реализовано", "MessageOpmlPreviewNote": "Примечание: Это предварительный просмотр разобранного файла OPML. Фактическое название подкаста будет взято из RSS-канала.", "MessageOr": "или", From 13f353596b96623c4d67ea6f839ff75e868193c5 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Sat, 1 Feb 2025 01:38:03 +0000 Subject: [PATCH 206/509] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1089 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 53c8484e..2ebec738 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -434,7 +434,7 @@ "LabelMetadataProvider": "元数据提供商", "LabelMinute": "分钟", "LabelMinutes": "分钟", - "LabelMissing": "丢失", + "LabelMissing": "丢失的", "LabelMissingEbook": "没有电子书", "LabelMissingSupplementaryEbook": "没有补充电子书", "LabelMobileRedirectURIs": "允许移动应用重定向 URI", @@ -486,6 +486,7 @@ "LabelPersonalYearReview": "你的年度回顾 ({0})", "LabelPhotoPathURL": "图片路径或 URL", "LabelPlayMethod": "播放方法", + "LabelPlaybackRateIncrementDecrement": "播放速率增加/减少量", "LabelPlayerChapterNumberMarker": "{0} 于 {1}", "LabelPlaylists": "播放列表", "LabelPodcast": "播客", @@ -710,6 +711,7 @@ "MessageBatchEditPopulateMapDetailsItemHelp": "使用此项目的数据填充已启用的地图详细信息字段", "MessageBatchQuickMatchDescription": "快速匹配将尝试为所选项目添加缺少的封面和元数据. 启用以下选项以允许快速匹配覆盖现有封面和或元数据.", "MessageBookshelfNoCollections": "你尚未进行任何收藏", + "MessageBookshelfNoCollectionsHelp": "收藏是公开的. 所有有权访问图书馆的用户都可以看到它们.", "MessageBookshelfNoRSSFeeds": "没有打开的 RSS 源", "MessageBookshelfNoResultsForFilter": "过滤器无结果 \"{0}: {1}\"", "MessageBookshelfNoResultsForQuery": "没有可查询的结果", @@ -820,6 +822,7 @@ "MessageNoTasksRunning": "没有正在运行的任务", "MessageNoUpdatesWereNecessary": "无需更新", "MessageNoUserPlaylists": "你没有播放列表", + "MessageNoUserPlaylistsHelp": "播放列表是私密的. 只有创建播放列表的用户才能看到.", "MessageNotYetImplemented": "尚未实施", "MessageOpmlPreviewNote": "注意: 这是解析的OPML文件的预览. 实际的播客标题将从 RSS 提要中获取.", "MessageOr": "或", From 5fa4c5a2c3f59f61acf25d1cfcca958fd526af33 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Sat, 1 Feb 2025 12:27:05 +0000 Subject: [PATCH 207/509] Translated using Weblate (German) Currently translated at 99.3% (1082 of 1089 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 99d5bb1e..b31fedc9 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -645,7 +645,7 @@ "LabelTimeToShift": "Zeit bis zum Wechsel in Sekunden", "LabelTitle": "Titel", "LabelToolsEmbedMetadata": "Metadaten einbetten", - "LabelToolsEmbedMetadataDescription": "Bettet die Metadaten einschließlich des Titelbildes und der Kapitel in die Audiodatein ein.", + "LabelToolsEmbedMetadataDescription": "Bettet die Metadaten einschließlich des Titelbildes und der Kapitel in die Audiodateien ein.", "LabelToolsM4bEncoder": "M4B Kodierer", "LabelToolsMakeM4b": "M4B-Datei erstellen", "LabelToolsMakeM4bDescription": "Erstellt eine M4B-Datei (Endung \".m4b\") welche mehrere mp3-Dateien in einer einzigen Datei inkl. derer Metadaten (Beschreibung, Titelbild, Kapitel, ...) zusammenfasst. M4B-Datei können darüber hinaus Lesezeichen speichern und mit einem Abspielschutz (Passwort) versehen werden.", From 3465790fe969bbf73f71ef38a1e479c96c63161b Mon Sep 17 00:00:00 2001 From: biuklija Date: Sat, 1 Feb 2025 10:09:26 +0000 Subject: [PATCH 208/509] Translated using Weblate (Croatian) Currently translated at 100.0% (1089 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index 2871c0c8..eda11cfa 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -401,7 +401,7 @@ "LabelLastBookAdded": "Zadnja dodana knjiga", "LabelLastBookUpdated": "Zadnja ažurirana knjiga", "LabelLastSeen": "Zadnji puta viđen", - "LabelLastTime": "Zadnji puta", + "LabelLastTime": "Zadnje vrijeme", "LabelLastUpdate": "Zadnje ažuriranje", "LabelLayout": "Prikaz", "LabelLayoutSinglePage": "Jedna stranica", @@ -711,6 +711,7 @@ "MessageBatchEditPopulateMapDetailsItemHelp": "Popuni omogućena polja mapiranih pojedinosti s podatcima iz ove stavke", "MessageBatchQuickMatchDescription": "Brzo prepoznavanje za odabrane će stavke pokušati dodati naslovnice i meta-podatke koji nedostaju. Uključite donje opcije ako želite da Brzo prepoznavanje prepiše postojeće naslovnice i/ili meta-podatke.", "MessageBookshelfNoCollections": "Niste izradili niti jednu zbirku", + "MessageBookshelfNoCollectionsHelp": "Zbirke su javne. Svi korisnici s pristupom knjižnici mogu ih vidjeti.", "MessageBookshelfNoRSSFeeds": "Nema otvorenih RSS izvora", "MessageBookshelfNoResultsForFilter": "Nema rezultata za filter \"{0}: {1}\"", "MessageBookshelfNoResultsForQuery": "Vaš upit nema rezultata", @@ -821,6 +822,7 @@ "MessageNoTasksRunning": "Nema zadataka koji se izvode", "MessageNoUpdatesWereNecessary": "Ažuriranje nije bilo potrebno", "MessageNoUserPlaylists": "Nemate popisa za izvođenje", + "MessageNoUserPlaylistsHelp": "Popisi za izvođenje su privatni. Može ih vidjeti samo korisnik koji ih je izradio.", "MessageNotYetImplemented": "Još nije implementirano", "MessageOpmlPreviewNote": "Napomena: Ovo je pretpregled raščlanjene OPML datoteke. Stvarni naslov podcasta preuzet će se iz RSS izvora.", "MessageOr": "ili", From 4464276a6e2ef1876faeea0f7a269189a8f413e1 Mon Sep 17 00:00:00 2001 From: thehijacker Date: Sat, 1 Feb 2025 13:51:58 +0000 Subject: [PATCH 209/509] Translated using Weblate (Slovenian) Currently translated at 100.0% (1089 of 1089 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, 2 insertions(+) diff --git a/client/strings/sl.json b/client/strings/sl.json index d991150b..33e6da86 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -711,6 +711,7 @@ "MessageBatchEditPopulateMapDetailsItemHelp": "Napolni omogočena polja s podrobnostmi zemljevida s podatki iz tega elementa", "MessageBatchQuickMatchDescription": "Hitro ujemanje bo poskušal dodati manjkajoče naslovnice in metapodatke za izbrane elemente. Omogočite spodnje možnosti, da omogočite hitremu ujemanju, da prepiše obstoječe naslovnice in/ali metapodatke.", "MessageBookshelfNoCollections": "Ustvaril nisi še nobene zbirke", + "MessageBookshelfNoCollectionsHelp": "Zbirke so javne. Vsi uporabniki z dostopom do knjižnice jih lahko vidijo.", "MessageBookshelfNoRSSFeeds": "Noben vir RSS ni odprt", "MessageBookshelfNoResultsForFilter": "Ni rezultatov za filter \"{0}: {1}\"", "MessageBookshelfNoResultsForQuery": "Ni rezultatov za poizvedbo", @@ -821,6 +822,7 @@ "MessageNoTasksRunning": "Nobeno opravili ne teče", "MessageNoUpdatesWereNecessary": "Posodobitve niso bile potrebne", "MessageNoUserPlaylists": "Nimate seznamov predvajanja", + "MessageNoUserPlaylistsHelp": "Seznami predvajanj so zasebni. Samo uporabniki, ki jih ustvarijo, jih lahko vidijo.", "MessageNotYetImplemented": "Še ni implementirano", "MessageOpmlPreviewNote": "Opomba: To je predogled razčlenjene datoteke OPML. Dejanski naslov podcasta bo vzet iz vira RSS.", "MessageOr": "ali", From eeaae5f9345036213a2c6dd9908ed7c5a4b3c56d Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 2 Feb 2025 22:06:22 +0100 Subject: [PATCH 210/509] Added translation using Weblate (Turkish) --- client/strings/tr.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 client/strings/tr.json diff --git a/client/strings/tr.json b/client/strings/tr.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/client/strings/tr.json @@ -0,0 +1 @@ +{} From 82ab95ab028711626b01838a2c562c796b36c2b0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 2 Feb 2025 15:39:46 -0600 Subject: [PATCH 211/509] Version bump v2.19.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 cf6ed862..7aa99d67 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.18.1", + "version": "2.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.18.1", + "version": "2.19.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index ee50f38d..7b09854a 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.18.1", + "version": "2.19.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 8d37a6d3..f0bca018 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.18.1", + "version": "2.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.18.1", + "version": "2.19.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index f194b675..ce36e229 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.18.1", + "version": "2.19.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 00343a953b20e675e1132213ecb5d24ce517e5a8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 3 Feb 2025 17:47:10 -0600 Subject: [PATCH 212/509] Update Collection/Playlist and batch quick match modal bg colors to be consistent with other modals --- client/components/modals/BatchQuickMatchModel.vue | 2 +- client/components/modals/collections/AddCreateModal.vue | 2 +- client/components/modals/collections/CollectionItem.vue | 2 +- client/components/modals/playlists/AddCreateModal.vue | 2 +- client/components/modals/playlists/UserPlaylistItem.vue | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/client/components/modals/BatchQuickMatchModel.vue b/client/components/modals/BatchQuickMatchModel.vue index bf596199..558a2fa3 100644 --- a/client/components/modals/BatchQuickMatchModel.vue +++ b/client/components/modals/BatchQuickMatchModel.vue @@ -6,7 +6,7 @@
-
+
diff --git a/client/components/modals/collections/AddCreateModal.vue b/client/components/modals/collections/AddCreateModal.vue index c4878adc..17959526 100644 --- a/client/components/modals/collections/AddCreateModal.vue +++ b/client/components/modals/collections/AddCreateModal.vue @@ -6,7 +6,7 @@
-
+

{{ $strings.LabelAddToCollection }}

diff --git a/client/components/modals/collections/CollectionItem.vue b/client/components/modals/collections/CollectionItem.vue index 70849e98..62ca8620 100644 --- a/client/components/modals/collections/CollectionItem.vue +++ b/client/components/modals/collections/CollectionItem.vue @@ -1,5 +1,5 @@ -
+

{{ $strings.LabelAddToPlaylist }}

diff --git a/client/components/modals/playlists/UserPlaylistItem.vue b/client/components/modals/playlists/UserPlaylistItem.vue index 0cccf359..f7df9218 100644 --- a/client/components/modals/playlists/UserPlaylistItem.vue +++ b/client/components/modals/playlists/UserPlaylistItem.vue @@ -1,5 +1,5 @@ @@ -24,7 +24,8 @@ export default { readonly: Boolean, disabled: Boolean, inputClass: String, - showCopy: Boolean + showCopy: Boolean, + trimWhitespace: Boolean }, data() { return {} diff --git a/client/components/widgets/BookDetailsEdit.vue b/client/components/widgets/BookDetailsEdit.vue index fa26bcf5..a7af02c8 100644 --- a/client/components/widgets/BookDetailsEdit.vue +++ b/client/components/widgets/BookDetailsEdit.vue @@ -3,10 +3,10 @@
- +
- +
@@ -42,19 +42,19 @@
- +
- +
- +
- +
diff --git a/client/components/widgets/PodcastDetailsEdit.vue b/client/components/widgets/PodcastDetailsEdit.vue index 389ca894..e665dc4e 100644 --- a/client/components/widgets/PodcastDetailsEdit.vue +++ b/client/components/widgets/PodcastDetailsEdit.vue @@ -3,14 +3,14 @@
- +
- +
- + @@ -25,13 +25,13 @@
- +
- +
- +
From b5e69630def903b7a00504f5eb54bd4fc01c0eea Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 6 Feb 2025 17:29:27 -0600 Subject: [PATCH 219/509] Update batch edit text inputs to trim whitespace --- client/pages/batch/index.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/pages/batch/index.vue b/client/pages/batch/index.vue index 263dee58..575f1db1 100644 --- a/client/pages/batch/index.vue +++ b/client/pages/batch/index.vue @@ -22,7 +22,7 @@
- +
@@ -31,7 +31,7 @@
- +
@@ -51,11 +51,11 @@
- +
- +
From a37fe3c3d2262963831afd8c2863018ae30ab571 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 7 Feb 2025 17:09:48 -0600 Subject: [PATCH 220/509] Fix: Users with update permission unable to remove books from collection #3947 --- server/controllers/CollectionController.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/controllers/CollectionController.js b/server/controllers/CollectionController.js index 00b82ce9..475adfe0 100644 --- a/server/controllers/CollectionController.js +++ b/server/controllers/CollectionController.js @@ -251,6 +251,7 @@ class CollectionController { /** * DELETE: /api/collections/:id/book/:bookId * Remove a single book from a collection. Re-order books + * Users with update permission can remove books from collections * TODO: bookId is actually libraryItemId. Clients need updating to use bookId * * @param {CollectionControllerRequest} req @@ -427,7 +428,8 @@ class CollectionController { req.collection = collection } - if (req.method == 'DELETE' && !req.user.canDelete) { + // Users with update permission can remove books from collections + if (req.method == 'DELETE' && !req.params.bookId && !req.user.canDelete) { Logger.warn(`[CollectionController] User "${req.user.username}" attempted to delete without permission`) return res.sendStatus(403) } else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) { From 52bb28669a62da31659b90bfd287f0bf06f629fd Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 8 Feb 2025 10:41:56 +0200 Subject: [PATCH 221/509] Add a profile utility function --- server/utils/profiler.js | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 server/utils/profiler.js diff --git a/server/utils/profiler.js b/server/utils/profiler.js new file mode 100644 index 00000000..614ce5b7 --- /dev/null +++ b/server/utils/profiler.js @@ -0,0 +1,41 @@ +const { performance, createHistogram } = require('perf_hooks') +const util = require('util') +const Logger = require('../Logger') + +const histograms = new Map() + +function profile(asyncFunc, isFindQuery = true, funcName = asyncFunc.name) { + if (!histograms.has(funcName)) { + const histogram = createHistogram() + histogram.values = [] + histograms.set(funcName, histogram) + } + const histogram = histograms.get(funcName) + + return async (...args) => { + if (isFindQuery) { + const findOptions = args[0] + Logger.info(`[${funcName}] findOptions:`, util.inspect(findOptions, { depth: null })) + findOptions.logging = (query, time) => Logger.info(`[${funcName}] ${query} Elapsed time: ${time}ms`) + findOptions.benchmark = true + } + const start = performance.now() + try { + const result = await asyncFunc(...args) + return result + } catch (error) { + Logger.error(`[${funcName}] failed`) + throw error + } finally { + const end = performance.now() + const duration = Math.round(end - start) + histogram.record(duration) + histogram.values.push(duration) + Logger.info(`[${funcName}] duration: ${duration}ms`) + Logger.info(`[${funcName}] histogram values:`, histogram.values) + Logger.info(`[${funcName}] histogram:`, histogram) + } + } +} + +module.exports = { profile } From a13143245b5bc1aafd8fac0c27b796be452da39f Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 8 Feb 2025 12:29:23 +0200 Subject: [PATCH 222/509] Improve page load queries on title, titleIgnorePrefix, and addedAt sort order --- .../v2.19.1-copy-title-to-library-items.js | 156 ++++++++++++++++++ server/models/Book.js | 9 + server/models/LibraryItem.js | 14 +- server/scanner/BookScanner.js | 2 + .../utils/queries/libraryItemsBookFilters.js | 43 ++++- ...2.19.1-copy-title-to-library-items.test.js | 148 +++++++++++++++++ 6 files changed, 363 insertions(+), 9 deletions(-) create mode 100644 server/migrations/v2.19.1-copy-title-to-library-items.js create mode 100644 test/server/migrations/v2.19.1-copy-title-to-library-items.test.js diff --git a/server/migrations/v2.19.1-copy-title-to-library-items.js b/server/migrations/v2.19.1-copy-title-to-library-items.js new file mode 100644 index 00000000..8f982333 --- /dev/null +++ b/server/migrations/v2.19.1-copy-title-to-library-items.js @@ -0,0 +1,156 @@ +const util = require('util') + +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.19.1' +const migrationName = `${migrationVersion}-copy-title-to-library-items` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration adds a title column to the libraryItems table, copies the title from the book to the libraryItem, + * and creates a new index on the title column. In addition it sets a trigger on the books table to update the title column + * in the libraryItems table when a book is updated. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + // Upwards migration script + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + await addColumn(queryInterface, logger, 'libraryItems', 'title', { type: queryInterface.sequelize.Sequelize.STRING, allowNull: true }) + await copyColumn(queryInterface, logger, 'books', 'title', 'id', 'libraryItems', 'title', 'mediaId') + await addTrigger(queryInterface, logger, 'books', 'title', 'id', 'libraryItems', 'title', 'mediaId') + await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', { name: 'title', collate: 'NOCASE' }]) + + await addColumn(queryInterface, logger, 'libraryItems', 'titleIgnorePrefix', { type: queryInterface.sequelize.Sequelize.STRING, allowNull: true }) + await copyColumn(queryInterface, logger, 'books', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId') + await addTrigger(queryInterface, logger, 'books', 'titleIgnorePrefix', 'id', 'libraryItems', 'titleIgnorePrefix', 'mediaId') + await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', { name: 'titleIgnorePrefix', collate: 'NOCASE' }]) + + await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'createdAt']) + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration script removes the title column from the libraryItems table, removes the trigger on the books table, + * and removes the index on the title column. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + // Downward migration script + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'title']) + await removeTrigger(queryInterface, logger, 'libraryItems', 'title') + await removeColumn(queryInterface, logger, 'libraryItems', 'title') + + await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'titleIgnorePrefix']) + await removeTrigger(queryInterface, logger, 'libraryItems', 'titleIgnorePrefix') + await removeColumn(queryInterface, logger, 'libraryItems', 'titleIgnorePrefix') + + await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'createdAt']) + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +/** + * Utility function to add an index to a table. If the index already z`exists, it logs a message and continues. + * + * @param {import('sequelize').QueryInterface} queryInterface + * @param {import ('../Logger')} logger + * @param {string} tableName + * @param {string[]} columns + */ +async function addIndex(queryInterface, logger, tableName, columns) { + const columnString = columns.map((column) => util.inspect(column)).join(', ') + const indexName = convertToSnakeCase(`${tableName}_${columns.map((column) => (typeof column === 'string' ? column : column.name)).join('_')}`) + try { + logger.info(`${loggerPrefix} adding index on [${columnString}] to table ${tableName}. index name: ${indexName}"`) + await queryInterface.addIndex(tableName, columns) + logger.info(`${loggerPrefix} added index on [${columnString}] to table ${tableName}. index name: ${indexName}"`) + } catch (error) { + if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) { + logger.info(`${loggerPrefix} index [${columnString}] for table "${tableName}" already exists`) + } else { + throw error + } + } +} + +/** + * Utility function to remove an index from a table. + * Sequelize implemets it using DROP INDEX IF EXISTS, so it won't throw an error if the index doesn't exist. + * + * @param {import('sequelize').QueryInterface} queryInterface + * @param {import ('../Logger')} logger + * @param {string} tableName + * @param {string[]} columns + */ +async function removeIndex(queryInterface, logger, tableName, columns) { + logger.info(`${loggerPrefix} removing index [${columns.join(', ')}] from table "${tableName}"`) + await queryInterface.removeIndex(tableName, columns) + logger.info(`${loggerPrefix} removed index [${columns.join(', ')}] from table "${tableName}"`) +} + +async function addColumn(queryInterface, logger, table, column, options) { + logger.info(`${loggerPrefix} adding column "${column}" to table "${table}"`) + await queryInterface.addColumn(table, column, options) + logger.info(`${loggerPrefix} added column "${column}" to table "${table}"`) +} + +async function removeColumn(queryInterface, logger, table, column) { + logger.info(`${loggerPrefix} removing column "${column}" from table "${table}"`) + await queryInterface.removeColumn(table, column) + logger.info(`${loggerPrefix} removed column "${column}" from table "${table}"`) +} + +async function copyColumn(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) { + logger.info(`${loggerPrefix} copying column "${sourceColumn}" from table "${sourceTable}" to table "${targetTable}"`) + await queryInterface.sequelize.query(` + UPDATE ${targetTable} + SET ${targetColumn} = ${sourceTable}.${sourceColumn} + FROM ${sourceTable} + WHERE ${targetTable}.${targetIdColumn} = ${sourceTable}.${sourceIdColumn} + `) + logger.info(`${loggerPrefix} copied column "${sourceColumn}" from table "${sourceTable}" to table "${targetTable}"`) +} + +async function addTrigger(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) { + logger.info(`${loggerPrefix} adding trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`) + const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}`) + await queryInterface.sequelize.query(` + CREATE TRIGGER ${triggerName} + AFTER UPDATE OF ${sourceColumn} ON ${sourceTable} + FOR EACH ROW + BEGIN + UPDATE ${targetTable} + SET ${targetColumn} = NEW.${sourceColumn} + WHERE ${targetTable}.${targetIdColumn} = NEW.${sourceIdColumn}; + END; + `) + logger.info(`${loggerPrefix} added trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`) +} + +async function removeTrigger(queryInterface, logger, targetTable, targetColumn) { + logger.info(`${loggerPrefix} removing trigger to update ${targetTable}.${targetColumn}`) + const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}`) + await queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`) + logger.info(`${loggerPrefix} removed trigger to update ${targetTable}.${targetColumn}`) +} + +function convertToSnakeCase(str) { + return str.replace(/([A-Z])/g, '_$1').toLowerCase() +} + +module.exports = { up, down } diff --git a/server/models/Book.js b/server/models/Book.js index 3684608d..fd24c269 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -3,6 +3,7 @@ const Logger = require('../Logger') const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils') const parseNameString = require('../utils/parsers/parseNameString') const htmlSanitizer = require('../utils/htmlSanitizer') +const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters') /** * @typedef EBookFileObject @@ -192,6 +193,14 @@ class Book extends Model { ] } ) + + Book.addHook('afterDestroy', async (instance) => { + libraryItemsBookFilters.clearCountCache('afterDestroy ') + }) + + Book.addHook('afterCreate', async (instance) => { + libraryItemsBookFilters.clearCountCache('afterCreate') + }) } /** diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 3ed4e31e..bd11e585 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -73,6 +73,10 @@ class LibraryItem extends Model { /** @type {Book.BookExpanded|Podcast.PodcastExpanded} - only set when expanded */ this.media + /** @type {string} */ + this.title // Only used for sorting + /** @type {string} */ + this.titleIgnorePrefix // Only used for sorting } /** @@ -677,7 +681,9 @@ class LibraryItem extends Model { lastScan: DataTypes.DATE, lastScanVersion: DataTypes.STRING, libraryFiles: DataTypes.JSON, - extraData: DataTypes.JSON + extraData: DataTypes.JSON, + title: DataTypes.STRING, + titleIgnorePrefix: DataTypes.STRING }, { sequelize, @@ -695,6 +701,12 @@ class LibraryItem extends Model { { fields: ['libraryId', 'mediaType', 'size'] }, + { + fields: ['libraryId', 'mediaType', { name: 'title', collate: 'NOCASE' }] + }, + { + fields: ['libraryId', 'mediaType', { name: 'titleIgnorePrefix', collate: 'NOCASE' }] + }, { fields: ['libraryId', 'mediaId', 'mediaType'] }, diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index 74798dd6..210f20f9 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -521,6 +521,8 @@ class BookScanner { libraryItemObj.isMissing = false libraryItemObj.isInvalid = false libraryItemObj.extraData = {} + libraryItemObj.title = bookMetadata.title + libraryItemObj.titleIgnorePrefix = getTitleIgnorePrefix(bookMetadata.title) // Set isSupplementary flag on ebook library files for (const libraryFile of libraryItemObj.libraryFiles) { diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 9e74276a..3adb929e 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -4,6 +4,9 @@ const Logger = require('../../Logger') const authorFilters = require('./authorFilters') const ShareManager = require('../../managers/ShareManager') +const { profile } = require('../profiler') + +const countCache = new Map() module.exports = { /** @@ -270,9 +273,9 @@ module.exports = { } if (global.ServerSettings.sortingIgnorePrefix) { - return [[Sequelize.literal('titleIgnorePrefix COLLATE NOCASE'), dir]] + return [[Sequelize.literal('`libraryItem`.`titleIgnorePrefix` COLLATE NOCASE'), dir]] } else { - return [[Sequelize.literal('`book`.`title` COLLATE NOCASE'), dir]] + return [[Sequelize.literal('`libraryItem`.`title` COLLATE NOCASE'), dir]] } } else if (sortBy === 'sequence') { const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST' @@ -336,6 +339,28 @@ module.exports = { return { booksToExclude, bookSeriesToInclude } }, + clearCountCache(hook) { + Logger.debug(`[LibraryItemsBookFilters] book.${hook}: Clearing count cache`) + countCache.clear() + }, + + async findAndCountAll(findOptions, limit, offset) { + const findOptionsKey = JSON.stringify(findOptions) + Logger.debug(`[LibraryItemsBookFilters] findOptionsKey: ${findOptionsKey}`) + + findOptions.limit = limit || null + findOptions.offset = offset + + if (countCache.has(findOptionsKey)) { + const rows = await Database.bookModel.findAll(findOptions) + return { rows, count: countCache.get(findOptionsKey) } + } else { + const result = await Database.bookModel.findAndCountAll(findOptions) + countCache.set(findOptionsKey, result.count) + return result + } + }, + /** * Get library items for book media type using filter and sort * @param {string} libraryId @@ -411,7 +436,8 @@ module.exports = { if (includeRSSFeed) { libraryItemIncludes.push({ model: Database.feedModel, - required: filterGroup === 'feed-open' + required: filterGroup === 'feed-open', + separate: true }) } if (filterGroup === 'feed-open' && !includeRSSFeed) { @@ -560,7 +586,7 @@ module.exports = { } } - const { rows: books, count } = await Database.bookModel.findAndCountAll({ + const findOptions = { where: bookWhere, distinct: true, attributes: bookAttributes, @@ -577,10 +603,11 @@ module.exports = { ...bookIncludes ], order: sortOrder, - subQuery: false, - limit: limit || null, - offset - }) + subQuery: false + } + + const findAndCountAll = process.env.QUERY_PROFILING ? profile(this.findAndCountAll) : this.findAndCountAll + const { rows: books, count } = await findAndCountAll(findOptions, limit, offset) const libraryItems = books.map((bookExpanded) => { const libraryItem = bookExpanded.libraryItem diff --git a/test/server/migrations/v2.19.1-copy-title-to-library-items.test.js b/test/server/migrations/v2.19.1-copy-title-to-library-items.test.js new file mode 100644 index 00000000..5b767856 --- /dev/null +++ b/test/server/migrations/v2.19.1-copy-title-to-library-items.test.js @@ -0,0 +1,148 @@ +const chai = require('chai') +const sinon = require('sinon') +const { expect } = chai + +const { DataTypes, Sequelize } = require('sequelize') +const Logger = require('../../../server/Logger') + +const { up, down } = require('../../../server/migrations/v2.19.1-copy-title-to-library-items') + +describe('Migration v2.19.1-copy-title-to-library-items', () => { + let sequelize + let queryInterface + let loggerInfoStub + + beforeEach(async () => { + sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false }) + queryInterface = sequelize.getQueryInterface() + loggerInfoStub = sinon.stub(Logger, 'info') + + await queryInterface.createTable('books', { + id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true }, + title: { type: DataTypes.STRING, allowNull: true }, + titleIgnorePrefix: { type: DataTypes.STRING, allowNull: true } + }) + + await queryInterface.createTable('libraryItems', { + id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, unique: true }, + libraryId: { type: DataTypes.INTEGER, allowNull: false }, + mediaType: { type: DataTypes.STRING, allowNull: false }, + mediaId: { type: DataTypes.INTEGER, allowNull: false }, + createdAt: { type: DataTypes.DATE, allowNull: false } + }) + + await queryInterface.bulkInsert('books', [ + { id: 1, title: 'The Book 1', titleIgnorePrefix: 'Book 1, The' }, + { id: 2, title: 'Book 2', titleIgnorePrefix: 'Book 2' } + ]) + + await queryInterface.bulkInsert('libraryItems', [ + { id: 1, libraryId: 1, mediaType: 'book', mediaId: 1, createdAt: '2025-01-01 00:00:00.000 +00:00' }, + { id: 2, libraryId: 2, mediaType: 'book', mediaId: 2, createdAt: '2025-01-02 00:00:00.000 +00:00' } + ]) + }) + + afterEach(() => { + sinon.restore() + }) + + describe('up', () => { + it('should copy title and titleIgnorePrefix to libraryItems', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems') + expect(libraryItems).to.deep.equal([ + { id: 1, libraryId: 1, mediaType: 'book', mediaId: 1, title: 'The Book 1', titleIgnorePrefix: 'Book 1, The', createdAt: '2025-01-01 00:00:00.000 +00:00' }, + { id: 2, libraryId: 2, mediaType: 'book', mediaId: 2, title: 'Book 2', titleIgnorePrefix: 'Book 2', createdAt: '2025-01-02 00:00:00.000 +00:00' } + ]) + }) + + it('should add index on title to libraryItems', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_title_ignore_prefix'`) + expect(count).to.equal(1) + }) + + it('should add trigger to books.title to update libraryItems.title', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title'`) + expect(count).to.equal(1) + }) + + it('should add index on titleIgnorePrefix to libraryItems', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_title_ignore_prefix'`) + expect(count).to.equal(1) + }) + + it('should add trigger to books.titleIgnorePrefix to update libraryItems.titleIgnorePrefix', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix'`) + expect(count).to.equal(1) + }) + + it('should add index on createdAt to libraryItems', async () => { + await up({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_created_at'`) + expect(count).to.equal(1) + }) + }) + + describe('down', () => { + it('should remove title and titleIgnorePrefix from libraryItems', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const [libraryItems] = await queryInterface.sequelize.query('SELECT * FROM libraryItems') + expect(libraryItems).to.deep.equal([ + { id: 1, libraryId: 1, mediaType: 'book', mediaId: 1, createdAt: '2025-01-01 00:00:00.000 +00:00' }, + { id: 2, libraryId: 2, mediaType: 'book', mediaId: 2, createdAt: '2025-01-02 00:00:00.000 +00:00' } + ]) + }) + + it('should remove title trigger from books', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title'`) + expect(count).to.equal(0) + }) + + it('should remove titleIgnorePrefix trigger from books', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='trigger' AND name='update_library_items_title_ignore_prefix'`) + expect(count).to.equal(0) + }) + + it('should remove index on titleIgnorePrefix from libraryItems', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_title_ignore_prefix'`) + expect(count).to.equal(0) + }) + + it('should remove index on title from libraryItems', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_title'`) + expect(count).to.equal(0) + }) + + it('should remove index on createdAt from libraryItems', async () => { + await up({ context: { queryInterface, logger: Logger } }) + await down({ context: { queryInterface, logger: Logger } }) + + const [[{ count }]] = await queryInterface.sequelize.query(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='index' AND name='library_items_library_id_media_type_created_at'`) + expect(count).to.equal(0) + }) + }) +}) From 3d08a35aa00f9282706e343795a042a2177ef69e Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 8 Feb 2025 14:53:01 +0200 Subject: [PATCH 223/509] Add index on (libraryId, mediaType, createdAt) --- server/models/LibraryItem.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index bd11e585..ace6af43 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -701,6 +701,9 @@ class LibraryItem extends Model { { fields: ['libraryId', 'mediaType', 'size'] }, + { + fields: ['libraryId', 'mediaType', 'createdAt'] + }, { fields: ['libraryId', 'mediaType', { name: 'title', collate: 'NOCASE' }] }, From 9a261195b74ded2649cb7f394169b1219ff20ed3 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 8 Feb 2025 10:19:13 -0600 Subject: [PATCH 224/509] Update server/models/Book.js --- server/models/Book.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/models/Book.js b/server/models/Book.js index fd24c269..1f4193a2 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -195,7 +195,7 @@ class Book extends Model { ) Book.addHook('afterDestroy', async (instance) => { - libraryItemsBookFilters.clearCountCache('afterDestroy ') + libraryItemsBookFilters.clearCountCache('afterDestroy') }) Book.addHook('afterCreate', async (instance) => { From ef45f844e52c44da36147987f2ddf76c558202f1 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 8 Feb 2025 12:37:34 -0600 Subject: [PATCH 225/509] Update upwards migration to be idempotent --- .../v2.19.1-copy-title-to-library-items.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/server/migrations/v2.19.1-copy-title-to-library-items.js b/server/migrations/v2.19.1-copy-title-to-library-items.js index 8f982333..7b75fa85 100644 --- a/server/migrations/v2.19.1-copy-title-to-library-items.js +++ b/server/migrations/v2.19.1-copy-title-to-library-items.js @@ -105,8 +105,13 @@ async function removeIndex(queryInterface, logger, tableName, columns) { async function addColumn(queryInterface, logger, table, column, options) { logger.info(`${loggerPrefix} adding column "${column}" to table "${table}"`) - await queryInterface.addColumn(table, column, options) - logger.info(`${loggerPrefix} added column "${column}" to table "${table}"`) + const tableDescription = await queryInterface.describeTable(table) + if (!tableDescription[column]) { + await queryInterface.addColumn(table, column, options) + logger.info(`${loggerPrefix} added column "${column}" to table "${table}"`) + } else { + logger.info(`${loggerPrefix} column "${column}" already exists in table "${table}"`) + } } async function removeColumn(queryInterface, logger, table, column) { @@ -129,6 +134,9 @@ async function copyColumn(queryInterface, logger, sourceTable, sourceColumn, sou async function addTrigger(queryInterface, logger, sourceTable, sourceColumn, sourceIdColumn, targetTable, targetColumn, targetIdColumn) { logger.info(`${loggerPrefix} adding trigger to update ${targetTable}.${targetColumn} when ${sourceTable}.${sourceColumn} is updated`) const triggerName = convertToSnakeCase(`update_${targetTable}_${targetColumn}`) + + await queryInterface.sequelize.query(`DROP TRIGGER IF EXISTS ${triggerName}`) + await queryInterface.sequelize.query(` CREATE TRIGGER ${triggerName} AFTER UPDATE OF ${sourceColumn} ON ${sourceTable} From 0dd57a8912143c00fc1943f007a3c654466e6487 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 8 Feb 2025 13:02:27 -0600 Subject: [PATCH 226/509] Fix using next/prev in edit modals while rich text input is focused #3951 --- client/components/modals/item/EditModal.vue | 8 +++++++- client/components/modals/podcast/EditEpisode.vue | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/client/components/modals/item/EditModal.vue b/client/components/modals/item/EditModal.vue index c41e6b54..ca6094d8 100644 --- a/client/components/modals/item/EditModal.vue +++ b/client/components/modals/item/EditModal.vue @@ -196,6 +196,9 @@ export default { methods: { async goPrevBook() { if (this.currentBookshelfIndex - 1 < 0) return + // Remove focus from active input + document.activeElement?.blur?.() + var prevBookId = this.bookshelfBookIds[this.currentBookshelfIndex - 1] this.processing = true var prevBook = await this.$axios.$get(`/api/items/${prevBookId}?expanded=1`).catch((error) => { @@ -215,6 +218,9 @@ export default { }, async goNextBook() { if (this.currentBookshelfIndex >= this.bookshelfBookIds.length - 1) return + // Remove focus from active input + document.activeElement?.blur?.() + this.processing = true var nextBookId = this.bookshelfBookIds[this.currentBookshelfIndex + 1] var nextBook = await this.$axios.$get(`/api/items/${nextBookId}?expanded=1`).catch((error) => { @@ -300,4 +306,4 @@ export default { .tab.tab-selected { height: 41px; } - \ No newline at end of file + diff --git a/client/components/modals/podcast/EditEpisode.vue b/client/components/modals/podcast/EditEpisode.vue index 9702ce38..9572a36a 100644 --- a/client/components/modals/podcast/EditEpisode.vue +++ b/client/components/modals/podcast/EditEpisode.vue @@ -117,8 +117,12 @@ export default { methods: { async goPrevEpisode() { if (this.currentEpisodeIndex - 1 < 0) return + // Remove focus from active input + document.activeElement?.blur?.() + const prevEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex - 1] this.processing = true + const prevEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${prevEpisodeId}`).catch((error) => { const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch episode' this.$toast.error(errorMsg) @@ -134,8 +138,12 @@ export default { }, async goNextEpisode() { if (this.currentEpisodeIndex >= this.episodeTableEpisodeIds.length - 1) return + // Remove focus from active input + document.activeElement?.blur?.() + this.processing = true const nextEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex + 1] + const nextEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${nextEpisodeId}`).catch((error) => { const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book' this.$toast.error(errorMsg) From 36ef675556461d9cd1bdb70e43be7289afded7fa Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 8 Feb 2025 13:05:50 -0600 Subject: [PATCH 227/509] Fix edit episode next/prev buttons showing when editing from home page --- client/components/app/BookShelfRow.vue | 1 + client/components/widgets/ItemSlider.vue | 1 + 2 files changed, 2 insertions(+) diff --git a/client/components/app/BookShelfRow.vue b/client/components/app/BookShelfRow.vue index 6e19b9dc..97d64d86 100644 --- a/client/components/app/BookShelfRow.vue +++ b/client/components/app/BookShelfRow.vue @@ -99,6 +99,7 @@ export default { this.$store.commit('showEditModal', libraryItem) }, editEpisode({ libraryItem, episode }) { + this.$store.commit('setEpisodeTableEpisodeIds', [episode.id]) this.$store.commit('setSelectedLibraryItem', libraryItem) this.$store.commit('globals/setSelectedEpisode', episode) this.$store.commit('globals/setShowEditPodcastEpisodeModal', true) diff --git a/client/components/widgets/ItemSlider.vue b/client/components/widgets/ItemSlider.vue index 92639cc6..92e9fcee 100644 --- a/client/components/widgets/ItemSlider.vue +++ b/client/components/widgets/ItemSlider.vue @@ -124,6 +124,7 @@ export default { this.updateSelectionMode(false) }, editEpisode({ libraryItem, episode }) { + this.$store.commit('setEpisodeTableEpisodeIds', [episode.id]) this.$store.commit('setSelectedLibraryItem', libraryItem) this.$store.commit('globals/setSelectedEpisode', episode) this.$store.commit('globals/setShowEditPodcastEpisodeModal', true) From 4cc300d6e9d91b10bd47dd45223b06f6a06f9ffe Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 9 Feb 2025 21:39:43 +0200 Subject: [PATCH 228/509] Update changelog with v2.19.1 migration --- server/migrations/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index 3b5a5626..acccef90 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -13,3 +13,4 @@ Please add a record of every database migration that you create to this file. Th | v2.17.5 | v2.17.5-remove-host-from-feed-urls | removes the host (serverAddress) from URL columns in the feeds and feedEpisodes tables | | v2.17.6 | v2.17.6-share-add-isdownloadable | Adds the isDownloadable column to the mediaItemShares table | | v2.17.7 | v2.17.7-add-indices | Adds indices to the libraryItems and books tables to reduce query times | +| v2.19.1 | v2.19.1-copy-title-to-library-items | Copies title and titleIgnorePrefix to the libraryItems table, creates update triggers and indices | From 0ccb88904a0c97c41f0031c545482f13de5c654e Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 9 Feb 2025 17:40:29 -0600 Subject: [PATCH 229/509] fix v2.15.0 migration test --- test/server/migrations/v2.15.0-series-column-unique.test.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/server/migrations/v2.15.0-series-column-unique.test.js b/test/server/migrations/v2.15.0-series-column-unique.test.js index a9ad0fab..34b5e52e 100644 --- a/test/server/migrations/v2.15.0-series-column-unique.test.js +++ b/test/server/migrations/v2.15.0-series-column-unique.test.js @@ -129,9 +129,9 @@ describe('migration-v2.15.0-series-column-unique', () => { { id: series1Id, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, { id: series2Id, name: 'Series 2', libraryId: library2Id, createdAt: new Date(), updatedAt: new Date() }, { id: series3Id, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, - { id: series1Id_dup, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, - { id: series3Id_dup, name: 'Series 3', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() }, - { id: series1Id_dup2, name: 'Series 1', libraryId: library1Id, createdAt: new Date(), updatedAt: new Date() } + { id: series1Id_dup, name: 'Series 1', libraryId: library1Id, createdAt: new Date(0), updatedAt: new Date(0) }, + { id: series3Id_dup, name: 'Series 3', libraryId: library1Id, createdAt: new Date(0), updatedAt: new Date(0) }, + { id: series1Id_dup2, name: 'Series 1', libraryId: library1Id, createdAt: new Date(0), updatedAt: new Date(0) } ]) // Add some entries to the BookSeries table await queryInterface.bulkInsert('BookSeries', [ From 14e92435ec95eaab2546666db41b59e70e548d53 Mon Sep 17 00:00:00 2001 From: Paul Date: Mon, 10 Feb 2025 11:41:26 +0100 Subject: [PATCH 230/509] Fix `ROUTER_BASE_PATH` override for empty string When the `ROUTER_BASE_PATH` env variable is set to an empty string it's mistakenly overriden to `/audiobookshelf` instead. The `/audiobookshelf` fallback should only be used when the `ROUTER_BASE_PATH` env variable is undefined, not just an empty string. Regression introduced in https://github.com/advplyr/audiobookshelf/pull/3810 See also: https://github.com/advplyr/audiobookshelf/pull/3810#discussion_r1948790937 Partially address https://github.com/advplyr/audiobookshelf/issues/3874 --- client/nuxt.config.js | 2 +- index.js | 4 ++-- prod.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/client/nuxt.config.js b/client/nuxt.config.js index bb595477..4df6246f 100644 --- a/client/nuxt.config.js +++ b/client/nuxt.config.js @@ -1,6 +1,6 @@ const pkg = require('./package.json') -const routerBasePath = process.env.ROUTER_BASE_PATH || '/audiobookshelf' +const routerBasePath = process.env.ROUTER_BASE_PATH ?? '/audiobookshelf' const serverHostUrl = process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3333' const serverPaths = ['api/', 'public/', 'hls/', 'auth/', 'feed/', 'status', 'login', 'logout', 'init'] const proxy = Object.fromEntries(serverPaths.map((path) => [`${routerBasePath}/${path}`, { target: process.env.NODE_ENV !== 'production' ? serverHostUrl : '/' }])) diff --git a/index.js b/index.js index 35dcc588..fea9a590 100644 --- a/index.js +++ b/index.js @@ -29,7 +29,7 @@ if (isDev) { if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1' if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath process.env.SOURCE = 'local' - process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath || '' + process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '' } const inputConfig = options.config ? Path.resolve(options.config) : null @@ -41,7 +41,7 @@ const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('conf const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata') const SOURCE = options.source || process.env.SOURCE || 'debian' -const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || '/audiobookshelf' +const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH ?? '/audiobookshelf' console.log(`Running in ${process.env.NODE_ENV} mode.`) console.log(`Options: CONFIG_PATH=${CONFIG_PATH}, METADATA_PATH=${METADATA_PATH}, PORT=${PORT}, HOST=${HOST}, SOURCE=${SOURCE}, ROUTER_BASE_PATH=${ROUTER_BASE_PATH}`) diff --git a/prod.js b/prod.js index 24a0c3f7..b0f53719 100644 --- a/prod.js +++ b/prod.js @@ -25,7 +25,7 @@ const CONFIG_PATH = inputConfig || process.env.CONFIG_PATH || Path.resolve('conf const METADATA_PATH = inputMetadata || process.env.METADATA_PATH || Path.resolve('metadata') const SOURCE = options.source || process.env.SOURCE || 'debian' -const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH || '/audiobookshelf' +const ROUTER_BASE_PATH = process.env.ROUTER_BASE_PATH ?? '/audiobookshelf' console.log(process.env.NODE_ENV, 'Config', CONFIG_PATH, METADATA_PATH) From 4e8cd6fba0bc7ae5c41b8a6153fda500fc879985 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 10 Feb 2025 17:58:18 -0600 Subject: [PATCH 231/509] Update index.js dev fallback router base path --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index fea9a590..2839c238 100644 --- a/index.js +++ b/index.js @@ -29,7 +29,7 @@ if (isDev) { if (devEnv.AllowIframe) process.env.ALLOW_IFRAME = '1' if (devEnv.BackupPath) process.env.BACKUP_PATH = devEnv.BackupPath process.env.SOURCE = 'local' - process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '' + process.env.ROUTER_BASE_PATH = devEnv.RouterBasePath ?? '/audiobookshelf' } const inputConfig = options.config ? Path.resolve(options.config) : null From ec6537656925a43871b07cfee12c9f383844d224 Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 11 Feb 2025 22:02:51 +0200 Subject: [PATCH 232/509] Security fix for GHSA-pg8v-5jcv-wrvw --- server/Auth.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 74b767f5..8ece90ae 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -10,6 +10,7 @@ const ExtractJwt = require('passport-jwt').ExtractJwt const OpenIDClient = require('openid-client') const Database = require('./Database') const Logger = require('./Logger') +const { escapeRegExp } = require('./utils') /** * @class Class for handling all the authentication related functionality. @@ -18,7 +19,11 @@ class Auth { constructor() { // Map of openId sessions indexed by oauth2 state-variable this.openIdAuthSession = new Map() - this.ignorePatterns = [/\/api\/items\/[^/]+\/cover/, /\/api\/authors\/[^/]+\/image/] + const escapedRouterBasePath = escapeRegExp(global.RouterBasePath) + this.ignorePatterns = [ + new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), + new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`) + ] } /** @@ -28,7 +33,7 @@ class Auth { * @private */ authNotNeeded(req) { - return req.method === 'GET' && this.ignorePatterns.some((pattern) => pattern.test(req.originalUrl)) + return req.method === 'GET' && this.ignorePatterns.some((pattern) => pattern.test(req.path)) } ifAuthNeeded(middleware) { From ed3af5bdcd7b9ef274d212794358b9042896fcbe Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 11 Feb 2025 16:14:49 -0600 Subject: [PATCH 233/509] Fix server crash when feed cover image is requested but doesnt exist --- server/managers/RssFeedManager.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index de009c3d..c4681bdc 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -246,6 +246,15 @@ class RssFeedManager { const extname = Path.extname(feed.coverPath).toLowerCase().slice(1) res.type(`image/${extname}`) const readStream = fs.createReadStream(feed.coverPath) + + readStream.on('error', (error) => { + Logger.error(`[RssFeedManager] Error streaming cover image: ${error.message}`) + // Only send error if headers haven't been sent yet + if (!res.headersSent) { + res.sendStatus(404) + } + }) + readStream.pipe(res) } From 39567c6c224fb1e5b9d0111461bb0cc78001f888 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 11 Feb 2025 16:47:34 -0600 Subject: [PATCH 234/509] Update view feed modal to sort episodes by pub date ascending --- client/pages/config/rss-feeds.vue | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/client/pages/config/rss-feeds.vue b/client/pages/config/rss-feeds.vue index 9b070f91..c3f60859 100644 --- a/client/pages/config/rss-feeds.vue +++ b/client/pages/config/rss-feeds.vue @@ -137,7 +137,16 @@ export default { this.$toast.error(this.$strings.ToastFailedToLoadData) return } - this.feeds = data.feeds + this.feeds = data.feeds.map((feed) => ({ + ...feed, + episodes: [...feed.episodes].sort((a, b) => { + if (!a.pubDate) return 1 // null dates sort to end + if (!b.pubDate) return -1 + const dateA = new Date(a.pubDate) + const dateB = new Date(b.pubDate) + return dateA - dateB + }) + })) }, init() { this.loadFeeds() From 70621e72e867a52643d59a1448875cb8743bb848 Mon Sep 17 00:00:00 2001 From: biuklija Date: Mon, 3 Feb 2025 20:51:20 +0000 Subject: [PATCH 235/509] Translated using Weblate (Croatian) Currently translated at 100.0% (1089 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/hr.json b/client/strings/hr.json index eda11cfa..9ac33476 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -641,10 +641,10 @@ "LabelTimeDurationXMinutes": "{0} minuta", "LabelTimeDurationXSeconds": "{0} sekundi", "LabelTimeInMinutes": "Vrijeme u minutama", - "LabelTimeLeft": "{0} preostalo", + "LabelTimeLeft": "preostalo {0}", "LabelTimeListened": "Vremena odslušano", "LabelTimeListenedToday": "Vremena odslušano danas", - "LabelTimeRemaining": "{0} preostalo", + "LabelTimeRemaining": "preostalo {0}", "LabelTimeToShift": "Vrijeme za pomjeriti u sekundama", "LabelTitle": "Naslov", "LabelToolsEmbedMetadata": "Ugradi meta-podatke", From 1fbd09044135b1ae290c37ee71ac82fa040a996a Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Mon, 3 Feb 2025 12:06:24 +0000 Subject: [PATCH 236/509] Translated using Weblate (Swedish) Currently translated at 85.8% (935 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index 6180652f..39ff8a31 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -10,6 +10,8 @@ "ButtonApplyChapters": "Tillämpa kapitel", "ButtonAuthors": "Författare", "ButtonBack": "Tillbaka", + "ButtonBatchEditPopulateFromExisting": "Hämta befintlig information", + "ButtonBatchEditPopulateMapDetails": "Addera befintliga information", "ButtonBrowseForFolder": "Bläddra efter mapp", "ButtonCancel": "Avbryt", "ButtonCancelEncode": "Avbryt omkodning", @@ -97,7 +99,7 @@ "ButtonSubmit": "Spara", "ButtonTest": "Testa", "ButtonUpload": "Ladda upp", - "ButtonUploadBackup": "Ladda upp säkerhetskopia", + "ButtonUploadBackup": "Läs in säkerhetskopia", "ButtonUploadCover": "Ladda upp bokomslag", "ButtonUploadOPMLFile": "Ladda upp OPML-fil", "ButtonUserDelete": "Radera användare {0}", @@ -147,7 +149,7 @@ "HeaderLogs": "Loggar", "HeaderManageGenres": "Hantera kategorier", "HeaderManageTags": "Hantera taggar", - "HeaderMapDetails": "Karta detaljer", + "HeaderMapDetails": "Gemensam information för samtliga objekt", "HeaderMatch": "Matcha", "HeaderMetadataOrderOfPrecedence": "Prioriteringsordning vid inläsning av metadata", "HeaderMetadataToEmbed": "Metadata som kommer att adderas", @@ -235,7 +237,7 @@ "LabelBackupLocation": "Plats för säkerhetskopia", "LabelBackupsEnableAutomaticBackups": "Aktivera automatisk säkerhetskopiering", "LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i \"/metadata/backups\"", - "LabelBackupsMaxBackupSize": "Maximal storlek på säkerhetskopia (i GB) (0 = obegränsad)", + "LabelBackupsMaxBackupSize": "Maximal storlek på säkerhetskopia i GigaByte (0 = obegränsad)", "LabelBackupsMaxBackupSizeHelp": "Som ett skydd mot en felaktig konfiguration kommer säkerhetskopior inte att genomföras om de överskrider den konfigurerade storleken.", "LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla", "LabelBackupsNumberToKeepHelp": "Endast en gammal säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än det angivna beloppet bör du ta bort dem manuellt.", @@ -404,7 +406,7 @@ "LabelNewPassword": "Nytt lösenord", "LabelNewestAuthors": "Senaste författarna", "LabelNewestEpisodes": "Senast tillagda avsnitt", - "LabelNextBackupDate": "Nästa datum för säkerhetskopiering", + "LabelNextBackupDate": "Nästa tillfälle för säkerhetskopiering", "LabelNextScheduledRun": "Nästa schemalagda körning", "LabelNoCustomMetadataProviders": "Ingen egen källa för metadata", "LabelNoEpisodesSelected": "Inga avsnitt valda", @@ -464,8 +466,8 @@ "LabelRead": "Läst", "LabelReadAgain": "Läs igen", "LabelReadEbookWithoutProgress": "Läs e-bok utan att behålla framsteg", - "LabelRecentSeries": "Nyaste serierna", - "LabelRecentlyAdded": "Nyligen tillagda", + "LabelRecentSeries": "Senaste serierna", + "LabelRecentlyAdded": "Nyligen adderade", "LabelRecommended": "Rekommenderad", "LabelRegion": "Region", "LabelReleaseDate": "Utgivningsdatum", @@ -641,6 +643,8 @@ "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", + "MessageBatchEditPopulateMapDetailsAllHelp": "Adderar information från alla objekt nedan i de fält som aktiverats. Om fälten innehåller olika uppgifter kommer informationen att slås samman.", + "MessageBatchEditPopulateMapDetailsItemHelp": "Addera information från detta objekt i aktiva fält ovan", "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", @@ -856,6 +860,7 @@ "ToastDeviceTestEmailSuccess": "Ett testmail har skickats", "ToastEmailSettingsUpdateSuccess": "Inställningarna av e-post har uppdaterats", "ToastEncodeCancelSucces": "Omkodningen avbruten", + "ToastEpisodeUpdateSuccess": "{0} avsnitt uppdaterades", "ToastFailedToLoadData": "Misslyckades med att ladda data", "ToastFailedToUpdate": "Misslyckades med att uppdatera", "ToastInvalidImageUrl": "Felaktig URL-adress till omslagsbilden", @@ -869,6 +874,7 @@ "ToastItemMarkedAsFinishedSuccess": "Den har markerat som avslutad", "ToastItemMarkedAsNotFinishedFailed": "Misslyckades med att markera den som ej avslutad", "ToastItemMarkedAsNotFinishedSuccess": "Den har markerats som ej avslutad", + "ToastItemUpdateSuccess": "Objektet har uppdaterats", "ToastLibraryCreateFailed": "Det gick inte att skapa biblioteket", "ToastLibraryCreateSuccess": "Biblioteket \"{0}\" har skapats", "ToastLibraryDeleteFailed": "Det gick inte att ta bort biblioteket", @@ -893,7 +899,7 @@ "ToastPlaylistCreateFailed": "Det gick inte att skapa spellistan", "ToastPlaylistCreateSuccess": "Spellistan skapad", "ToastPlaylistRemoveSuccess": "Spellistan har tagits bort", - "ToastPlaylistUpdateSuccess": "Spellistan uppdaterad", + "ToastPlaylistUpdateSuccess": "Spellistan har uppdaterats", "ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten", "ToastPodcastCreateSuccess": "Podcasten skapad framgångsrikt", "ToastProviderCreatedFailed": "Misslyckades med att addera en källa", From bf795d36621d430fe78988bb4bc089dbb30822b4 Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Tue, 4 Feb 2025 18:31:20 +0000 Subject: [PATCH 237/509] Translated using Weblate (Swedish) Currently translated at 85.9% (936 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index 39ff8a31..e7b32e38 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -720,7 +720,7 @@ "MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som ej avslutade", "MessageMarkAsFinished": "Markera som 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.", + "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 har hittats", "MessageNoAuthors": "Inga författare", "MessageNoBackups": "Inga säkerhetskopior", @@ -779,6 +779,7 @@ "MessageTaskFailedToCreateCacheDirectory": "Misslyckades med att skapa bibliotek för cachen", "MessageTaskMatchingBooksInLibrary": "Matchar böcker i biblioteket \"{0}\"", "MessageTaskScanItemsAdded": "{0} adderades", + "MessageTaskScanItemsMissing": "{0} saknades", "MessageTaskScanItemsUpdated": "{0} uppdaterades", "MessageTaskScanNoChangesNeeded": "Inget adderades eller uppdaterades", "MessageTaskScanningLibrary": "Biblioteket \"{0}\" har skannats", From 78d8c83e6db3601033d6c47db662831267d5afa4 Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Wed, 5 Feb 2025 10:53:25 +0000 Subject: [PATCH 238/509] Translated using Weblate (Swedish) Currently translated at 85.9% (936 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index e7b32e38..29f26445 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -136,7 +136,7 @@ "HeaderFiles": "Filer", "HeaderFindChapters": "Hitta kapitel", "HeaderIgnoredFiles": "Ignorerade filer", - "HeaderItemFiles": "Föremålsfiler", + "HeaderItemFiles": "Filer", "HeaderItemMetadataUtils": "Metadataverktyg för föremål", "HeaderLastListeningSession": "Senaste lyssningstillfället", "HeaderLatestEpisodes": "Senaste avsnitten", @@ -319,8 +319,8 @@ "LabelFeedURL": "Flödes-URL", "LabelFetchingMetadata": "Hämtar metadata", "LabelFile": "Fil", - "LabelFileBirthtime": "Tidpunkt, filen skapades", - "LabelFileModified": "Tidpunkt, filen ändrades", + "LabelFileBirthtime": "Tidpunkt, fil skapad", + "LabelFileModified": "Tidpunkt, fil ändrad", "LabelFileModifiedDate": "Ändrad {0}", "LabelFilename": "Filnamn", "LabelFilterByUser": "Välj användare", @@ -870,7 +870,7 @@ "ToastItemCoverUpdateSuccess": "Objektets bokomslag har uppdaterats", "ToastItemDeletedFailed": "Misslyckades med att radera objektet", "ToastItemDeletedSuccess": "Objektet har raderats", - "ToastItemDetailsUpdateSuccess": "Detaljerna om boken har uppdaterats", + "ToastItemDetailsUpdateSuccess": "Informationen 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", From 74b35ea9d1c6febe49f9089771d975fc0dc0c908 Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Thu, 6 Feb 2025 10:11:19 +0000 Subject: [PATCH 239/509] Translated using Weblate (Swedish) Currently translated at 88.7% (966 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 84 ++++++++++++++++++++++++++++-------------- 1 file changed, 57 insertions(+), 27 deletions(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index 29f26445..d616efe7 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -16,7 +16,7 @@ "ButtonCancel": "Avbryt", "ButtonCancelEncode": "Avbryt omkodning", "ButtonChangeRootPassword": "Ändra lösenordet för root", - "ButtonCheckAndDownloadNewEpisodes": "Kontrollera och ladda ner nya avsnitt", + "ButtonCheckAndDownloadNewEpisodes": "Sök & Ladda ner nya avsnitt", "ButtonChooseAFolder": "Välj en mapp", "ButtonChooseFiles": "Välj filer", "ButtonClearFilter": "Rensa filter", @@ -32,7 +32,7 @@ "ButtonEditChapters": "Redigera kapitel", "ButtonEditPodcast": "Redigera podcast", "ButtonEnable": "Aktivera", - "ButtonForceReScan": "Tvinga omstart", + "ButtonForceReScan": "Starta ny skanning", "ButtonFullPath": "Fullständig sökväg", "ButtonHide": "Dölj", "ButtonHome": "Hem", @@ -66,8 +66,8 @@ "ButtonPurgeItemsCache": "Rensa cache för föremål", "ButtonQueueAddItem": "Lägg till i kön", "ButtonQueueRemoveItem": "Ta bort från kön", - "ButtonQuickMatch": "Snabb matchning", - "ButtonReScan": "Omstart", + "ButtonQuickMatch": "Snabbmatchning", + "ButtonReScan": "Ny skanning", "ButtonRead": "Läs", "ButtonReadLess": "Visa mindre", "ButtonReadMore": "Visa mer", @@ -137,7 +137,7 @@ "HeaderFindChapters": "Hitta kapitel", "HeaderIgnoredFiles": "Ignorerade filer", "HeaderItemFiles": "Filer", - "HeaderItemMetadataUtils": "Metadataverktyg för föremål", + "HeaderItemMetadataUtils": "Metadataverktyg", "HeaderLastListeningSession": "Senaste lyssningstillfället", "HeaderLatestEpisodes": "Senaste avsnitten", "HeaderLibraries": "Bibliotek", @@ -170,11 +170,11 @@ "HeaderRSSFeedGeneral": "RSS-information", "HeaderRSSFeedIsOpen": "RSS-flödet är öppet", "HeaderRSSFeeds": "RSS-flöden", - "HeaderRemoveEpisode": "Ta bort avsnitt", - "HeaderRemoveEpisodes": "Ta bort {0} avsnitt", + "HeaderRemoveEpisode": "Radera avsnitt", + "HeaderRemoveEpisodes": "Radera {0} avsnitt", "HeaderSavedMediaProgress": "Sparad historik", "HeaderSchedule": "Schema", - "HeaderScheduleEpisodeDownloads": "Schemalägg automatiska avsnittsnedladdningar", + "HeaderScheduleEpisodeDownloads": "Schemalägg automatiska nedladdning av avsnitt", "HeaderScheduleLibraryScans": "Schema för skanning av biblioteket", "HeaderSession": "Tillfälle", "HeaderSetBackupSchedule": "Ange schemaläggning för säkerhetskopia", @@ -200,7 +200,7 @@ "HeaderUsers": "Användare", "HeaderYearReview": "Sammanställning av {0}", "HeaderYourStats": "Din statistik", - "LabelAbridged": "Förkortad", + "LabelAbridged": "Förkortad version", "LabelAccessibleBy": "Tillgänglig för", "LabelAccountType": "Kontotyp", "LabelAccountTypeAdmin": "Administratör", @@ -240,7 +240,7 @@ "LabelBackupsMaxBackupSize": "Maximal storlek på säkerhetskopia i GigaByte (0 = obegränsad)", "LabelBackupsMaxBackupSizeHelp": "Som ett skydd mot en felaktig konfiguration kommer säkerhetskopior inte att genomföras om de överskrider den konfigurerade storleken.", "LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla", - "LabelBackupsNumberToKeepHelp": "Endast en gammal säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än det angivna beloppet bör du ta bort dem manuellt.", + "LabelBackupsNumberToKeepHelp": "Endast en gammal säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än det angivna värdet bör du ta bort dem manuellt.", "LabelBitrate": "Bitfrekvens", "LabelBonus": "Bonus", "LabelBooks": "Böcker", @@ -312,11 +312,16 @@ "LabelEnd": "Slut", "LabelEndOfChapter": "Slut av kapitel", "LabelEpisode": "Avsnitt", - "LabelEpisodeTitle": "Avsnittsrubrik", - "LabelEpisodeType": "Avsnittstyp", + "LabelEpisodeNumber": "Avsnitt #{0}", + "LabelEpisodeTitle": "Titel på avsnittet", + "LabelEpisodeType": "Typ av avsnitt", + "LabelEpisodes": "Avsnitt", "LabelExample": "Exempel", "LabelExpandSeries": "Expandera serier", - "LabelFeedURL": "Flödes-URL", + "LabelExplicit": "Explicit version", + "LabelExplicitChecked": "Explicit version (markerad)", + "LabelExplicitUnchecked": "Ej Explicit version (ej markerad)", + "LabelFeedURL": "URL-adress för flödet", "LabelFetchingMetadata": "Hämtar metadata", "LabelFile": "Fil", "LabelFileBirthtime": "Tidpunkt, fil skapad", @@ -324,14 +329,14 @@ "LabelFileModifiedDate": "Ändrad {0}", "LabelFilename": "Filnamn", "LabelFilterByUser": "Välj användare", - "LabelFindEpisodes": "Hitta avsnitt", + "LabelFindEpisodes": "Sök avsnitt", "LabelFinished": "Avslutad", "LabelFolder": "Mapp", "LabelFolders": "Mappar", "LabelFontBold": "Fetstil", "LabelFontBoldness": "Fetstil", "LabelFontFamily": "Typsnittsfamilj", - "LabelFontItalic": "Kursiverad", + "LabelFontItalic": "Kursiv", "LabelFontScale": "Skala på typsnitt", "LabelFontStrikethrough": "Genomstruken", "LabelGenre": "Kategori", @@ -386,6 +391,9 @@ "LabelLogLevelWarn": "Varningar", "LabelLookForNewEpisodesAfterDate": "Sök efter nya avsnitt efter detta datum", "LabelLowestPriority": "Lägst prioritet", + "LabelMaxEpisodesToDownload": "Maximalt antal avsnitt att ladda ner (0 = obegränsat).", + "LabelMaxEpisodesToDownloadPerCheck": "Maximalt antal nya avsnitt att ladda ner per tillfälle", + "LabelMaxEpisodesToKeep": "Maximalt antal avsnitt att behålla", "LabelMediaPlayer": "Mediaspelare", "LabelMediaType": "Mediatyp", "LabelMetaTag": "Metadata", @@ -431,7 +439,7 @@ "LabelPath": "Sökväg", "LabelPermissionsAccessAllLibraries": "Kan komma åt alla bibliotek", "LabelPermissionsAccessAllTags": "Kan komma åt alla taggar", - "LabelPermissionsAccessExplicitContent": "Kan komma åt explicit innehåll", + "LabelPermissionsAccessExplicitContent": "Kan komma åt explicit version", "LabelPermissionsCreateEreader": "Kan addera e-läsarenhet", "LabelPermissionsDelete": "Kan radera", "LabelPermissionsDownload": "Kan ladda ner", @@ -444,7 +452,7 @@ "LabelPlaylists": "Spellistor", "LabelPodcast": "Podcast", "LabelPodcastSearchRegion": "Podcast-sökområde", - "LabelPodcastType": "Podcasttyp", + "LabelPodcastType": "Typ av postcast", "LabelPodcasts": "Podcasts", "LabelPort": "Port", "LabelPrefixesToIgnore": "Prefix att ignorera (skiftlägesokänsligt)", @@ -469,6 +477,7 @@ "LabelRecentSeries": "Senaste serierna", "LabelRecentlyAdded": "Nyligen adderade", "LabelRecommended": "Rekommenderad", + "LabelRedo": "Gör om", "LabelRegion": "Region", "LabelReleaseDate": "Utgivningsdatum", "LabelRemoveAllMetadataAbs": "Radera alla 'metadata.abs' filer", @@ -502,16 +511,16 @@ "LabelSettingsDateFormat": "Datumformat", "LabelSettingsDisableWatcher": "Inaktivera Watcher", "LabelSettingsDisableWatcherForLibrary": "Inaktivera bevakning med Watcher för biblioteket", - "LabelSettingsDisableWatcherHelp": "Inaktiverar automatik att addera/uppdatera objekt
när ändringar av filer genomförs.
OBS: Kräver en omstart av servern", + "LabelSettingsDisableWatcherHelp": "Inaktiverar automatik att addera/uppdatera
objekt när ändringar av filer genomförs.
OBS: Kräver en omstart av servern", "LabelSettingsEnableWatcher": "Aktivera Watcher", "LabelSettingsEnableWatcherForLibrary": "Aktivera bevakning med Watcher för biblioteket", - "LabelSettingsEnableWatcherHelp": "Aktiverar automatik att addera/uppdatera objekt
när ändringar av filer genomförs.
OBS: Kräver en omstart av servern", + "LabelSettingsEnableWatcherHelp": "Aktiverar automatik att addera/uppdatera
objekt när ändringar av filer genomförs.
OBS: Kräver en omstart av servern", "LabelSettingsEpubsAllowScriptedContent": "Tillåt e-böcker i epubs-format som innehåller script", "LabelSettingsEpubsAllowScriptedContentHelp": "Tillåt att epub-filer får använda script.
Det rekommenderas att denna inställning är
avstängd när du inte litar på källan för epub-filerna.", "LabelSettingsExperimentalFeatures": "Experimentella funktioner", "LabelSettingsExperimentalFeaturesHelp": "Funktioner under utveckling som behöver din feedback och hjälp med testning. Klicka för att öppna diskussionen på GitHub.", "LabelSettingsFindCovers": "Hitta ett bokomslag", - "LabelSettingsFindCoversHelp": "Om din bok inte har ett bokomslag inbäddat i filen eller en fil med bokomslaget i mappen kommer skannern att försöka hitta ett omslag. OBS: Detta kommer att förlänga inläsningstiden", + "LabelSettingsFindCoversHelp": "Om din bok INTE har ett bokomslag inbäddat i filen eller en fil med bokomslaget i mappen kommer skannern att försöka hitta ett omslag.
OBS: Detta kommer att förlänga inläsningstiden", "LabelSettingsHideSingleBookSeries": "Dölj serier som endast innehåller en bok", "LabelSettingsHideSingleBookSeriesHelp": "Serier som endast har en bok kommer att
döljas från sidan 'Serier' och hyllorna på startsidan.", "LabelSettingsHomePageBookshelfView": "Använd vy liknande en bokhylla på startsidan", @@ -522,9 +531,9 @@ "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Hoppa över tidigare böcker i en serie", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Sektionen 'Fortsätt med serien' på startsidan visar \"nästa bok\" i serien,
där åtminstone en bok avslutats, och ingen bok i serien har påbörjats.
Om detta alternativ aktiveras kommer efterföljande bok till den
avslutade att föreslås - istället för den första ej avslutade boken i serien.", "LabelSettingsParseSubtitles": "Hämta undertitel från bokens mapp", - "LabelSettingsParseSubtitlesHelp": "Hämtar undertiteln från namnet på mappen där boken lagras.
Undertiteln måste vara åtskilda med ett bindestreck ' - '.
En mapp med namnet 'Boktitel - Bokens undertitel'
får undertiteln \"Bokens undertitel\"", + "LabelSettingsParseSubtitlesHelp": "Hämtar undertiteln från namnet
på mappen där boken lagras.
Undertiteln måste vara åtskilda med ett bindestreck ' - '.
En mapp med namnet 'Boktitel - Bokens undertitel'
får undertiteln \"Bokens undertitel\"", "LabelSettingsPreferMatchedMetadata": "Prioritera matchad metadata", - "LabelSettingsPreferMatchedMetadataHelp": "Matchad data kommer att åsidosätta objektdetaljer vid snabbmatchning. Som standard kommer snabbmatchning endast att fylla i saknade detaljer.", + "LabelSettingsPreferMatchedMetadataHelp": "Matchad data kommer att ersätta befintliga uppgifter vid en snabbmatchning. Som standard kommer en snabbmatchning endast att fylla i saknade detaljer.", "LabelSettingsSkipMatchingBooksWithASIN": "Hoppa över matchande böcker som har en ASIN-kod", "LabelSettingsSkipMatchingBooksWithISBN": "Hoppa över matchande böcker som har en ISBN-kod", "LabelSettingsSortingIgnorePrefixes": "Ignorera prefix vid sortering", @@ -571,6 +580,7 @@ "LabelTagsNotAccessibleToUser": "Taggar inte tillgängliga för användaren", "LabelTasks": "Pågående aktivitet", "LabelTextEditorBulletedList": "Punktlista", + "LabelTextEditorLink": "Länk", "LabelTextEditorNumberedList": "Numrerad lista", "LabelTheme": "Utseende", "LabelThemeDark": "Mörkt", @@ -639,7 +649,7 @@ "LabelYourProgress": "Framsteg", "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.", + "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", @@ -647,6 +657,7 @@ "MessageBatchEditPopulateMapDetailsItemHelp": "Addera information från detta objekt i aktiva fält ovan", "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", + "MessageBookshelfNoCollectionsHelp": "Samlingar är privata. Endast den användare som skapat en samling kan se den.", "MessageBookshelfNoRSSFeeds": "Inga RSS-flöden är öppna", "MessageBookshelfNoResultsForFilter": "Inga resultat för filter \"{0}: {1}\"", "MessageBookshelfNoResultsForQuery": "Sökningen gav inget resultat", @@ -678,7 +689,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 en ny genomsökning för {0} objekt?", + "MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra en ny skanning 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}\"?", @@ -701,7 +712,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.", + "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.", @@ -751,6 +762,7 @@ "MessageNoTasksRunning": "Inga pågående uppgifter", "MessageNoUpdatesWereNecessary": "Inga uppdateringar var nödvändiga", "MessageNoUserPlaylists": "Du har inga spellistor", + "MessageNoUserPlaylistsHelp": "Spellistor är privata. Endast den användare som skapat listan kan se den.", "MessageNotYetImplemented": "Ännu inte implementerad", "MessageOr": "eller", "MessagePauseChapter": "Pausa kapiteluppspelning", @@ -774,9 +786,15 @@ "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", + "MessageTaskEmbeddingMetadata": "Infogar metadata", + "MessageTaskEmbeddingMetadataDescription": "Infogar metadata i ljudboken \"{0}\"", "MessageTaskEncodingM4bDescription": "Omkodning av ljudbok \"{0}\" till en M4B-fil", "MessageTaskFailed": "Misslyckades", "MessageTaskFailedToCreateCacheDirectory": "Misslyckades med att skapa bibliotek för cachen", + "MessageTaskFailedToEmbedMetadataInFile": "Misslyckades med att infoga metadata i \"{0}\"", + "MessageTaskFailedToMergeAudioFiles": "Misslyckades med att sammanfoga ljudfilerna", + "MessageTaskFailedToMoveM4bFile": "Misslyckades med att flytta M4B-filen", + "MessageTaskFailedToWriteMetadataFile": "Misslyckades med att skapa filen med metadata", "MessageTaskMatchingBooksInLibrary": "Matchar böcker i biblioteket \"{0}\"", "MessageTaskScanItemsAdded": "{0} adderades", "MessageTaskScanItemsMissing": "{0} saknades", @@ -870,7 +888,7 @@ "ToastItemCoverUpdateSuccess": "Objektets bokomslag har uppdaterats", "ToastItemDeletedFailed": "Misslyckades med att radera objektet", "ToastItemDeletedSuccess": "Objektet har raderats", - "ToastItemDetailsUpdateSuccess": "Informationen om boken har uppdaterats", + "ToastItemDetailsUpdateSuccess": "Informationen om objektet 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", @@ -883,26 +901,31 @@ "ToastLibraryScanFailedToStart": "Misslyckades med att starta skanningen", "ToastLibraryScanStarted": "Skanning av biblioteket påbörjad", "ToastLibraryUpdateSuccess": "Biblioteket \"{0}\" har uppdaterats", + "ToastMatchAllAuthorsFailed": "Misslyckades med att matcha alla författare", "ToastMetadataFilesRemovedError": "Misslyckades med att radera 'metadata.{0}' filerna", "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", + "ToastNewEpisodesFound": "Hittade {0} nya avsnitt", "ToastNewUserCreatedFailed": "Misslyckades med att skapa kontot \"{0}\"", "ToastNewUserCreatedSuccess": "Ett nytt konto har skapats", "ToastNewUserLibraryError": "Minst ett bibliotek måste anges", "ToastNewUserPasswordError": "Ett lösenord måste anges. Endast användaren 'root' kan vara utan lösenord.", + "ToastNewUserTagError": "Minst en tagg måste läggas till", "ToastNewUserUsernameError": "Ange ett användarnamn", + "ToastNoNewEpisodesFound": "Inga nya avsnitt kunde hittas", "ToastNoUpdatesNecessary": "Inga uppdateringar var nödvändiga", "ToastNotificationCreateFailed": "Misslyckades med att skapa meddelandet", "ToastNotificationDeleteFailed": "Misslyckades med att radera meddelandet", + "ToastNotificationUpdateSuccess": "Meddelandet har uppdaterats", "ToastPlaylistCreateFailed": "Det gick inte att skapa spellistan", "ToastPlaylistCreateSuccess": "Spellistan skapad", "ToastPlaylistRemoveSuccess": "Spellistan har tagits bort", "ToastPlaylistUpdateSuccess": "Spellistan har uppdaterats", "ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten", - "ToastPodcastCreateSuccess": "Podcasten skapad framgångsrikt", + "ToastPodcastCreateSuccess": "Podcasten skapades framgångsrikt", "ToastProviderCreatedFailed": "Misslyckades med att addera en källa", "ToastProviderCreatedSuccess": "En ny källa har adderats", "ToastProviderNameAndUrlRequired": "Ett namn och en URL-adress krävs", @@ -912,7 +935,14 @@ "ToastRemoveFailed": "Misslyckades med att radera", "ToastRemoveItemFromCollectionFailed": "Misslyckades med att ta bort objektet från samlingen", "ToastRemoveItemFromCollectionSuccess": "Objektet borttaget från samlingen", + "ToastRemoveItemsWithIssuesFailed": "Misslyckades med att radera objekt med problem", + "ToastRemoveItemsWithIssuesSuccess": "Raderade objekt med problem", "ToastRenameFailed": "Misslyckades med att ändra namn", + "ToastRescanFailed": "Skanningen misslyckades för {0}", + "ToastRescanRemoved": "Skanningen har genomförts - objektet har raderats", + "ToastRescanUpToDate": "Skanningen har genomförts - objektet behövde inte uppdateras", + "ToastRescanUpdated": "Skanningen har genomförts - objektet har uppdaterats", + "ToastScanFailed": "Misslyckades med att skanna biblioteket", "ToastSelectAtLeastOneUser": "Åtminstone en användare måste väljas", "ToastSendEbookToDeviceFailed": "Misslyckades med att skicka e-boken till enheten", "ToastSendEbookToDeviceSuccess": "E-boken skickad till enheten \"{0}\"", From a14c6a3a8b1bc298d78b302b5b84947224638a22 Mon Sep 17 00:00:00 2001 From: Pepijn Date: Thu, 6 Feb 2025 20:53:14 +0000 Subject: [PATCH 240/509] Translated using Weblate (Dutch) Currently translated at 99.8% (1087 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/ --- client/strings/nl.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/strings/nl.json b/client/strings/nl.json index b2965bb3..9ab6977c 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -484,6 +484,7 @@ "LabelPersonalYearReview": "Jouw jaar in review ({0})", "LabelPhotoPathURL": "Foto pad/URL", "LabelPlayMethod": "Afspeelwijze", + "LabelPlaybackRateIncrementDecrement": "Afspeel Snelheid Vermeerderen/Verminderen", "LabelPlayerChapterNumberMarker": "{0} van {1}", "LabelPlaylists": "Afspeellijsten", "LabelPodcast": "Podcast", @@ -704,8 +705,11 @@ "MessageBackupsLocationEditNote": "Let op: het bijwerken van de back-uplocatie zal bestaande back-ups niet verplaatsen of wijzigen", "MessageBackupsLocationNoEditNote": "Let op: De back-uplocatie wordt ingesteld via een omgevingsvariabele en kan hier niet worden gewijzigd.", "MessageBackupsLocationPathEmpty": "Backup locatie pad kan niet leeg zijn", + "MessageBatchEditPopulateMapDetailsAllHelp": "Vul actieve velden in met data van alle items. Velden met meerdere waarden zullen worden samengevoegd", + "MessageBatchEditPopulateMapDetailsItemHelp": "Vul actieve folder detail velden met de data van dit item", "MessageBatchQuickMatchDescription": "Quick Match zal proberen ontbrekende covers en metadata voor de geselecteerde onderdelen te matchten. Schakel de opties hieronder in om Quick Match toe te staan bestaande covers en/of metadata te overschrijven.", "MessageBookshelfNoCollections": "Je hebt nog geen collecties gemaakt", + "MessageBookshelfNoCollectionsHelp": "Collecties zijn publiekelijk. Alle gebruikers met toegang tot de bibliotheek kunnen ze zien.", "MessageBookshelfNoRSSFeeds": "Geen RSS-feeds geopend", "MessageBookshelfNoResultsForFilter": "Geen resultaten voor filter \"{0}: {1}\"", "MessageBookshelfNoResultsForQuery": "Geen resultaten voor query", @@ -816,6 +820,7 @@ "MessageNoTasksRunning": "Geen lopende taken", "MessageNoUpdatesWereNecessary": "Geen bijwerkingen waren noodzakelijk", "MessageNoUserPlaylists": "Je hebt geen afspeellijsten", + "MessageNoUserPlaylistsHelp": "Afspeellijsten zijn privaat. Alleen de gebruikers die ze hebben gemaakt kunnen ze zien.", "MessageNotYetImplemented": "Nog niet geimplementeerd", "MessageOpmlPreviewNote": "Let op: Dit is een preview van het geparseerde OPML-bestand. De werkelijke podcasttitel wordt overgenomen uit de RSS-feed.", "MessageOr": "of", From ac8324e595d28af60f9b75e30acca449075d66f8 Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Thu, 6 Feb 2025 10:49:59 +0000 Subject: [PATCH 241/509] Translated using Weblate (Swedish) Currently translated at 90.1% (982 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 74 +++++++++++++++++++++++++----------------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index d616efe7..1bc566a5 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -75,8 +75,8 @@ "ButtonRemove": "Ta bort", "ButtonRemoveAll": "Ta bort alla", "ButtonRemoveAllLibraryItems": "Ta bort alla objekt i biblioteket", - "ButtonRemoveFromContinueListening": "Radera från 'Fortsätt läsa/lyssna'", - "ButtonRemoveFromContinueReading": "Ta bort från Fortsätt läsa", + "ButtonRemoveFromContinueListening": "Radera från 'Fortsätt lyssna'", + "ButtonRemoveFromContinueReading": "Radera från 'Fortsätt läsa'", "ButtonRemoveSeriesFromContinueSeries": "Radera från 'Fortsätt med serien'", "ButtonReset": "Tillbaka", "ButtonResetToDefault": "Återställ till standard", @@ -100,7 +100,7 @@ "ButtonTest": "Testa", "ButtonUpload": "Ladda upp", "ButtonUploadBackup": "Läs in säkerhetskopia", - "ButtonUploadCover": "Ladda upp bokomslag", + "ButtonUploadCover": "Ladda upp omslag", "ButtonUploadOPMLFile": "Ladda upp OPML-fil", "ButtonUserDelete": "Radera användare {0}", "ButtonUserEdit": "Redigera användare {0}", @@ -122,7 +122,7 @@ "HeaderChooseAFolder": "Välj en mapp", "HeaderCollection": "Samling", "HeaderCollectionItems": "Böcker i samlingen", - "HeaderCover": "Bokomslag", + "HeaderCover": "Omslag", "HeaderCurrentDownloads": "Aktuella nedladdningar", "HeaderCustomMetadataProviders": "Egen källa för metadata", "HeaderDetails": "Detaljer", @@ -166,7 +166,7 @@ "HeaderPlaylist": "Spellista", "HeaderPlaylistItems": "Böcker i spellistan", "HeaderPodcastsToAdd": "Podcaster att lägga till", - "HeaderPreviewCover": "Förhandsgranska bokomslag", + "HeaderPreviewCover": "Förhandsgranska omslag", "HeaderRSSFeedGeneral": "RSS-information", "HeaderRSSFeedIsOpen": "RSS-flödet är öppet", "HeaderRSSFeeds": "RSS-flöden", @@ -264,7 +264,7 @@ "LabelContinueListening": "Fortsätt att lyssna", "LabelContinueReading": "Fortsätt att läsa", "LabelContinueSeries": "Fortsätt med serien", - "LabelCover": "Bokomslag", + "LabelCover": "Omslag", "LabelCoverImageURL": "URL till omslagsbild", "LabelCreatedAt": "Skapad", "LabelCronExpression": "Schemaläggning med hjälp av Cron (Cron Expression)", @@ -299,7 +299,7 @@ "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": "E-postadress för test", - "LabelEmbeddedCover": "Inbäddat bokomslag", + "LabelEmbeddedCover": "Infogat omslag", "LabelEnable": "Aktivera", "LabelEncodingBackupLocation": "En säkerhetskopia av dina orginalljudfiler kommer att placeras i katalogen:", "LabelEncodingClearItemCache": "Kom ihåg att regelbundet radera cachen för föremål. Du hittar funktionen längst ner på sidan 'Inställningar'.", @@ -316,11 +316,13 @@ "LabelEpisodeTitle": "Titel på avsnittet", "LabelEpisodeType": "Typ av avsnitt", "LabelEpisodes": "Avsnitt", + "LabelEpisodic": "Uppdelad i avsnitt", "LabelExample": "Exempel", "LabelExpandSeries": "Expandera serier", "LabelExplicit": "Explicit version", "LabelExplicitChecked": "Explicit version (markerad)", "LabelExplicitUnchecked": "Ej Explicit version (ej markerad)", + "LabelExportOPML": "Exportera OPML-information", "LabelFeedURL": "URL-adress för flödet", "LabelFetchingMetadata": "Hämtar metadata", "LabelFile": "Fil", @@ -370,7 +372,7 @@ "LabelLanguage": "Språk", "LabelLanguageDefaultServer": "Standardspråk för server", "LabelLanguages": "Språk", - "LabelLastBookAdded": "Bok senast tillagd", + "LabelLastBookAdded": "Bok senast adderad", "LabelLastBookUpdated": "Bok senast uppdaterad", "LabelLastSeen": "Senast inloggad", "LabelLastTime": "Senaste tillfället", @@ -385,7 +387,7 @@ "LabelLibraryName": "Biblioteksnamn", "LabelLimit": "Begränsning", "LabelLineSpacing": "Radavstånd", - "LabelListenAgain": "Läs/Lyssna igen", + "LabelListenAgain": "Lyssna igen", "LabelLogLevelDebug": "Felsökning", "LabelLogLevelInfo": "Information", "LabelLogLevelWarn": "Varningar", @@ -394,6 +396,7 @@ "LabelMaxEpisodesToDownload": "Maximalt antal avsnitt att ladda ner (0 = obegränsat).", "LabelMaxEpisodesToDownloadPerCheck": "Maximalt antal nya avsnitt att ladda ner per tillfälle", "LabelMaxEpisodesToKeep": "Maximalt antal avsnitt att behålla", + "LabelMaxEpisodesToKeepHelp": "'0' innebär obegränsat antal avsnitt. Efter att nya avsnitt laddats ner raderas det äldsta avsnittet om du har mer än maximalt antal avsnitt. Endast ett avsnitt kommer att raderas per tillfälle.", "LabelMediaPlayer": "Mediaspelare", "LabelMediaType": "Mediatyp", "LabelMetaTag": "Metadata", @@ -413,11 +416,11 @@ "LabelNew": "Nytt", "LabelNewPassword": "Nytt lösenord", "LabelNewestAuthors": "Senaste författarna", - "LabelNewestEpisodes": "Senast tillagda avsnitt", + "LabelNewestEpisodes": "Senast adderade avsnitt", "LabelNextBackupDate": "Nästa tillfälle för säkerhetskopiering", "LabelNextScheduledRun": "Nästa schemalagda körning", "LabelNoCustomMetadataProviders": "Ingen egen källa för metadata", - "LabelNoEpisodesSelected": "Inga avsnitt valda", + "LabelNoEpisodesSelected": "Inga avsnitt har valts", "LabelNotFinished": "Ej avslutad", "LabelNotStarted": "Ej påbörjad", "LabelNotes": "Anteckningar", @@ -482,7 +485,7 @@ "LabelReleaseDate": "Utgivningsdatum", "LabelRemoveAllMetadataAbs": "Radera alla 'metadata.abs' filer", "LabelRemoveAllMetadataJson": "Radera alla 'metadata.json' filer", - "LabelRemoveCover": "Ta bort bokomslag", + "LabelRemoveCover": "Ta bort omslag", "LabelRemoveMetadataFile": "Radera metadata-filer i alla mappar i biblioteket", "LabelRemoveMetadataFileHelp": "Radera alla 'metadata.json' och 'metadata.abs' filer i dina {0} mappar.", "LabelRowsPerPage": "Antal rader per sida", @@ -490,6 +493,7 @@ "LabelSearchTitle": "Titel", "LabelSearchTitleOrASIN": "Sök titel eller ASIN-kod", "LabelSeason": "Säsong", + "LabelSeasonNumber": "Säsong #{0}", "LabelSelectAll": "Välj alla", "LabelSelectAllEpisodes": "Välj alla avsnitt", "LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas", @@ -519,8 +523,8 @@ "LabelSettingsEpubsAllowScriptedContentHelp": "Tillåt att epub-filer får använda script.
Det rekommenderas att denna inställning är
avstängd när du inte litar på källan för epub-filerna.", "LabelSettingsExperimentalFeatures": "Experimentella funktioner", "LabelSettingsExperimentalFeaturesHelp": "Funktioner under utveckling som behöver din feedback och hjälp med testning. Klicka för att öppna diskussionen på GitHub.", - "LabelSettingsFindCovers": "Hitta ett bokomslag", - "LabelSettingsFindCoversHelp": "Om din bok INTE har ett bokomslag inbäddat i filen eller en fil med bokomslaget i mappen kommer skannern att försöka hitta ett omslag.
OBS: Detta kommer att förlänga inläsningstiden", + "LabelSettingsFindCovers": "Hitta ett omslag", + "LabelSettingsFindCoversHelp": "Om din bok INTE har ett omslag inbäddat i filen eller en fil med omslaget i mappen kommer skannern att försöka hitta ett omslag.
OBS: Detta kommer att förlänga inläsningstiden", "LabelSettingsHideSingleBookSeries": "Dölj serier som endast innehåller en bok", "LabelSettingsHideSingleBookSeriesHelp": "Serier som endast har en bok kommer att
döljas från sidan 'Serier' och hyllorna på startsidan.", "LabelSettingsHomePageBookshelfView": "Använd vy liknande en bokhylla på startsidan", @@ -538,10 +542,10 @@ "LabelSettingsSkipMatchingBooksWithISBN": "Hoppa över matchande böcker som har en ISBN-kod", "LabelSettingsSortingIgnorePrefixes": "Ignorera prefix vid sortering", "LabelSettingsSortingIgnorePrefixesHelp": "För prefix som t.ex. \"the\" kommer boktiteln \"The Book Title\" att sorteras som \"Book Title, The\"", - "LabelSettingsSquareBookCovers": "Använd kvadratiska bokomslag", - "LabelSettingsSquareBookCoversHelp": "Föredrar att använda kvadratiska bokomslag
före standardformatet 1.6:1", - "LabelSettingsStoreCoversWithItem": "Lagra bokomslag med objektet", - "LabelSettingsStoreCoversWithItemHelp": "Som standard lagras bokomslag i mappen '/metadata/items'.
Genom att aktivera detta alternativ kommer
omslagen att lagra i din biblioteksmapp.
Endast en fil med namnet 'cover' kommer att behållas", + "LabelSettingsSquareBookCovers": "Använd kvadratiska omslag", + "LabelSettingsSquareBookCoversHelp": "Föredrar att använda kvadratiska omslag
före standardformatet 1.6:1", + "LabelSettingsStoreCoversWithItem": "Lagra omslag med objektet", + "LabelSettingsStoreCoversWithItemHelp": "Som standard lagras omslag i mappen '/metadata/items'.
Genom att aktivera detta alternativ kommer
omslagen att lagra i din biblioteksmapp.
Endast en fil med namnet 'cover' kommer att behållas", "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", @@ -616,8 +620,8 @@ "LabelUndo": "Ångra", "LabelUnknown": "Okänd", "LabelUnknownPublishDate": "Okänt publiceringsdatum", - "LabelUpdateCover": "Uppdatera bokomslag", - "LabelUpdateCoverHelp": "Tillåt att befintliga bokomslag för de valda böckerna ersätts när en matchning hittas", + "LabelUpdateCover": "Uppdatera omslag", + "LabelUpdateCoverHelp": "Tillåt att befintliga omslag för de valda böckerna ersätts när en matchning hittas", "LabelUpdateDetails": "Uppdatera detaljer", "LabelUpdateDetailsHelp": "Tillåt att befintliga detaljer för de valda böckerna ersätts när en matchning hittas", "LabelUpdatedAt": "Uppdaterades", @@ -693,8 +697,8 @@ "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}\"?", - "MessageConfirmRemoveEpisode": "Är du säker på att du vill ta bort avsnittet \"{0}\"?", - "MessageConfirmRemoveEpisodes": "Är du säker på att du vill ta bort {0} avsnitt?", + "MessageConfirmRemoveEpisode": "Är du säker på att du vill radera avsnittet \"{0}\"?", + "MessageConfirmRemoveEpisodes": "Är du säker på att du vill radera {0} avsnitt?", "MessageConfirmRemoveListeningSessions": "Är du säker på att du vill radera {0} lyssningstillfällen?", "MessageConfirmRemoveMetadataFiles": "Är du säker på att du vill radera filerna 'metadata.{0}' i alla mappar i ditt bibliotek?", "MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort uppläsaren \"{0}\"?", @@ -731,19 +735,19 @@ "MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som ej avslutade", "MessageMarkAsFinished": "Markera som 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.", + "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 omslag.
Inga befintliga uppgifter kommer att ersättas.", "MessageNoAudioTracks": "Inga ljudspår har hittats", "MessageNoAuthors": "Inga författare", "MessageNoBackups": "Inga säkerhetskopior", "MessageNoBookmarks": "Inga bokmärken", "MessageNoChapters": "Inga kapitel", "MessageNoCollections": "Inga samlingar", - "MessageNoCoversFound": "Inga bokomslag hittades", + "MessageNoCoversFound": "Inga omslag 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", + "MessageNoEpisodeMatchesFound": "Inga matchande avsnitt kunde hittas", "MessageNoEpisodes": "Inga avsnitt", "MessageNoFoldersAvailable": "Inga mappar tillgängliga", "MessageNoGenres": "Inga kategorier", @@ -770,9 +774,11 @@ "MessagePlaylistCreateFromCollection": "Skapa en spellista från samlingen", "MessagePleaseWait": "Vänta ett ögonblick...", "MessagePodcastHasNoRSSFeedForMatching": "Podcasten har ingen RSS-flödes-URL att använda för matchning", + "MessagePodcastSearchField": "Skriv sökfrågan eller URL-adressen för RSS-flödet", + "MessageQuickMatchAllEpisodes": "Snabbmatchning av alla avsnitt", "MessageQuickMatchDescription": "Adderar uppgifter som saknas samt en omslagsbild från
första träffen i resultatet vid sökningen från '{0}'.
Skriver inte över befintliga uppgifter om inte
inställningen 'Prioritera matchad metadata' är aktiverad.", "MessageRemoveChapter": "Ta bort kapitel", - "MessageRemoveEpisodes": "Ta bort {0} avsnitt", + "MessageRemoveEpisodes": "Radera {0} avsnitt", "MessageRemoveFromPlayerQueue": "Ta bort från spellistan", "MessageRemoveUserWarning": "Är du säker på att du vill radera användaren \"{0}\"?", "MessageReportBugsAndContribute": "Rapportera buggar, begär funktioner och bidra på", @@ -786,6 +792,7 @@ "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", + "MessageTaskDownloadingEpisodeDescription": "Laddar ner avsnitt \"{0}\"", "MessageTaskEmbeddingMetadata": "Infogar metadata", "MessageTaskEmbeddingMetadataDescription": "Infogar metadata i ljudboken \"{0}\"", "MessageTaskEncodingM4bDescription": "Omkodning av ljudbok \"{0}\" till en M4B-fil", @@ -796,6 +803,10 @@ "MessageTaskFailedToMoveM4bFile": "Misslyckades med att flytta M4B-filen", "MessageTaskFailedToWriteMetadataFile": "Misslyckades med att skapa filen med metadata", "MessageTaskMatchingBooksInLibrary": "Matchar böcker i biblioteket \"{0}\"", + "MessageTaskOpmlImportFinished": "Adderade {0} podcasts", + "MessageTaskOpmlParseFailed": "Misslyckades att tolka OPML-filen", + "MessageTaskOpmlParseFastFail": "Felaktig OPML-fil. Ingen tag eller tag finns i filen", + "MessageTaskOpmlParseNoneFound": "Inget flöde finns angivet i OPML-filen", "MessageTaskScanItemsAdded": "{0} adderades", "MessageTaskScanItemsMissing": "{0} saknades", "MessageTaskScanItemsUpdated": "{0} uppdaterades", @@ -814,7 +825,7 @@ "NoteChapterEditorTimes": "OBS: Starttiden för första kapitlet måste vara 0:00 och starttiden för det sista kapitlet får inte överstiga ljudbokens totala varaktighet.", "NoteFolderPicker": "OBS: Mappar som redan är kopplade kommer inte att visas", "NoteRSSFeedPodcastAppsHttps": "VARNING: De flesta applikationer för podcasts kräver att URL:en för RSS-flödet använder HTTPS", - "NoteRSSFeedPodcastAppsPubDate": "VARNING: Ett eller flera av dina avsnitt har inte ett publiceringsdatum. Vissa applikationer för podcasts kräver detta.", + "NoteRSSFeedPodcastAppsPubDate": "VARNING: Ett eller flera av dina avsnitt saknar publiceringsdatum. Vissa applikationer för podcasts kräver detta.", "NoteUploaderFoldersWithMediaFiles": "Mappar som innehåller mediefiler hanteras som separata objekt i biblioteket.", "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.", @@ -868,10 +879,12 @@ "ToastCachePurgeSuccess": "Rensning av cachen har genomförts", "ToastChaptersHaveErrors": "Kapitlen har fel", "ToastChaptersMustHaveTitles": "Kapitel måste ha titlar", + "ToastChaptersRemoved": "Kapitlen har raderats", + "ToastChaptersUpdated": "Kapitlen har uppdaterats", "ToastCollectionItemsAddFailed": "Misslyckades med att addera böcker till samlingen", "ToastCollectionRemoveSuccess": "Samlingen har raderats", "ToastCollectionUpdateSuccess": "Samlingen har uppdaterats", - "ToastCoverUpdateFailed": "Uppdatering av bokomslag misslyckades", + "ToastCoverUpdateFailed": "Uppdatering av omslag misslyckades", "ToastDateTimeInvalidOrIncomplete": "Datum och klockslag är felaktigt eller ej komplett", "ToastDeleteFileFailed": "Misslyckades att radera filen", "ToastDeleteFileSuccess": "Filen har raderats", @@ -879,13 +892,15 @@ "ToastDeviceTestEmailSuccess": "Ett testmail har skickats", "ToastEmailSettingsUpdateSuccess": "Inställningarna av e-post har uppdaterats", "ToastEncodeCancelSucces": "Omkodningen avbruten", + "ToastEpisodeDownloadQueueClearFailed": "Misslyckades med att tömma kön", + "ToastEpisodeDownloadQueueClearSuccess": "Kö för nedladdning av avsnitt har tömts", "ToastEpisodeUpdateSuccess": "{0} avsnitt uppdaterades", "ToastFailedToLoadData": "Misslyckades med att ladda data", "ToastFailedToUpdate": "Misslyckades med att uppdatera", "ToastInvalidImageUrl": "Felaktig URL-adress till omslagsbilden", "ToastInvalidMaxEpisodesToDownload": "Ogiltigt maximalt antal avsnitt att ladda ner", "ToastInvalidUrl": "Felaktig URL-adress", - "ToastItemCoverUpdateSuccess": "Objektets bokomslag har uppdaterats", + "ToastItemCoverUpdateSuccess": "Objektets omslag har uppdaterats", "ToastItemDeletedFailed": "Misslyckades med att radera objektet", "ToastItemDeletedSuccess": "Objektet har raderats", "ToastItemDetailsUpdateSuccess": "Informationen om objektet har uppdaterats", @@ -926,6 +941,7 @@ "ToastPlaylistUpdateSuccess": "Spellistan har uppdaterats", "ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten", "ToastPodcastCreateSuccess": "Podcasten skapades framgångsrikt", + "ToastPodcastNoEpisodesInFeed": "Inga avsnitt finns i RSS-flödet", "ToastProviderCreatedFailed": "Misslyckades med att addera en källa", "ToastProviderCreatedSuccess": "En ny källa har adderats", "ToastProviderNameAndUrlRequired": "Ett namn och en URL-adress krävs", From 08b4d4d7a2b7588ac71d05862d1e17ec19e5386c Mon Sep 17 00:00:00 2001 From: burghy86 Date: Mon, 10 Feb 2025 13:25:01 +0000 Subject: [PATCH 242/509] Translated using Weblate (Italian) Currently translated at 100.0% (1089 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/it/ --- client/strings/it.json | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/client/strings/it.json b/client/strings/it.json index 1d04521f..ba106ee3 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -10,6 +10,8 @@ "ButtonApplyChapters": "Applica", "ButtonAuthors": "Autori", "ButtonBack": "Indietro", + "ButtonBatchEditPopulateFromExisting": "Popola da esistente", + "ButtonBatchEditPopulateMapDetails": "Inserisci i dettagli della mappa", "ButtonBrowseForFolder": "Per Cartella", "ButtonCancel": "Cancella", "ButtonCancelEncode": "Ferma la codifica", @@ -88,6 +90,8 @@ "ButtonSaveTracklist": "Salva Tracklist", "ButtonScan": "Scansiona", "ButtonScanLibrary": "Scansiona Libreria", + "ButtonScrollLeft": "Scorri verso sinistra", + "ButtonScrollRight": "Scorri verso destra", "ButtonSearch": "Cerca", "ButtonSelectFolderPath": "Seleziona percorso cartella", "ButtonSeries": "Serie", @@ -190,6 +194,7 @@ "HeaderSettingsExperimental": "Opzioni Sperimentali", "HeaderSettingsGeneral": "Generale", "HeaderSettingsScanner": "Scanner", + "HeaderSettingsWebClient": "Web Client", "HeaderSleepTimer": "Sveglia", "HeaderStatsLargestItems": "File pesanti", "HeaderStatsLongestItems": "libri più lunghi (ore)", @@ -429,7 +434,7 @@ "LabelMetadataProvider": "Metadata Provider", "LabelMinute": "Minuto", "LabelMinutes": "Minuti", - "LabelMissing": "Altro", + "LabelMissing": "Mancante", "LabelMissingEbook": "Non ha libri digitali", "LabelMissingSupplementaryEbook": "Non ha un libro digitale supplementare", "LabelMobileRedirectURIs": "URI di reindirizzamento mobile consentiti", @@ -481,6 +486,7 @@ "LabelPersonalYearReview": "Il tuo anno in rassegna ({0})", "LabelPhotoPathURL": "foto Path/URL", "LabelPlayMethod": "Metodo di riproduzione", + "LabelPlaybackRateIncrementDecrement": "Valore incremento/decremento velocità di riproduzione", "LabelPlayerChapterNumberMarker": "{0} di {1}", "LabelPlaylists": "Playlist", "LabelPodcast": "Podcast", @@ -543,6 +549,7 @@ "LabelServerYearReview": "Anno del server in sintesi({0})", "LabelSetEbookAsPrimary": "Imposta come primario", "LabelSetEbookAsSupplementary": "Imposta come suplementare", + "LabelSettingsAllowIframe": "Consenti l'incorporamento in un iframe", "LabelSettingsAudiobooksOnly": "Solo Audiolibri", "LabelSettingsAudiobooksOnlyHelp": "L'abilitazione di questa impostazione ignorerà i file di libro digitale a meno che non si trovino all'interno di una cartella di audiolibri, nel qual caso verranno impostati come libri digitali supplementari", "LabelSettingsBookshelfViewHelp": "Design con scaffali in legno", @@ -585,6 +592,7 @@ "LabelSettingsStoreMetadataWithItemHelp": "Di default, i metadati sono salvati dentro /metadata/items, abilitando questa opzione si memorizzeranno i metadata nella cartella della libreria", "LabelSettingsTimeFormat": "Formato Ora", "LabelShare": "Condividi", + "LabelShareDownloadableHelp": "Consente agli utenti dotati del link di condivisione di scaricare un file zip dell'elemento della libreria.", "LabelShareOpen": "Apri Condivisioni", "LabelShareURL": "Condividi URL", "LabelShowAll": "Mostra tutto", @@ -593,6 +601,8 @@ "LabelSize": "Dimensione", "LabelSleepTimer": "Temporizzatore", "LabelSlug": "Lento", + "LabelSortAscending": "Crescente", + "LabelSortDescending": "Discendente", "LabelStart": "Inizo", "LabelStartTime": "Tempo di inizio", "LabelStarted": "Iniziato", @@ -664,6 +674,7 @@ "LabelUpdateDetailsHelp": "Consenti la sovrascrittura dei dettagli esistenti per i libri selezionati quando viene individuata una corrispondenza", "LabelUpdatedAt": "Aggiornato alle", "LabelUploaderDragAndDrop": "Drag & drop file o Cartelle", + "LabelUploaderDragAndDropFilesOnly": "Drag & drop files", "LabelUploaderDropFiles": "Elimina file", "LabelUploaderItemFetchMetadataHelp": "Recupera automaticamente titolo, autore e serie", "LabelUseAdvancedOptions": "Usa le opzioni avanzate", @@ -679,6 +690,8 @@ "LabelViewPlayerSettings": "Mostra Impostazioni player", "LabelViewQueue": "Visualizza coda", "LabelVolume": "Volume", + "LabelWebRedirectURLsDescription": "Autorizza questi URL nel tuo provider OAuth per consentire il reindirizzamento all'app Web dopo l'accesso:", + "LabelWebRedirectURLsSubfolder": "Sottocartella per URL di reindirizzamento", "LabelWeekdaysToRun": "Giorni feriali da eseguire", "LabelXBooks": "{0} libri", "LabelXItems": "{0} oggetti", @@ -694,8 +707,11 @@ "MessageBackupsLocationEditNote": "Nota: l'aggiornamento della posizione di backup non sposterà o modificherà i backup esistenti", "MessageBackupsLocationNoEditNote": "Nota: la posizione del backup viene impostata tramite una variabile di ambiente e non può essere modificata qui.", "MessageBackupsLocationPathEmpty": "Il percorso del backup non può essere vuoto", + "MessageBatchEditPopulateMapDetailsAllHelp": "Popola i campi abilitati con i dati di tutti gli elementi. I campi con più valori verranno uniti", + "MessageBatchEditPopulateMapDetailsItemHelp": "Compila i campi dei dettagli della mappa abilitati con i dati di questo elemento", "MessageBatchQuickMatchDescription": "Quick Match tenterà di aggiungere copertine e metadati mancanti per gli elementi selezionati. Attiva l'opzione per consentire a Quick Match di sovrascrivere copertine e/o metadati esistenti.", "MessageBookshelfNoCollections": "Non hai ancora creato nessuna raccolta", + "MessageBookshelfNoCollectionsHelp": "le collezioni sono pubbliche. Tutti gli utenti con accesso alla biblioteca possono vederle.", "MessageBookshelfNoRSSFeeds": "Nessun RSS feeds aperto", "MessageBookshelfNoResultsForFilter": "Nessun risultato per il filtro \"{0}: {1}\"", "MessageBookshelfNoResultsForQuery": "Nessun risultato per la query", @@ -748,6 +764,7 @@ "MessageConfirmResetProgress": "Vuoi davvero azzerare i tuoi progressi?", "MessageConfirmSendEbookToDevice": "Sei sicuro/sicura di voler inviare {0} libro «{1}» al dispositivo «{2}»?", "MessageConfirmUnlinkOpenId": "Vuoi davvero scollegare questo utente da OpenID?", + "MessageDaysListenedInTheLastYear": "{0} giorni ascoltati nell'ultimo anno", "MessageDownloadingEpisode": "Scaricamento dell’episodio in corso", "MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto", "MessageEmbedFailed": "Incorporamento non riuscito!", @@ -805,6 +822,7 @@ "MessageNoTasksRunning": "Nessun processo in esecuzione", "MessageNoUpdatesWereNecessary": "Nessun aggiornamento necessario", "MessageNoUserPlaylists": "non hai nessuna Playlist", + "MessageNoUserPlaylistsHelp": "Le playlist sono private. Solo l'utente che le crea può vederle.", "MessageNotYetImplemented": "Non Ancora Implementato", "MessageOpmlPreviewNote": "Nota: questa è un'anteprima del file OPML analizzato. Il titolo effettivo del podcast verrà preso dal feed RSS.", "MessageOr": "o", @@ -826,6 +844,7 @@ "MessageResetChaptersConfirm": "Sei sicuro di voler reimpostare i capitoli e annullare le modifiche ?", "MessageRestoreBackupConfirm": "Sei sicuro di voler ripristinare il backup creato su", "MessageRestoreBackupWarning": "Il ripristino di un backup sovrascriverà l'intero database situato in /config e sovrascrive le immagini in /metadata/items & /metadata/authors.

I backup non modificano alcun file nelle cartelle della libreria. Se hai abilitato le impostazioni del server per archiviare copertine e metadati nelle cartelle della libreria, questi non vengono sottoposti a backup o sovrascritti.

Tutti i client che utilizzano il tuo server verranno aggiornati automaticamente.", + "MessageScheduleLibraryScanNote": "Per la maggior parte degli utenti, si consiglia di lasciare questa funzionalità disabilitata e di mantenere abilitata l'impostazione di folder watcher. Il folder watcher rileverà automaticamente le modifiche nelle cartelle della libreria. Il folder watcher non funziona per ogni file system (come NFS), quindi è possibile utilizzare le scansioni pianificate della libreria.", "MessageSearchResultsFor": "cerca risultati per", "MessageSelected": "{0} selezionati", "MessageServerCouldNotBeReached": "Impossibile raggiungere il server", @@ -952,6 +971,7 @@ "ToastCollectionRemoveSuccess": "Collezione rimossa", "ToastCollectionUpdateSuccess": "Raccolta aggiornata", "ToastCoverUpdateFailed": "Aggiornamento cover fallito", + "ToastDateTimeInvalidOrIncomplete": "Data e ora non sono valide o incomplete", "ToastDeleteFileFailed": "Impossibile eliminare il file", "ToastDeleteFileSuccess": "File eliminato", "ToastDeviceAddFailed": "Aggiunta dispositivo fallita", @@ -1004,6 +1024,7 @@ "ToastNewUserTagError": "Devi selezionare almeno un tag", "ToastNewUserUsernameError": "Inserisci un nome utente", "ToastNoNewEpisodesFound": "Nessun nuovo episodio trovato", + "ToastNoRSSFeed": "Il podcast non ha un feed RSS", "ToastNoUpdatesNecessary": "Nessun aggiornamento necessario", "ToastNotificationCreateFailed": "Impossibile creare la notifica", "ToastNotificationDeleteFailed": "Impossibile eliminare la notifica", From cbe5e3db8a7107342e5dd5364fd568fdfbeaefe0 Mon Sep 17 00:00:00 2001 From: Troja Date: Mon, 10 Feb 2025 15:50:40 +0000 Subject: [PATCH 243/509] Translated using Weblate (Belarusian) Currently translated at 13.0% (142 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/be/ --- client/strings/be.json | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/client/strings/be.json b/client/strings/be.json index fcf30d39..09deaa9b 100644 --- a/client/strings/be.json +++ b/client/strings/be.json @@ -100,9 +100,14 @@ "ButtonUserEdit": "Рэдагаваць карыстальніка {0}", "ButtonViewAll": "Прагледзець усе", "ButtonYes": "Так", + "ErrorUploadFetchMetadataAPI": "Памылка пры атрыманні метададзеных", + "ErrorUploadFetchMetadataNoResults": "Не ўдалося атрымаць метададзеныя – паспрабуйце абнавіць назву і/або аўтара", + "ErrorUploadLacksTitle": "Павінна быць назва", "HeaderAccount": "Уліковы запіс", "HeaderAddCustomMetadataProvider": "Дадаць карыстальніцкага пастаўшчыка метаданных", + "HeaderAdvanced": "Дадаткова", "HeaderAppriseNotificationSettings": "Налады апавяшчэнняў Apprise", + "HeaderAudioTracks": "Аўдыёдарожкі", "HeaderAudiobookTools": "Сродкі кіравання файламі аўдыякніг", "HeaderAuthentication": "Аўтэнтыфікацыя", "HeaderBackups": "Рэзервовыя копіі", @@ -113,5 +118,28 @@ "HeaderCollectionItems": "Элементы калекцыі", "HeaderCover": "Вокладка", "HeaderCurrentDownloads": "Бягучыя загрузкі", - "HeaderCustomMessageOnLogin": "Карыстальніцкае паведамленне пры ўваходзе" + "HeaderCustomMessageOnLogin": "Карыстальніцкае паведамленне пры ўваходзе", + "HeaderCustomMetadataProviders": "Карыстальніцкія крыніцы метададзеных", + "HeaderDetails": "Падрабязнасці", + "HeaderDownloadQueue": "Чарга загрузкі", + "HeaderEbookFiles": "Файлы электронных кніг", + "HeaderEmail": "Электронная пошта", + "HeaderEmailSettings": "Налады электроннай пошты", + "HeaderEpisodes": "Эпізоды", + "HeaderEreaderDevices": "Прылады для чытання", + "HeaderEreaderSettings": "Налады прылады для чытання", + "HeaderFiles": "Файлы", + "HeaderFindChapters": "Знайсці раздзелы", + "HeaderIgnoredFiles": "Ігнараваныя файлы", + "HeaderItemFiles": "Файлы элементаў", + "HeaderItemMetadataUtils": "Утыліты для метададзеных элементаў", + "HeaderLastListeningSession": "Апошні сеанс праслухоўвання", + "HeaderLatestEpisodes": "Апошнія эпізоды", + "HeaderLibraries": "Бібліятэкі", + "HeaderLibraryFiles": "Файлы бібліятэкі", + "HeaderLibraryStats": "Статыстыка бібліятэкі", + "HeaderListeningSessions": "Сеансы праслухоўвання", + "HeaderListeningStats": "Статыстыка праслухоўвання", + "HeaderLogin": "Уваход", + "LabelTracks": "Дарожкі" } From 21b27c432c4082d8b065ad2a48aabef7c9f7cd48 Mon Sep 17 00:00:00 2001 From: Troja Date: Mon, 10 Feb 2025 18:43:45 +0000 Subject: [PATCH 244/509] Translated using Weblate (Belarusian) Currently translated at 16.0% (175 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/be/ --- client/strings/be.json | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/client/strings/be.json b/client/strings/be.json index 09deaa9b..9bdc5c94 100644 --- a/client/strings/be.json +++ b/client/strings/be.json @@ -10,6 +10,7 @@ "ButtonApplyChapters": "Ужыць раздзелы", "ButtonAuthors": "Аўтары", "ButtonBack": "Назад", + "ButtonBatchEditPopulateFromExisting": "Запоўніць з існуючага", "ButtonBrowseForFolder": "Знайсці тэчку", "ButtonCancel": "Адмяніць", "ButtonCancelEncode": "Адмяніць кадзіраванне", @@ -35,14 +36,18 @@ "ButtonForceReScan": "Прымусовае паўторнае сканаванне", "ButtonFullPath": "Поўны шлях", "ButtonHide": "Схаваць", + "ButtonHome": "Галоўная", "ButtonIssues": "Праблемы", "ButtonJumpBackward": "Перайсці назад", "ButtonJumpForward": "Перайсці наперад", + "ButtonLatest": "Апошняе", "ButtonLibrary": "Бібліятэка", "ButtonLogout": "Выйсці", "ButtonLookup": "", + "ButtonManageTracks": "Кіраванне дарожкамі", "ButtonMapChapterTitles": "Супаставіць назвы раздзелаў", "ButtonMatchAllAuthors": "Супадзенне ўсіх аўтараў", + "ButtonMatchBooks": "Падбор кніг", "ButtonNevermind": "Няважна", "ButtonNext": "Далей", "ButtonNextChapter": "Наступны раздзел", @@ -71,6 +76,9 @@ "ButtonRemove": "Выдаліць", "ButtonRemoveAll": "Выдаліць усе", "ButtonRemoveAllLibraryItems": "Выдаліць усе элементы бібліятэкі", + "ButtonRemoveFromContinueListening": "Выдаліць з Працягваць слухаць", + "ButtonRemoveFromContinueReading": "Выдаліць з Працягваць чытанне", + "ButtonRemoveSeriesFromContinueSeries": "Выдаліць серыю з Працягваць серыю", "ButtonReset": "Скінуць", "ButtonResetToDefault": "Скінуць па змаўчанні", "ButtonRestore": "Аднавіць", @@ -117,11 +125,11 @@ "HeaderCollection": "Калекцыя", "HeaderCollectionItems": "Элементы калекцыі", "HeaderCover": "Вокладка", - "HeaderCurrentDownloads": "Бягучыя загрузкі", + "HeaderCurrentDownloads": "Бягучыя спампоўкі", "HeaderCustomMessageOnLogin": "Карыстальніцкае паведамленне пры ўваходзе", "HeaderCustomMetadataProviders": "Карыстальніцкія крыніцы метададзеных", "HeaderDetails": "Падрабязнасці", - "HeaderDownloadQueue": "Чарга загрузкі", + "HeaderDownloadQueue": "Чарга спамповак", "HeaderEbookFiles": "Файлы электронных кніг", "HeaderEmail": "Электронная пошта", "HeaderEmailSettings": "Налады электроннай пошты", @@ -141,5 +149,30 @@ "HeaderListeningSessions": "Сеансы праслухоўвання", "HeaderListeningStats": "Статыстыка праслухоўвання", "HeaderLogin": "Уваход", - "LabelTracks": "Дарожкі" + "HeaderLogs": "Журналы", + "HeaderManageGenres": "Кіраванне жанрамі", + "HeaderManageTags": "Кіраванне тэгамі", + "HeaderMapDetails": "Падрабязнасці адлюстравання", + "HeaderScheduleEpisodeDownloads": "Расклад аўтаматычных спамповак эпізодаў", + "LabelAutoDownloadEpisodes": "Аўтаматычнае спампаванне эпізодаў", + "LabelContinueListening": "Працягваць слухаць", + "LabelDownload": "Спампаваць", + "LabelDownloadNEpisodes": "Спампована {0} эпізодаў", + "LabelDownloadable": "Спампоўваецца", + "LabelMaxEpisodesToDownload": "Максімальная колькасць эпізодаў для спампоўкі. Выкарыстоўвайце 0 для неабмежаванай колькасці.", + "LabelMaxEpisodesToDownloadPerCheck": "Максімальная колькасць новых эпізодаў для спампоўкі за праверку", + "LabelMaxEpisodesToKeepHelp": "Значэнне 0 не ўстанаўлівае максімальнага абмежавання. Пасля аўтаматычнай спампоўкі новага эпізоду будзе выдалены самы стары эпізод, калі ў вас больш за X эпізодаў. Пры кожнай новай спампоўцы будзе выдаляцца толькі 1 эпізод.", + "LabelPermissionsDownload": "Можна спампаваць", + "LabelReAddSeriesToContinueListening": "Дадаць серыю зноў у Працягваць слухаць", + "LabelShareDownloadableHelp": "Дазваляе карыстальнікам, якія маюць спасылку на доступ, спампаваць ZIP-файл элемента бібліятэкі.", + "LabelTracks": "Дарожкі", + "MessageDownloadingEpisode": "Спампоўка эпізоду", + "MessageEpisodesQueuedForDownload": "{0} эпізод(аў) у чарзе для спампоўкі", + "MessageNoDownloadsInProgress": "Зараз няма актыўных спамповак", + "MessageNoDownloadsQueued": "Няма спамповак у чарзе", + "MessageTaskDownloadingEpisodeDescription": "Спампоўка эпізоду \"{0}\"", + "NotificationOnEpisodeDownloadedDescription": "Выклікаецца, калі эпізод падкаста аўтаматычна спампоўваецца", + "ToastEpisodeDownloadQueueClearFailed": "Не ўдалося ачысціць чаргу", + "ToastEpisodeDownloadQueueClearSuccess": "Чарга спампоўкі эпізодаў ачышчана", + "ToastInvalidMaxEpisodesToDownload": "Няправільная максімальная колькасць эпізодаў для спампоўкі" } From 1be1ce6f87dbdc96623510b949f31f50c7365c84 Mon Sep 17 00:00:00 2001 From: Mario Date: Tue, 11 Feb 2025 14:12:45 +0000 Subject: [PATCH 245/509] Translated using Weblate (German) Currently translated at 99.9% (1088 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/strings/de.json b/client/strings/de.json index b31fedc9..2b07ee03 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -10,6 +10,8 @@ "ButtonApplyChapters": "Kapitel anwenden", "ButtonAuthors": "Autoren", "ButtonBack": "Zurück", + "ButtonBatchEditPopulateFromExisting": "Auffüllen aus vorhandenem", + "ButtonBatchEditPopulateMapDetails": "Kartendetails auffüllen", "ButtonBrowseForFolder": "Ordnersuche", "ButtonCancel": "Abbrechen", "ButtonCancelEncode": "Codierung abbrechen", @@ -484,6 +486,7 @@ "LabelPersonalYearReview": "Dein Jahr in Übersicht ({0})", "LabelPhotoPathURL": "Foto Pfad/URL", "LabelPlayMethod": "Abspielmethode", + "LabelPlaybackRateIncrementDecrement": "Wiedergaberate der Erhöhung/Verminderung", "LabelPlayerChapterNumberMarker": "{0} von {1}", "LabelPlaylists": "Wiedergabelisten", "LabelPodcast": "Podcast", @@ -704,8 +707,10 @@ "MessageBackupsLocationEditNote": "Hinweis: Durch das Aktualisieren des Backup-Speicherorts werden vorhandene Sicherungen nicht verschoben oder geändert", "MessageBackupsLocationNoEditNote": "Hinweis: Der Sicherungsspeicherort wird über eine Umgebungsvariable festgelegt und kann hier nicht geändert werden.", "MessageBackupsLocationPathEmpty": "Der Backup-Pfad darf nicht leer sein", + "MessageBatchEditPopulateMapDetailsAllHelp": "Fülle die aktivierten Felder mit Daten aus allen Elementen. Felder mit mehreren Werten werden zusammengeführt", "MessageBatchQuickMatchDescription": "Der Schnellabgleich versucht, fehlende Titelbilder und Metadaten für die ausgewählten Artikel hinzuzufügen. Aktiviere die nachstehenden Optionen, damit der Schnellabgleich vorhandene Titelbilder und/oder Metadaten überschreiben kann.", "MessageBookshelfNoCollections": "Es wurden noch keine Sammlungen erstellt", + "MessageBookshelfNoCollectionsHelp": "Sammlungen sind öffentlich. Alle Benutzer mit Zugriff auf die Bibliothek können sie sehen.", "MessageBookshelfNoRSSFeeds": "Keine RSS-Feeds geöffnet", "MessageBookshelfNoResultsForFilter": "Keine Ergebnisse für Filter \"{0}: {1}\"", "MessageBookshelfNoResultsForQuery": "Keine Ergebnisse für die Abfrage", @@ -816,6 +821,7 @@ "MessageNoTasksRunning": "Keine laufenden Aufgaben", "MessageNoUpdatesWereNecessary": "Keine Aktualisierungen waren notwendig", "MessageNoUserPlaylists": "Keine Wiedergabelisten vorhanden", + "MessageNoUserPlaylistsHelp": "Wiedergabelisten sind privat. Nur der Benutzer, der sie erstellt hat, kann sie sehen.", "MessageNotYetImplemented": "Noch nicht implementiert", "MessageOpmlPreviewNote": "Hinweis: Dies ist nur eine Vorschau der geparsten OPML Datei. Der eigentliche Podcast-Titel wird aus dem RSS-Feed übernommen.", "MessageOr": "Oder", From 03a1d7da32b6a7c5e807c068c7e6d28d399f1382 Mon Sep 17 00:00:00 2001 From: Troja Date: Tue, 11 Feb 2025 19:14:44 +0000 Subject: [PATCH 246/509] Translated using Weblate (Belarusian) Currently translated at 19.4% (212 of 1089 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/be/ --- client/strings/be.json | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/client/strings/be.json b/client/strings/be.json index 9bdc5c94..5533123b 100644 --- a/client/strings/be.json +++ b/client/strings/be.json @@ -115,7 +115,7 @@ "HeaderAddCustomMetadataProvider": "Дадаць карыстальніцкага пастаўшчыка метаданных", "HeaderAdvanced": "Дадаткова", "HeaderAppriseNotificationSettings": "Налады апавяшчэнняў Apprise", - "HeaderAudioTracks": "Аўдыёдарожкі", + "HeaderAudioTracks": "Аўдыядарожкі", "HeaderAudiobookTools": "Сродкі кіравання файламі аўдыякніг", "HeaderAuthentication": "Аўтэнтыфікацыя", "HeaderBackups": "Рэзервовыя копіі", @@ -153,26 +153,63 @@ "HeaderManageGenres": "Кіраванне жанрамі", "HeaderManageTags": "Кіраванне тэгамі", "HeaderMapDetails": "Падрабязнасці адлюстравання", + "HeaderNewAccount": "Новы ўліковы запіс", + "HeaderNewLibrary": "Новая бібліятэка", + "HeaderNotificationCreate": "Стварыць апавяшчэнне", + "HeaderNotificationUpdate": "Абнавіць апавяшчэнне", + "HeaderNotifications": "Апавяшчэнні", + "HeaderOpenListeningSessions": "Адкрыць сеансы праслухоўвання", "HeaderScheduleEpisodeDownloads": "Расклад аўтаматычных спамповак эпізодаў", + "HeaderSettings": "Налады", + "HeaderSettingsDisplay": "Дысплей", + "HeaderSettingsExperimental": "Эксперыментальныя функцыі", + "HeaderSettingsGeneral": "Агульныя", + "HeaderSettingsScanner": "Сканер", + "HeaderSettingsWebClient": "Вэб-кліент", + "HeaderStatsTop10Authors": "10 лепшых аўтараў", + "HeaderStatsTop5Genres": "5 лепшых жанраў", + "HeaderTableOfContents": "Змест", + "HeaderTools": "Інструменты", + "HeaderUpdateAccount": "Абнавіць уліковы запіс", + "LabelAccountType": "Тып уліковага запіса", + "LabelAccountTypeAdmin": "Адміністратар", + "LabelAccountTypeGuest": "Госць", + "LabelAccountTypeUser": "Карыстальнік", + "LabelAudioBitrate": "Бітрэйт аўдыё (напрыклад, 128к)", + "LabelAudioChannels": "Аўдыёканалы (1 або 2)", + "LabelAudioCodec": "Аўдыёкодэк", "LabelAutoDownloadEpisodes": "Аўтаматычнае спампаванне эпізодаў", + "LabelBackupAudioFiles": "Рэзервовае капіраванне аўдыёфайлаў", "LabelContinueListening": "Працягваць слухаць", "LabelDownload": "Спампаваць", "LabelDownloadNEpisodes": "Спампована {0} эпізодаў", "LabelDownloadable": "Спампоўваецца", + "LabelEncodingBackupLocation": "Рэзервовая копія вашых арыгінальных аўдыёфайлаў будзе захавана ў:", + "LabelEncodingChaptersNotEmbedded": "Раздзелы не ўбудаваны ў шматдарожкавыя аўдыякнігі.", + "LabelEncodingFinishedM4B": "Гатовы файл M4B будзе змешчаны ў вашу тэчку з аўдыякнігамі па адрасе:", + "LabelEncodingInfoEmbedded": "Метаданыя будуць убудаваны ў аўдыядарожкі ўнутры вашай тэчкі з аўдыякнігамі.", "LabelMaxEpisodesToDownload": "Максімальная колькасць эпізодаў для спампоўкі. Выкарыстоўвайце 0 для неабмежаванай колькасці.", "LabelMaxEpisodesToDownloadPerCheck": "Максімальная колькасць новых эпізодаў для спампоўкі за праверку", "LabelMaxEpisodesToKeepHelp": "Значэнне 0 не ўстанаўлівае максімальнага абмежавання. Пасля аўтаматычнай спампоўкі новага эпізоду будзе выдалены самы стары эпізод, калі ў вас больш за X эпізодаў. Пры кожнай новай спампоўцы будзе выдаляцца толькі 1 эпізод.", "LabelPermissionsDownload": "Можна спампаваць", "LabelReAddSeriesToContinueListening": "Дадаць серыю зноў у Працягваць слухаць", "LabelShareDownloadableHelp": "Дазваляе карыстальнікам, якія маюць спасылку на доступ, спампаваць ZIP-файл элемента бібліятэкі.", + "LabelStatsAudioTracks": "Аўдыядарожкі", "LabelTracks": "Дарожкі", + "MessageConfirmRemoveListeningSessions": "Вы ўпэўнены, што жадаеце выдаліць {0} сеансаў праслухоўвання?", "MessageDownloadingEpisode": "Спампоўка эпізоду", "MessageEpisodesQueuedForDownload": "{0} эпізод(аў) у чарзе для спампоўкі", "MessageNoDownloadsInProgress": "Зараз няма актыўных спамповак", "MessageNoDownloadsQueued": "Няма спамповак у чарзе", + "MessageNoListeningSessions": "Няма сеансаў праслухоўвання", "MessageTaskDownloadingEpisodeDescription": "Спампоўка эпізоду \"{0}\"", "NotificationOnEpisodeDownloadedDescription": "Выклікаецца, калі эпізод падкаста аўтаматычна спампоўваецца", + "ToastAccountUpdateSuccess": "Уліковы запіс абноўлены", "ToastEpisodeDownloadQueueClearFailed": "Не ўдалося ачысціць чаргу", "ToastEpisodeDownloadQueueClearSuccess": "Чарга спампоўкі эпізодаў ачышчана", - "ToastInvalidMaxEpisodesToDownload": "Няправільная максімальная колькасць эпізодаў для спампоўкі" + "ToastInvalidMaxEpisodesToDownload": "Няправільная максімальная колькасць эпізодаў для спампоўкі", + "ToastNewUserCreatedFailed": "Не ўдалося стварыць уліковы запіс: \"{0}\"", + "ToastNewUserCreatedSuccess": "Новы ўліковы запіс створаны", + "ToastUserPasswordMustChange": "Новы пароль не можа супадаць са старым", + "ToastUserRootRequireName": "Неабходна ўвесці імя карыстальніка адміністратара" } From 725192fbc0f5b289bc70a4eed451710359464ca6 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 11 Feb 2025 17:17:07 -0600 Subject: [PATCH 247/509] Version bump v2.19.1 --- 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 7aa99d67..4d45011a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.19.0", + "version": "2.19.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.19.0", + "version": "2.19.1", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 7b09854a..43d0457f 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.19.0", + "version": "2.19.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index f0bca018..615c8d0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.19.0", + "version": "2.19.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.19.0", + "version": "2.19.1", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index ce36e229..8cb0cdd5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.19.0", + "version": "2.19.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From a34813b3ab81fc96ccea0152a7ec400c4e31e70d Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 12 Feb 2025 08:52:20 -0600 Subject: [PATCH 248/509] Fix server crash remove column name ambiguity #3966 --- server/utils/queries/libraryItemsBookFilters.js | 8 ++++---- server/utils/queries/libraryItemsPodcastFilters.js | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 3adb929e..65ab1fef 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -580,9 +580,9 @@ module.exports = { // When collapsing series and sorting by title then use the series name instead of the book title // for this set an attribute "display_title" to use in sorting if (global.ServerSettings.sortingIgnorePrefix) { - bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.nameIgnorePrefix FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map((v) => `"${v.id}"`).join(', ')})), titleIgnorePrefix)`), 'display_title']) + bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.nameIgnorePrefix FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map((v) => `"${v.id}"`).join(', ')})), \`libraryItem\`.\`titleIgnorePrefix\`)`), 'display_title']) } else { - bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.name FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map((v) => `"${v.id}"`).join(', ')})), \`book\`.\`title\`)`), 'display_title']) + bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.name FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map((v) => `"${v.id}"`).join(', ')})), \`libraryItem\`.\`title\`)`), 'display_title']) } } @@ -1035,8 +1035,8 @@ module.exports = { const textSearchQuery = await Database.createTextSearchQuery(query) - const matchTitle = textSearchQuery.matchExpression('title') - const matchSubtitle = textSearchQuery.matchExpression('subtitle') + const matchTitle = textSearchQuery.matchExpression('book.title') + const matchSubtitle = textSearchQuery.matchExpression('book.subtitle') // Search title, subtitle, asin, isbn const books = await Database.bookModel.findAll({ diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 36241f33..0cd159ba 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -84,7 +84,7 @@ module.exports = { return [[Sequelize.literal(`\`podcast\`.\`author\` COLLATE NOCASE ${nullDir}`)]] } else if (sortBy === 'media.metadata.title') { if (global.ServerSettings.sortingIgnorePrefix) { - return [[Sequelize.literal('titleIgnorePrefix COLLATE NOCASE'), dir]] + return [[Sequelize.literal('`podcast`.`titleIgnorePrefix` COLLATE NOCASE'), dir]] } else { return [[Sequelize.literal('`podcast`.`title` COLLATE NOCASE'), dir]] } @@ -321,8 +321,8 @@ module.exports = { const textSearchQuery = await Database.createTextSearchQuery(query) - const matchTitle = textSearchQuery.matchExpression('title') - const matchAuthor = textSearchQuery.matchExpression('author') + const matchTitle = textSearchQuery.matchExpression('podcast.title') + const matchAuthor = textSearchQuery.matchExpression('podcast.author') // Search title, author, itunesId, itunesArtistId const podcasts = await Database.podcastModel.findAll({ From ebdf377fc186ee34c1700b8943cfc088b549369e Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 12 Feb 2025 10:01:05 -0600 Subject: [PATCH 249/509] Version bump v2.19.2 --- 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 4d45011a..7189685c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.19.1", + "version": "2.19.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.19.1", + "version": "2.19.2", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 43d0457f..7b0530a2 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.19.1", + "version": "2.19.2", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 615c8d0f..637789aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.19.1", + "version": "2.19.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.19.1", + "version": "2.19.2", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 8cb0cdd5..a6dd6fb0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.19.1", + "version": "2.19.2", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From f460297dafa6ddf1ced9bdacaef8609626a9f392 Mon Sep 17 00:00:00 2001 From: Conner McCall Date: Thu, 13 Feb 2025 09:06:53 -0600 Subject: [PATCH 250/509] fix: allow upgrading HTTP to HTTPS for redirects Re: #3142 and #3658 When adding certain podcasts, the server encountered a redirect from an HTTP URL to an HTTPS domain, causing an error that was difficult for end users to diagnose without inspecting logs or HTML. This issue arose due to SSRF security measures that blocked such redirects. Instead of failing in these cases, we now detect when the error is caused by an HTTP-to-HTTPS upgrade. If confirmed, we upgrade the initial URL to HTTPS and resend the request. Since this change does not allow cross-protocol or cross-domain redirections, it remains secure while resolving most of the reported issues. Affected podcasts that are now fixed: - D&D is for Nerds - The New Yorker: The Writer's Voice - New Fiction from The New Yorker - Radiolab --- server/utils/podcastUtils.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 485fccfb..1ecb0a75 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -343,6 +343,14 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { return payload.podcast }) .catch((error) => { + // Check for failures due to redirecting from http to https. If original url was http, upgrade to https and try again + if (error.code === 'ERR_FR_REDIRECTION_FAILURE' && error.cause.code === 'ERR_INVALID_PROTOCOL') { + if (feedUrl.startsWith('http://') && error.request._options.protocol === 'https:') { + Logger.info('Redirection from http to https detected. Upgrading Request', error.request._options.href) + feedUrl = feedUrl.replace('http://', 'https://') + return this.getPodcastFeed(feedUrl, excludeEpisodeMetadata) + } + } Logger.error('[podcastUtils] getPodcastFeed Error', error) return null }) From 5ca12eee19a8d64d0a2f029465e228da15ca799c Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 13 Feb 2025 18:07:59 -0600 Subject: [PATCH 251/509] Fix count cache by stringify Symbols #3979 --- .../utils/queries/libraryItemsBookFilters.js | 5 +-- server/utils/stringifySequelizeQuery.js | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 server/utils/stringifySequelizeQuery.js diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 65ab1fef..d446a5e9 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -5,7 +5,7 @@ const authorFilters = require('./authorFilters') const ShareManager = require('../../managers/ShareManager') const { profile } = require('../profiler') - +const stringifySequelizeQuery = require('../stringifySequelizeQuery') const countCache = new Map() module.exports = { @@ -345,7 +345,7 @@ module.exports = { }, async findAndCountAll(findOptions, limit, offset) { - const findOptionsKey = JSON.stringify(findOptions) + const findOptionsKey = stringifySequelizeQuery(findOptions) Logger.debug(`[LibraryItemsBookFilters] findOptionsKey: ${findOptionsKey}`) findOptions.limit = limit || null @@ -353,6 +353,7 @@ module.exports = { if (countCache.has(findOptionsKey)) { const rows = await Database.bookModel.findAll(findOptions) + return { rows, count: countCache.get(findOptionsKey) } } else { const result = await Database.bookModel.findAndCountAll(findOptions) diff --git a/server/utils/stringifySequelizeQuery.js b/server/utils/stringifySequelizeQuery.js new file mode 100644 index 00000000..a41e0650 --- /dev/null +++ b/server/utils/stringifySequelizeQuery.js @@ -0,0 +1,34 @@ +function stringifySequelizeQuery(findOptions) { + // Helper function to handle symbols in nested objects + function handleSymbols(obj) { + if (!obj || typeof obj !== 'object') return obj + + if (Array.isArray(obj)) { + return obj.map(handleSymbols) + } + + const newObj = {} + for (const [key, value] of Object.entries(obj)) { + // Handle Symbol keys from Object.getOwnPropertySymbols + Object.getOwnPropertySymbols(obj).forEach((sym) => { + newObj[`__Op.${sym.toString()}`] = handleSymbols(obj[sym]) + }) + + // Handle regular keys + if (typeof key === 'string') { + if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Symbol.prototype) { + // Handle Symbol values + newObj[key] = `__Op.${value.toString()}` + } else { + // Recursively handle nested objects + newObj[key] = handleSymbols(value) + } + } + } + return newObj + } + + const sanitizedOptions = handleSymbols(findOptions) + return JSON.stringify(sanitizedOptions) +} +module.exports = stringifySequelizeQuery From c4d99a118fc0ebd613bf58dd73324a4776e942f8 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 14 Feb 2025 16:24:39 -0600 Subject: [PATCH 252/509] Fix chapter end sleep timer sometimes not stopping #3969 --- .../components/app/MediaPlayerContainer.vue | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/client/components/app/MediaPlayerContainer.vue b/client/components/app/MediaPlayerContainer.vue index cf22d322..8aa6188b 100644 --- a/client/components/app/MediaPlayerContainer.vue +++ b/client/components/app/MediaPlayerContainer.vue @@ -85,7 +85,8 @@ export default { displayTitle: null, currentPlaybackRate: 1, syncFailedToast: null, - coverAspectRatio: 1 + coverAspectRatio: 1, + lastChapterId: null } }, computed: { @@ -236,12 +237,16 @@ export default { } }, 1000) }, - checkChapterEnd(time) { + checkChapterEnd() { if (!this.currentChapter) return - const chapterEndTime = this.currentChapter.end - const tolerance = 0.75 - if (time >= chapterEndTime - tolerance) { - this.sleepTimerEnd() + + // Track chapter transitions by comparing current chapter with last chapter + if (this.lastChapterId !== this.currentChapter.id) { + // Chapter changed - if we had a previous chapter, this means we crossed a boundary + if (this.lastChapterId) { + this.sleepTimerEnd() + } + this.lastChapterId = this.currentChapter.id } }, sleepTimerEnd() { @@ -301,7 +306,7 @@ export default { } if (this.sleepTimerType === this.$constants.SleepTimerTypes.CHAPTER && this.sleepTimerSet) { - this.checkChapterEnd(time) + this.checkChapterEnd() } }, setDuration(duration) { From d9b206fe1cfdd6f6714917d68e4b1bf726e968b9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 14 Feb 2025 16:56:37 -0600 Subject: [PATCH 253/509] Fix server crash when quick match all updates existing series sequence #3961 --- server/models/LibraryItem.js | 2 +- server/scanner/Scanner.js | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index ace6af43..5d23bc8f 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -103,7 +103,7 @@ class LibraryItem extends Model { { model: this.sequelize.models.series, through: { - attributes: ['sequence', 'createdAt'] + attributes: ['id', 'sequence', 'createdAt'] } } ] diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 1a2a7aaf..c5c62532 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -48,13 +48,7 @@ class Scanner { let updatePayload = {} let hasUpdated = false - let existingAuthors = [] // Used for checking if authors or series are now empty - let existingSeries = [] - if (libraryItem.isBook) { - existingAuthors = libraryItem.media.authors.map((a) => a.id) - existingSeries = libraryItem.media.series.map((s) => s.id) - const searchISBN = options.isbn || libraryItem.media.isbn const searchASIN = options.asin || libraryItem.media.asin From 8ee5646d790d5bf5a1fef42079b686b93f40a9d1 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 15 Feb 2025 23:57:27 +0200 Subject: [PATCH 254/509] fix stringifySequelizeQuery and add tests --- server/utils/stringifySequelizeQuery.js | 49 +++++++---------- .../utils/stringifySequeslizeQuery.test.js | 52 +++++++++++++++++++ 2 files changed, 72 insertions(+), 29 deletions(-) create mode 100644 test/server/utils/stringifySequeslizeQuery.test.js diff --git a/server/utils/stringifySequelizeQuery.js b/server/utils/stringifySequelizeQuery.js index a41e0650..44d8b12d 100644 --- a/server/utils/stringifySequelizeQuery.js +++ b/server/utils/stringifySequelizeQuery.js @@ -1,34 +1,25 @@ function stringifySequelizeQuery(findOptions) { - // Helper function to handle symbols in nested objects - function handleSymbols(obj) { - if (!obj || typeof obj !== 'object') return obj - - if (Array.isArray(obj)) { - return obj.map(handleSymbols) - } - - const newObj = {} - for (const [key, value] of Object.entries(obj)) { - // Handle Symbol keys from Object.getOwnPropertySymbols - Object.getOwnPropertySymbols(obj).forEach((sym) => { - newObj[`__Op.${sym.toString()}`] = handleSymbols(obj[sym]) - }) - - // Handle regular keys - if (typeof key === 'string') { - if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Symbol.prototype) { - // Handle Symbol values - newObj[key] = `__Op.${value.toString()}` - } else { - // Recursively handle nested objects - newObj[key] = handleSymbols(value) - } - } - } - return newObj + function isClass(func) { + return typeof func === 'function' && /^class\s/.test(func.toString()) } - const sanitizedOptions = handleSymbols(findOptions) - return JSON.stringify(sanitizedOptions) + function replacer(key, value) { + if (typeof value === 'object' && value !== null) { + const symbols = Object.getOwnPropertySymbols(value).reduce((acc, sym) => { + acc[sym.toString()] = value[sym] + return acc + }, {}) + + return { ...value, ...symbols } + } + + if (isClass(value)) { + return `${value.name}` + } + + return value + } + + return JSON.stringify(findOptions, replacer) } module.exports = stringifySequelizeQuery diff --git a/test/server/utils/stringifySequeslizeQuery.test.js b/test/server/utils/stringifySequeslizeQuery.test.js new file mode 100644 index 00000000..764acfd2 --- /dev/null +++ b/test/server/utils/stringifySequeslizeQuery.test.js @@ -0,0 +1,52 @@ +const { expect } = require('chai') +const stringifySequelizeQuery = require('../../../server/utils/stringifySequelizeQuery') +const Sequelize = require('sequelize') + +class DummyClass {} + +describe('stringifySequelizeQuery', () => { + it('should stringify a sequelize query containing an op', () => { + const query = { + where: { + name: 'John', + age: { + [Sequelize.Op.gt]: 20 + } + } + } + + const result = stringifySequelizeQuery(query) + expect(result).to.equal('{"where":{"name":"John","age":{"Symbol(gt)":20}}}') + }) + + it('should stringify a sequelize query containing a literal', () => { + const query = { + order: [[Sequelize.literal('libraryItem.title'), 'ASC']] + } + + const result = stringifySequelizeQuery(query) + expect(result).to.equal('{"order":{"0":{"0":{"val":"libraryItem.title"},"1":"ASC"}}}') + }) + + it('should stringify a sequelize query containing a class', () => { + const query = { + include: [ + { + model: DummyClass + } + ] + } + + const result = stringifySequelizeQuery(query) + expect(result).to.equal('{"include":{"0":{"model":"DummyClass"}}}') + }) + + it('should ignore non-class functions', () => { + const query = { + logging: (query) => console.log(query) + } + + const result = stringifySequelizeQuery(query) + expect(result).to.equal('{}') + }) +}) From 6a7418ad41305c7fbdee12d1513c0c32183b020e Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 15 Feb 2025 17:55:56 -0600 Subject: [PATCH 255/509] Fix:Edit book cover tab local images overflowing #3986 --- client/components/modals/item/tabs/Cover.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/components/modals/item/tabs/Cover.vue b/client/components/modals/item/tabs/Cover.vue index 69c11119..5b371fc4 100644 --- a/client/components/modals/item/tabs/Cover.vue +++ b/client/components/modals/item/tabs/Cover.vue @@ -1,7 +1,7 @@