From d7830f4bfc79cb0dbd7c507263813a8e19083a41 Mon Sep 17 00:00:00 2001 From: maxlajoie99 Date: Fri, 27 Dec 2024 20:26:55 -0500 Subject: [PATCH 01/52] Experimental proxy support by manually following redirects --- server/Server.js | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/server/Server.js b/server/Server.js index 46850cbb..9bbdb486 100644 --- a/server/Server.js +++ b/server/Server.js @@ -6,6 +6,7 @@ const util = require('util') const fs = require('./libs/fsExtra') const fileUpload = require('./libs/expressFileupload') const cookieParser = require('cookie-parser') +const axios = require('axios') const { version } = require('../package.json') @@ -54,7 +55,25 @@ class Server { global.XAccel = process.env.USE_X_ACCEL global.AllowCors = process.env.ALLOW_CORS === '1' - if (process.env.DISABLE_SSRF_REQUEST_FILTER === '1') { + if (process.env.EXP_PROXY_SUPPORT === '1') { + Logger.info(`[Server] Experimental Proxy Support Enabled, SSRF Request Filter was Disabled`); + global.DisableSsrfRequestFilter = () => true + + axios.defaults.maxRedirects = 0; + axios.interceptors.response.use( + response => response, + error => { + if ([301, 302].includes(error.response?.status)) { + return axios({ + ...error.config, + url: error.response.headers.location, + }); + } + + return Promise.reject(error); + } + ); + } else if (process.env.DISABLE_SSRF_REQUEST_FILTER === '1') { Logger.info(`[Server] SSRF Request Filter Disabled`) global.DisableSsrfRequestFilter = () => true } else if (process.env.SSRF_REQUEST_FILTER_WHITELIST?.length) { From e0c674d9a9519124227a874638ec7da3ac6ec43c Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 28 Dec 2024 16:36:53 -0600 Subject: [PATCH 02/52] Fix:Opening audiobook RSS feeds use audiofile name #3752 --- 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 3bf0ff85..0d1a3a48 100644 --- a/server/models/FeedEpisode.js +++ b/server/models/FeedEpisode.js @@ -138,7 +138,7 @@ class FeedEpisode extends Model { const contentUrl = `/feed/${slug}/item/${episodeId}/media${Path.extname(audioTrack.metadata.filename)}` - let title = audioTrack.title + let title = Path.basename(audioTrack.metadata.filename, Path.extname(audioTrack.metadata.filename)) if (book.trackList.length == 1) { // If audiobook is a single file, use book title instead of chapter/file title title = book.title From 4cdc2a8c28c3759cdbbd84dd4ae2ac79f6bb7ee9 Mon Sep 17 00:00:00 2001 From: Greg Lorenzen <31518305+glorenzen@users.noreply.github.com> Date: Sun, 29 Dec 2024 14:52:57 -0800 Subject: [PATCH 03/52] Feat/download via share link (#3666) * Adds share download endpoint * Adds Downloadable toggle to share modal --------- Co-authored-by: advplyr --- client/components/modals/ShareModal.vue | 20 ++++-- client/pages/share/_slug.vue | 10 +++ client/strings/en-us.json | 2 + server/controllers/ShareController.js | 65 +++++++++++++++++- server/migrations/changelog.md | 1 + .../v2.17.6-share-add-isdownloadable.js | 68 +++++++++++++++++++ server/models/MediaItemShare.js | 36 +++++++++- server/routers/PublicRouter.js | 1 + .../v2.17.6-share-add-isdownloadable.test.js | 68 +++++++++++++++++++ 9 files changed, 263 insertions(+), 8 deletions(-) create mode 100644 server/migrations/v2.17.6-share-add-isdownloadable.js create mode 100644 test/server/migrations/v2.17.6-share-add-isdownloadable.test.js diff --git a/client/components/modals/ShareModal.vue b/client/components/modals/ShareModal.vue index d0487fd3..5b379884 100644 --- a/client/components/modals/ShareModal.vue +++ b/client/components/modals/ShareModal.vue @@ -19,12 +19,13 @@
-

{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}

+

{{ $strings.LabelDownloadable }}

+

{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}

{{ $strings.LabelPermanent }}

@@ -81,7 +91,8 @@ export default { text: this.$strings.LabelDays, value: 'days' } - ] + ], + isDownloadable: false } }, watch: { @@ -172,7 +183,8 @@ export default { slug: this.newShareSlug, mediaItemType: 'book', mediaItemId: this.libraryItem.media.id, - expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0 + expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0, + isDownloadable: this.isDownloadable } this.processing = true this.$axios diff --git a/client/pages/share/_slug.vue b/client/pages/share/_slug.vue index 7ddb994c..6bce2f8a 100644 --- a/client/pages/share/_slug.vue +++ b/client/pages/share/_slug.vue @@ -12,6 +12,10 @@
+ + + + @@ -63,6 +67,9 @@ export default { if (!this.playbackSession.coverPath) return `${this.$config.routerBasePath}/book_placeholder.jpg` return `${this.$config.routerBasePath}/public/share/${this.mediaItemShare.slug}/cover` }, + downloadUrl() { + return `${process.env.serverUrl}/public/share/${this.mediaItemShare.slug}/download` + }, audioTracks() { return (this.playbackSession.audioTracks || []).map((track) => { track.relativeContentUrl = track.contentUrl @@ -247,6 +254,9 @@ export default { }, playerFinished() { console.log('Player finished') + }, + downloadShareItem() { + this.$downloadFile(this.downloadUrl) } }, mounted() { diff --git a/client/strings/en-us.json b/client/strings/en-us.json index a029fadb..eee94abf 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -300,6 +300,7 @@ "LabelDiscover": "Discover", "LabelDownload": "Download", "LabelDownloadNEpisodes": "Download {0} episodes", + "LabelDownloadable": "Downloadable", "LabelDuration": "Duration", "LabelDurationComparisonExactMatch": "(exact match)", "LabelDurationComparisonLonger": "({0} longer)", @@ -588,6 +589,7 @@ "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders", "LabelSettingsTimeFormat": "Time Format", "LabelShare": "Share", + "LabelShareDownloadableHelp": "Allows users with the share link to download a zip file of the library item.", "LabelShareOpen": "Share Open", "LabelShareURL": "Share URL", "LabelShowAll": "Show All", diff --git a/server/controllers/ShareController.js b/server/controllers/ShareController.js index e1568c0d..93c6e9fb 100644 --- a/server/controllers/ShareController.js +++ b/server/controllers/ShareController.js @@ -7,6 +7,7 @@ const Database = require('../Database') const { PlayMethod } = require('../utils/constants') const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils') +const zipHelpers = require('../utils/zipHelpers') const PlaybackSession = require('../objects/PlaybackSession') const ShareManager = require('../managers/ShareManager') @@ -210,6 +211,65 @@ class ShareController { res.sendFile(audioTrackPath) } + /** + * Public route - requires share_session_id cookie + * + * GET: /api/share/:slug/download + * Downloads media item share + * + * @param {Request} req + * @param {Response} res + */ + async downloadMediaItemShare(req, res) { + if (!req.cookies.share_session_id) { + return res.status(404).send('Share session not set') + } + + const { slug } = req.params + const mediaItemShare = ShareManager.findBySlug(slug) + if (!mediaItemShare) { + return res.status(404) + } + if (!mediaItemShare.isDownloadable) { + return res.status(403).send('Download is not allowed for this item') + } + + const playbackSession = ShareManager.findPlaybackSessionBySessionId(req.cookies.share_session_id) + if (!playbackSession || playbackSession.mediaItemShareId !== mediaItemShare.id) { + return res.status(404).send('Share session not found') + } + + const libraryItem = await Database.libraryItemModel.findByPk(playbackSession.libraryItemId, { + attributes: ['id', 'path', 'relPath', 'isFile'] + }) + if (!libraryItem) { + return res.status(404).send('Library item not found') + } + + const itemPath = libraryItem.path + const itemTitle = playbackSession.displayTitle + + Logger.info(`[ShareController] Requested download for book "${itemTitle}" at "${itemPath}"`) + + try { + if (libraryItem.isFile) { + const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(itemPath)) + if (audioMimeType) { + res.setHeader('Content-Type', audioMimeType) + } + await new Promise((resolve, reject) => res.download(itemPath, libraryItem.relPath, (error) => (error ? reject(error) : resolve()))) + } else { + const filename = `${itemTitle}.zip` + await zipHelpers.zipDirectoryPipe(itemPath, filename, res) + } + + Logger.info(`[ShareController] Downloaded item "${itemTitle}" at "${itemPath}"`) + } catch (error) { + Logger.error(`[ShareController] Download failed for item "${itemTitle}" at "${itemPath}"`, error) + res.status(500).send('Failed to download the item') + } + } + /** * Public route - requires share_session_id cookie * @@ -259,7 +319,7 @@ class ShareController { return res.sendStatus(403) } - const { slug, expiresAt, mediaItemType, mediaItemId } = req.body + const { slug, expiresAt, mediaItemType, mediaItemId, isDownloadable } = req.body if (!slug?.trim?.() || typeof mediaItemType !== 'string' || typeof mediaItemId !== 'string') { return res.status(400).send('Missing or invalid required fields') @@ -298,7 +358,8 @@ class ShareController { expiresAt: expiresAt || null, mediaItemId, mediaItemType, - userId: req.user.id + userId: req.user.id, + isDownloadable }) ShareManager.openMediaItemShare(mediaItemShare) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index f4992432..c2de4693 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -11,3 +11,4 @@ Please add a record of every database migration that you create to this file. Th | v2.17.3 | v2.17.3-fk-constraints | Changes the foreign key constraints for tables due to sequelize bug dropping constraints in v2.17.0 migration | | v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations | | 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 | diff --git a/server/migrations/v2.17.6-share-add-isdownloadable.js b/server/migrations/v2.17.6-share-add-isdownloadable.js new file mode 100644 index 00000000..9434d284 --- /dev/null +++ b/server/migrations/v2.17.6-share-add-isdownloadable.js @@ -0,0 +1,68 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.17.6' +const migrationName = `${migrationVersion}-share-add-isdownloadable` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This migration script adds the isDownloadable column to the mediaItemShares table. + * + * @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 } }) { + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists('mediaItemShares')) { + const tableDescription = await queryInterface.describeTable('mediaItemShares') + if (!tableDescription.isDownloadable) { + logger.info(`${loggerPrefix} Adding isDownloadable column to mediaItemShares table`) + await queryInterface.addColumn('mediaItemShares', 'isDownloadable', { + type: queryInterface.sequelize.Sequelize.DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false + }) + logger.info(`${loggerPrefix} Added isDownloadable column to mediaItemShares table`) + } else { + logger.info(`${loggerPrefix} isDownloadable column already exists in mediaItemShares table`) + } + } else { + logger.info(`${loggerPrefix} mediaItemShares table does not exist`) + } + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This migration script removes the isDownloadable column from the mediaItemShares table. + * + * @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 } }) { + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists('mediaItemShares')) { + const tableDescription = await queryInterface.describeTable('mediaItemShares') + if (tableDescription.isDownloadable) { + logger.info(`${loggerPrefix} Removing isDownloadable column from mediaItemShares table`) + await queryInterface.removeColumn('mediaItemShares', 'isDownloadable') + logger.info(`${loggerPrefix} Removed isDownloadable column from mediaItemShares table`) + } else { + logger.info(`${loggerPrefix} isDownloadable column does not exist in mediaItemShares table`) + } + } else { + logger.info(`${loggerPrefix} mediaItemShares table does not exist`) + } + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/server/models/MediaItemShare.js b/server/models/MediaItemShare.js index 38b8dbbf..2d7b3896 100644 --- a/server/models/MediaItemShare.js +++ b/server/models/MediaItemShare.js @@ -12,6 +12,7 @@ const { DataTypes, Model } = require('sequelize') * @property {Object} extraData * @property {Date} createdAt * @property {Date} updatedAt + * @property {boolean} isDownloadable * * @typedef {MediaItemShareObject & MediaItemShare} MediaItemShareModel */ @@ -25,11 +26,40 @@ const { DataTypes, Model } = require('sequelize') * @property {Date} expiresAt * @property {Date} createdAt * @property {Date} updatedAt + * @property {boolean} isDownloadable */ class MediaItemShare extends Model { constructor(values, options) { super(values, options) + + /** @type {UUIDV4} */ + this.id + /** @type {UUIDV4} */ + this.mediaItemId + /** @type {string} */ + this.mediaItemType + /** @type {string} */ + this.slug + /** @type {string} */ + this.pash + /** @type {UUIDV4} */ + this.userId + /** @type {Date} */ + this.expiresAt + /** @type {Object} */ + this.extraData + /** @type {Date} */ + this.createdAt + /** @type {Date} */ + this.updatedAt + /** @type {boolean} */ + this.isDownloadable + + // Expanded properties + + /** @type {import('./Book')|import('./PodcastEpisode')} */ + this.mediaItem } toJSONForClient() { @@ -40,7 +70,8 @@ class MediaItemShare extends Model { slug: this.slug, expiresAt: this.expiresAt, createdAt: this.createdAt, - updatedAt: this.updatedAt + updatedAt: this.updatedAt, + isDownloadable: this.isDownloadable } } @@ -114,7 +145,8 @@ class MediaItemShare extends Model { slug: DataTypes.STRING, pash: DataTypes.STRING, expiresAt: DataTypes.DATE, - extraData: DataTypes.JSON + extraData: DataTypes.JSON, + isDownloadable: DataTypes.BOOLEAN }, { sequelize, diff --git a/server/routers/PublicRouter.js b/server/routers/PublicRouter.js index 98ac4955..107edf99 100644 --- a/server/routers/PublicRouter.js +++ b/server/routers/PublicRouter.js @@ -15,6 +15,7 @@ class PublicRouter { this.router.get('/share/:slug', ShareController.getMediaItemShareBySlug.bind(this)) this.router.get('/share/:slug/track/:index', ShareController.getMediaItemShareAudioTrack.bind(this)) this.router.get('/share/:slug/cover', ShareController.getMediaItemShareCoverImage.bind(this)) + this.router.get('/share/:slug/download', ShareController.downloadMediaItemShare.bind(this)) this.router.patch('/share/:slug/progress', ShareController.updateMediaItemShareProgress.bind(this)) } } diff --git a/test/server/migrations/v2.17.6-share-add-isdownloadable.test.js b/test/server/migrations/v2.17.6-share-add-isdownloadable.test.js new file mode 100644 index 00000000..6c778b14 --- /dev/null +++ b/test/server/migrations/v2.17.6-share-add-isdownloadable.test.js @@ -0,0 +1,68 @@ +const chai = require('chai') +const sinon = require('sinon') +const { expect } = chai + +const { DataTypes } = require('sequelize') + +const { up, down } = require('../../../server/migrations/v2.17.6-share-add-isdownloadable') + +describe('Migration v2.17.6-share-add-isDownloadable', () => { + let queryInterface, logger + + beforeEach(() => { + queryInterface = { + addColumn: sinon.stub().resolves(), + removeColumn: sinon.stub().resolves(), + tableExists: sinon.stub().resolves(true), + describeTable: sinon.stub().resolves({ isDownloadable: undefined }), + sequelize: { + Sequelize: { + DataTypes: { + BOOLEAN: DataTypes.BOOLEAN + } + } + } + } + + logger = { + info: sinon.stub(), + error: sinon.stub() + } + }) + + describe('up', () => { + it('should add the isDownloadable column to mediaItemShares table', async () => { + await up({ context: { queryInterface, logger } }) + + expect(queryInterface.addColumn.calledOnce).to.be.true + expect( + queryInterface.addColumn.calledWith('mediaItemShares', 'isDownloadable', { + type: DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false + }) + ).to.be.true + + expect(logger.info.calledWith('[2.17.6 migration] UPGRADE BEGIN: 2.17.6-share-add-isdownloadable')).to.be.true + expect(logger.info.calledWith('[2.17.6 migration] Adding isDownloadable column to mediaItemShares table')).to.be.true + expect(logger.info.calledWith('[2.17.6 migration] Added isDownloadable column to mediaItemShares table')).to.be.true + expect(logger.info.calledWith('[2.17.6 migration] UPGRADE END: 2.17.6-share-add-isdownloadable')).to.be.true + }) + }) + + describe('down', () => { + it('should remove the isDownloadable column from mediaItemShares table', async () => { + queryInterface.describeTable.resolves({ isDownloadable: true }) + + await down({ context: { queryInterface, logger } }) + + expect(queryInterface.removeColumn.calledOnce).to.be.true + expect(queryInterface.removeColumn.calledWith('mediaItemShares', 'isDownloadable')).to.be.true + + expect(logger.info.calledWith('[2.17.6 migration] DOWNGRADE BEGIN: 2.17.6-share-add-isdownloadable')).to.be.true + expect(logger.info.calledWith('[2.17.6 migration] Removing isDownloadable column from mediaItemShares table')).to.be.true + expect(logger.info.calledWith('[2.17.6 migration] Removed isDownloadable column from mediaItemShares table')).to.be.true + expect(logger.info.calledWith('[2.17.6 migration] DOWNGRADE END: 2.17.6-share-add-isdownloadable')).to.be.true + }) + }) +}) From 6928f6eeb657a2a394555faa97ae0f7c8d5866b5 Mon Sep 17 00:00:00 2001 From: jonarihen Date: Thu, 19 Dec 2024 18:34:31 +0000 Subject: [PATCH 04/52] Translated using Weblate (Danish) Currently translated at 62.3% (673 of 1080 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/da/ --- client/strings/da.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/strings/da.json b/client/strings/da.json index b4b92bc8..0f7af2c4 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -19,6 +19,7 @@ "ButtonChooseFiles": "Vælg filer", "ButtonClearFilter": "Ryd filter", "ButtonCloseFeed": "Luk feed", + "ButtonCloseSession": "Luk Åben Session", "ButtonCollections": "Samlinger", "ButtonConfigureScanner": "Konfigurer scanner", "ButtonCreate": "Opret", @@ -29,7 +30,9 @@ "ButtonEditChapters": "Rediger kapitler", "ButtonEditPodcast": "Rediger podcast", "ButtonEnable": "Aktiver", - "ButtonForceReScan": "Tvungen genindlæsning", + "ButtonFireAndFail": "Affyring Og Fejl", + "ButtonFireOnTest": "Affyring vedTest begivenhed", + "ButtonForceReScan": "Tving genindlæsning", "ButtonFullPath": "Fuld sti", "ButtonHide": "Skjul", "ButtonHome": "Hjem", From 20e0172fa3b4b33d507274bf5440165963da559d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98ystein=20S=2E=20Hegnander?= Date: Thu, 19 Dec 2024 21:45:35 +0000 Subject: [PATCH 05/52] =?UTF-8?q?Translated=20using=20Weblate=20(Norwegian?= =?UTF-8?q?=20Bokm=C3=A5l)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 82.3% (889 of 1080 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nb_NO/ --- client/strings/no.json | 238 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 217 insertions(+), 21 deletions(-) diff --git a/client/strings/no.json b/client/strings/no.json index 592991b4..96de90d9 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -29,13 +29,16 @@ "ButtonEdit": "Rediger", "ButtonEditChapters": "Rediger kapittel", "ButtonEditPodcast": "Rediger podcast", + "ButtonEnable": "Aktiver", + "ButtonFireAndFail": "Kjør ved feil", + "ButtonFireOnTest": "Kjør onTest-kommando", "ButtonForceReScan": "Tving skann", "ButtonFullPath": "Full sti", "ButtonHide": "Gjøm", "ButtonHome": "Hjem", "ButtonIssues": "Problemer", - "ButtonJumpBackward": "Hopp Bakover", - "ButtonJumpForward": "Hopp Fremover", + "ButtonJumpBackward": "Hopp bakover", + "ButtonJumpForward": "Hopp frem", "ButtonLatest": "Siste", "ButtonLibrary": "Bibliotek", "ButtonLogout": "Logg ut", @@ -45,24 +48,31 @@ "ButtonMatchAllAuthors": "Søk opp alle forfattere", "ButtonMatchBooks": "Søk opp bøker", "ButtonNevermind": "Avbryt", + "ButtonNext": "Neste", "ButtonNextChapter": "Neste Kapittel", + "ButtonNextItemInQueue": "Neste element i køen", + "ButtonOk": "Ok", "ButtonOpenFeed": "Åpne Feed", "ButtonOpenManager": "Åpne behandler", + "ButtonPause": "Pause", "ButtonPlay": "Spill av", + "ButtonPlayAll": "Spill av alle", "ButtonPlaying": "Spiller av", "ButtonPlaylists": "Spillelister", "ButtonPrevious": "Forrige", "ButtonPreviousChapter": "Forrige Kapittel", + "ButtonProbeAudioFile": "Analyser lydfil", "ButtonPurgeAllCache": "Tøm alle mellomlager", "ButtonPurgeItemsCache": "Tøm mellomlager", "ButtonQueueAddItem": "Legg til kø", "ButtonQueueRemoveItem": "Fjern fra kø", - "ButtonQuickEmbedMetadata": "Hurtig Innbygging Av Metadata", + "ButtonQuickEmbed": "Hurtiginnbygging", + "ButtonQuickEmbedMetadata": "Bygg inn metadata", "ButtonQuickMatch": "Kjapt søk", "ButtonReScan": "Skann på nytt", "ButtonRead": "Les", - "ButtonReadLess": "Les Mindre", - "ButtonReadMore": "Les Mer", + "ButtonReadLess": "Vis mindre", + "ButtonReadMore": "Vis mer", "ButtonRefresh": "Oppdater", "ButtonRemove": "Fjern", "ButtonRemoveAll": "Fjern alle", @@ -71,12 +81,15 @@ "ButtonRemoveFromContinueReading": "Fjern fra Fortsett å lese", "ButtonRemoveSeriesFromContinueSeries": "Fjern serie fra Fortsett serie", "ButtonReset": "Nullstill", + "ButtonResetToDefault": "Tilbakestill til standard", "ButtonRestore": "Gjenopprett", "ButtonSave": "Lagre", "ButtonSaveAndClose": "Lagre og lukk", "ButtonSaveTracklist": "Lagre spilleliste", "ButtonScan": "Skann", "ButtonScanLibrary": "Skann bibliotek", + "ButtonScrollLeft": "Rull til venstre", + "ButtonScrollRight": "Rull til høyre", "ButtonSearch": "Søk", "ButtonSelectFolderPath": "Velg mappe", "ButtonSeries": "Serier", @@ -88,20 +101,26 @@ "ButtonStartMetadataEmbed": "Start Metadata innbaking", "ButtonStats": "Statistikk", "ButtonSubmit": "Send inn", + "ButtonTest": "Test", + "ButtonUnlinkOpenId": "Koble fra OpenID", "ButtonUpload": "Last opp", "ButtonUploadBackup": "Last opp sikkerhetskopi", "ButtonUploadCover": "Last opp cover", "ButtonUploadOPMLFile": "Last opp OPML fil", "ButtonUserDelete": "Slett bruker {0}", "ButtonUserEdit": "Rediger bruker {0}", - "ButtonViewAll": "Vis alt", + "ButtonViewAll": "Vis alle", "ButtonYes": "Ja", "ErrorUploadFetchMetadataAPI": "Feil ved innhenting av metadata", + "ErrorUploadFetchMetadataNoResults": "Kunne ikke hente metadata - forsøk å oppdatere tittel og/eller forfatter", + "ErrorUploadLacksTitle": "Tittel kreves", "HeaderAccount": "Konto", + "HeaderAddCustomMetadataProvider": "Legg til egendefinert metadata tilbyder", "HeaderAdvanced": "Avansert", - "HeaderAppriseNotificationSettings": "Apprise notifikasjonsinstillinger", + "HeaderAppriseNotificationSettings": "Apprise varslingsinstillinger", "HeaderAudioTracks": "Lydspor", "HeaderAudiobookTools": "Lydbok Filbehandlingsverktøy", + "HeaderAuthentication": "Autentisering", "HeaderBackups": "Sikkerhetskopier", "HeaderChangePassword": "Bytt passord", "HeaderChapters": "Kapittel", @@ -110,6 +129,8 @@ "HeaderCollectionItems": "Samlingsgjenstander", "HeaderCover": "Omslag", "HeaderCurrentDownloads": "Aktive nedlastinger", + "HeaderCustomMessageOnLogin": "Egendefinert melding ved pålogging", + "HeaderCustomMetadataProviders": "Egendefinerte metadata tilbydere", "HeaderDetails": "Detaljer", "HeaderDownloadQueue": "Last ned kø", "HeaderEbookFiles": "Ebook filer", @@ -140,12 +161,17 @@ "HeaderMetadataToEmbed": "Metadata å bake inn", "HeaderNewAccount": "Ny konto", "HeaderNewLibrary": "Ny bibliotek", - "HeaderNotifications": "Notifikasjoner", + "HeaderNotificationCreate": "Opprett varsling", + "HeaderNotificationUpdate": "Oppdater varsling", + "HeaderNotifications": "Varslinger", "HeaderOpenIDConnectAuthentication": "Autentisering med OpenID Connect", + "HeaderOpenListeningSessions": "Åpne lyttesesjoner", "HeaderOpenRSSFeed": "Åpne RSS Feed", "HeaderOtherFiles": "Andre filer", + "HeaderPasswordAuthentication": "Logg inn med brukernavn og passord", "HeaderPermissions": "Rettigheter", "HeaderPlayerQueue": "Spiller kø", + "HeaderPlayerSettings": "Avspillingsinnstillinger", "HeaderPlaylist": "Spilleliste", "HeaderPlaylistItems": "Spillelisteelement", "HeaderPodcastsToAdd": "Podcaster å legge til", @@ -157,6 +183,7 @@ "HeaderRemoveEpisodes": "Fjern {0} episoder", "HeaderSavedMediaProgress": "Lagret mediefremgang", "HeaderSchedule": "Timeplan", + "HeaderScheduleEpisodeDownloads": "Planlegg automatisk nedlasting av episoder", "HeaderScheduleLibraryScans": "Planlegg automatisk bibliotek skann", "HeaderSession": "Sesjon", "HeaderSetBackupSchedule": "Sett timeplan for sikkerhetskopi", @@ -165,6 +192,7 @@ "HeaderSettingsExperimental": "Eksperimentelle funksjoner", "HeaderSettingsGeneral": "Generell", "HeaderSettingsScanner": "Skanner", + "HeaderSettingsWebClient": "Webklient", "HeaderSleepTimer": "Sove timer", "HeaderStatsLargestItems": "Største enheter", "HeaderStatsLongestItems": "Lengste enheter (timer)", @@ -179,9 +207,14 @@ "HeaderUpdateDetails": "Oppdater detaljer", "HeaderUpdateLibrary": "Oppdater bibliotek", "HeaderUsers": "Brukere", + "HeaderYearReview": "{0} oppsummert", "HeaderYourStats": "Din statistikk", "LabelAbridged": "Forkortet", + "LabelAbridgedChecked": "Forkortet (valgt)", + "LabelAbridgedUnchecked": "Forkortet (ikke valgt)", + "LabelAccessibleBy": "Tilgjengelig via", "LabelAccountType": "Kontotype", + "LabelAccountTypeAdmin": "Administrator", "LabelAccountTypeGuest": "Gjest", "LabelAccountTypeUser": "Bruker", "LabelActivity": "Aktivitet", @@ -190,32 +223,55 @@ "LabelAddToPlaylist": "Legg til i spilleliste", "LabelAddToPlaylistBatch": "Legg {0} enheter til i spilleliste", "LabelAddedAt": "Lagt Til", + "LabelAddedDate": "La til {0}", + "LabelAdminUsersOnly": "Kun administratorer", "LabelAll": "Alle", "LabelAllUsers": "Alle brukere", + "LabelAllUsersExcludingGuests": "Alle brukere bortsett fra gjester", + "LabelAllUsersIncludingGuests": "Alle brukere inkludert gjester", "LabelAlreadyInYourLibrary": "Allerede i biblioteket", + "LabelApiToken": "API token", "LabelAppend": "Legge til", + "LabelAudioBitrate": "Bitrate for lyd (f.eks. 128k)", + "LabelAudioChannels": "Lydkanaler (1 eller 2)", + "LabelAudioCodec": "Audio Codec", "LabelAuthor": "Forfatter", "LabelAuthorFirstLast": "Forfatter (Fornavn Etternavn)", "LabelAuthorLastFirst": "Forfatter (Etternavn Fornavn)", "LabelAuthors": "Forfattere", "LabelAutoDownloadEpisodes": "Last ned episoder automatisk", + "LabelAutoFetchMetadata": "Automatisk henting av metadata", + "LabelAutoFetchMetadataHelp": "Henter metadata for tittel, forfatter og serie for å optimalisere opplasting. Ekstra metadata må kanskje bekreftes etter opplasting.", + "LabelAutoLaunch": "Autostart", + "LabelAutoLaunchDescription": "Omdiriger til leverandør for innlogging automatisk når innloggingssiden åpnes. (Kan overstyres med /login?autoLaunch=0)", + "LabelAutoRegister": "Automatisk registrering", + "LabelAutoRegisterDescription": "Lag bruker automatisk ved første innlogging", "LabelBackToUser": "Tilbake til bruker", + "LabelBackupAudioFiles": "Sikkerhetskopier lydfiler", + "LabelBackupLocation": "Mappe for sikkerhetskopiering", "LabelBackupsEnableAutomaticBackups": "Aktiver automatisk sikkerhetskopi", "LabelBackupsEnableAutomaticBackupsHelp": "Sikkerhetskopier lagret under /metadata/backups", - "LabelBackupsMaxBackupSize": "Maks sikkerhetskopi størrelse (i GB)", + "LabelBackupsMaxBackupSize": "Maksimal størrelse for sikkerhetskopi (i GB) (0 for ubegrenset)", "LabelBackupsMaxBackupSizeHelp": "For å forhindre feilkonfigurasjon, vil sikkerhetskopier mislykkes hvis de oveskride konfigurert størrelse.", "LabelBackupsNumberToKeep": "Antall sikkerhetskopier som skal beholdes", "LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhetskopi vil bli fjernet om gangen, hvis du allerede har flere sikkerhetskopier enn dette bør du fjerne de manuelt.", "LabelBitrate": "Bithastighet", + "LabelBonus": "Bonus", "LabelBooks": "Bøker", + "LabelButtonText": "Tekst på knappen", + "LabelByAuthor": "av {0}", "LabelChangePassword": "Endre passord", "LabelChannels": "Kanaler", + "LabelChapterCount": "{0} kapitler", "LabelChapterTitle": "Kapittel tittel", "LabelChapters": "Kapitler", "LabelChaptersFound": "kapitler funnet", + "LabelClickForMoreInfo": "Klikk for mer informasjon", + "LabelClickToUseCurrentValue": "Klikk for å bruke valgt verdi", "LabelClosePlayer": "Lukk spiller", "LabelCodec": "Kodek", "LabelCollapseSeries": "Minimer serier", + "LabelCollapseSubSeries": "Skjul underserier", "LabelCollection": "Samling", "LabelCollections": "Samlings", "LabelComplete": "Fullfør", @@ -232,58 +288,94 @@ "LabelCustomCronExpression": "Tilpasset Cron utrykk:", "LabelDatetime": "Dato tid", "LabelDays": "Dager", + "LabelDeleteFromFileSystemCheckbox": "Slett fra filsystemet (fjern haken for kun å ta bort fra databasen)", "LabelDescription": "Beskrivelse", "LabelDeselectAll": "Fjern valg", "LabelDevice": "Enhet", "LabelDeviceInfo": "Enhetsinformasjon", + "LabelDeviceIsAvailableTo": "Enheten er tilgjengelig for...", "LabelDirectory": "Mappe", "LabelDiscFromFilename": "Disk fra filnavn", "LabelDiscFromMetadata": "Disk fra metadata", - "LabelDiscover": "Oppdagelse", + "LabelDiscover": "Oppdag", "LabelDownload": "Last ned", "LabelDownloadNEpisodes": "Last ned {0} episoder", "LabelDuration": "Varighet", + "LabelDurationComparisonExactMatch": "(nøyaktig treff)", + "LabelDurationComparisonLonger": "({0} lenger)", + "LabelDurationComparisonShorter": "({0} kortere)", "LabelDurationFound": "Varighet funnet:", "LabelEbook": "Ebok", "LabelEbooks": "E-bøker", "LabelEdit": "Rediger", "LabelEmail": "Epost", "LabelEmailSettingsFromAddress": "Fra Adresse", + "LabelEmailSettingsRejectUnauthorized": "Avvis uautoriserte sertifikat", + "LabelEmailSettingsRejectUnauthorizedHelp": "Ved å deaktivere sjekk av SSL sertifikat eksponerer man tilkoblingen for sikkerhetsrisiko, som for eksempel mann-i-midten-angrep. Slå av kun om du forstår implikasjonene og stoler på e-post-serveren du kobler til!", "LabelEmailSettingsSecure": "Sikker", "LabelEmailSettingsSecureHelp": "Hvis aktivert, vil tilkoblingen bruke TLS under tilkobling til tjeneren. Ellers vil TLS bli brukt hvis tjeneren støtter STARTTLS utvidelsen. I de fleste tilfeller aktiver valget hvis du kobler til med port 465. Med port 587 eller 25 deaktiver valget. (fra nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Test Adresse", "LabelEmbeddedCover": "Bak inn omslag", "LabelEnable": "Aktiver", + "LabelEncodingBackupLocation": "En sikkerhetskopi av de originale lyd-filene lagres i mappen:", + "LabelEncodingChaptersNotEmbedded": "Kapitler er ikke bygget inn i flersporede lydbøker.", + "LabelEncodingClearItemCache": "Husk å tømme mellomlageret med jevne mellomrom.", + "LabelEncodingFinishedM4B": "Ferdig konvertert M4B-lydbøker legges i lydbok-mappen:", + "LabelEncodingInfoEmbedded": "Metadata bygges inn i lydsporene i lydbokmappen.", + "LabelEncodingStartedNavigation": "Så snart oppgaven er startet kan du navigere bort fra denne siden.", + "LabelEncodingTimeWarning": "Konvertering kan ta opptil 30 minutter.", + "LabelEncodingWarningAdvancedSettings": "Advarsel: Ikke oppdater disse innstillingene med mindre du er godt kjent med hvordan ffmpeg og konverteringsvalgene fungerer.", + "LabelEncodingWatcherDisabled": "Hvis du har slått av overvåking så må du skanne dette biblioteket på nytt etterpå.", "LabelEnd": "Slutt", "LabelEndOfChapter": "Slutt på kapittel", + "LabelEpisode": "Episode", + "LabelEpisodeNotLinkedToRssFeed": "Episode er ikke koblet til en RSS feed", + "LabelEpisodeNumber": "Episode #{0}", "LabelEpisodeTitle": "Episode tittel", "LabelEpisodeType": "Episode type", + "LabelEpisodeUrlFromRssFeed": "Episode URL fra RSS feed", + "LabelEpisodes": "Episoder", + "LabelEpisodic": "Episodisk", "LabelExample": "Eksempel", + "LabelExpandSeries": "Vis serie", + "LabelExpandSubSeries": "Vis underserie", "LabelExplicit": "Eksplisitt", + "LabelExplicitChecked": "Eksplisitt (avhuket)", + "LabelExplicitUnchecked": "Ikke eksplisitt (ikke avhuket)", "LabelExportOPML": "Eksporter OPML", "LabelFeedURL": "Feed Adresse", + "LabelFetchingMetadata": "Henter metadata", "LabelFile": "Fil", "LabelFileBirthtime": "Fil Opprettelsesdato", + "LabelFileBornDate": "Født {0}", "LabelFileModified": "Fil Endret", + "LabelFileModifiedDate": "Redigert {0}", "LabelFilename": "Filnavn", "LabelFilterByUser": "Filtrer etter bruker", "LabelFindEpisodes": "Finn episoder", "LabelFinished": "Fullført", "LabelFolder": "Mappe", "LabelFolders": "Mapper", + "LabelFontBold": "Fet", "LabelFontBoldness": "Skrifttykkelse", "LabelFontFamily": "Fontfamilie", + "LabelFontItalic": "Kursiv", "LabelFontScale": "Font størrelse", + "LabelFontStrikethrough": "Gjennomstreking", + "LabelFormat": "Format", + "LabelFull": "Full", "LabelGenre": "Sjanger", "LabelGenres": "Sjangers", "LabelHardDeleteFile": "Tving sletting av fil", "LabelHasEbook": "Har e-bok", "LabelHasSupplementaryEbook": "Har komplimentær e-bok", "LabelHideSubtitles": "Skjul undertekster", + "LabelHighestPriority": "Høyeste prioritet", "LabelHost": "Tjener", "LabelHour": "Time", "LabelHours": "Timer", "LabelIcon": "Ikon", + "LabelImageURLFromTheWeb": "Bilde-URL fra nett", "LabelInProgress": "I gang", "LabelIncludeInTracklist": "Inkluder i sporliste", "LabelIncomplete": "Ufullstendig", @@ -298,8 +390,11 @@ "LabelIntervalEveryHour": "Hver time", "LabelInvert": "Inverter", "LabelItem": "Enhet", + "LabelJumpBackwardAmount": "Hopp bakover med", + "LabelJumpForwardAmount": "Hopp forover med", "LabelLanguage": "Språk", "LabelLanguageDefaultServer": "Standard tjener språk", + "LabelLanguages": "Språk", "LabelLastBookAdded": "Siste bok lagt til", "LabelLastBookUpdated": "Siste bok oppdatert", "LabelLastSeen": "Sist sett", @@ -316,12 +411,30 @@ "LabelLimit": "Begrensning", "LabelLineSpacing": "Linjemellomrom", "LabelListenAgain": "Lytt igjen", + "LabelLogLevelDebug": "Debug", + "LabelLogLevelInfo": "Info", + "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Se etter nye episoder etter denne datoen", + "LabelLowestPriority": "Laveste prioritet", + "LabelMatchExistingUsersBy": "Knytt sammen eksisterende brukere basert på", + "LabelMatchExistingUsersByDescription": "Brukes for å koble til eksisterende brukere. Når koblingen er i orden vil brukerne bli identifisert med en unik id fra SSO-tilbyderen.", + "LabelMaxEpisodesToDownload": "Maksimalt antall episoder som skal lastes ned. Bruk 0 for ubegrenset.", + "LabelMaxEpisodesToDownloadPerCheck": "Maksimalt antall nye episoder som skal lastes ned per sjekk", + "LabelMaxEpisodesToKeep": "Maksimalt antall episoder som skal beholdes", + "LabelMaxEpisodesToKeepHelp": "Sett verdien til null (0) for ubegrenset. Etter at en episode lastes ned automatisk, så slettes den eldste episoden, om du har mer enn X episoder. Det slettes kun én episode per nye nedlasting.", "LabelMediaPlayer": "Mediespiller", "LabelMediaType": "Medie type", + "LabelMetaTag": "Meta tag", + "LabelMetaTags": "Meta tags", + "LabelMetadataOrderOfPrecedenceDescription": "Høyere prioritert kilder for metadata overstyrer laverer prioriterte kilder for metadata.", "LabelMetadataProvider": "Metadata Leverandør", "LabelMinute": "Minutt", + "LabelMinutes": "Minutter", "LabelMissing": "Mangler", + "LabelMissingEbook": "Har ingen e-bok", + "LabelMissingSupplementaryEbook": "Har ingen komplementær e-bok", + "LabelMobileRedirectURIs": "Tillatte URL-er for vidersending", + "LabelMobileRedirectURIsDescription": "Dette er en liste over godkjente videresendings-URL-er for mobil-apper. Standarden er audiobookshelf://oauth, som du kan fjerne eller supplere med ekstra URL-er for tredjeparts app-integrasjoner. For å tillate alle URL-er kan du bruke kun en (*) .", "LabelMore": "Mer", "LabelMoreInfo": "Mer info", "LabelName": "Navn", @@ -333,6 +446,7 @@ "LabelNewestEpisodes": "Nyeste episoder", "LabelNextBackupDate": "Neste sikkerhetskopi dato", "LabelNextScheduledRun": "Neste planlagte kjøring", + "LabelNoCustomMetadataProviders": "Ingen egendefinerte tilbydere for metadata", "LabelNoEpisodesSelected": "Ingen episoder valgt", "LabelNotFinished": "Ikke fullført", "LabelNotStarted": "Ikke startet", @@ -340,66 +454,95 @@ "LabelNotificationAppriseURL": "Apprise URL(er)", "LabelNotificationAvailableVariables": "Tilgjengelige variabler", "LabelNotificationBodyTemplate": "Kroppsmal", - "LabelNotificationEvent": "Notifikasjons hendelse", + "LabelNotificationEvent": "Varsling", "LabelNotificationTitleTemplate": "Tittel mal", "LabelNotificationsMaxFailedAttempts": "Maks mislykkede forsøk", - "LabelNotificationsMaxFailedAttemptsHelp": "Notifikasjoner er deaktivert når de mislykkes på sende dette flere ganger", - "LabelNotificationsMaxQueueSize": "Maks kø lengde for Notifikasjonshendelser", - "LabelNotificationsMaxQueueSizeHelp": "Hendelser er begrenset til avfyre 1 gang per sekund. Hendelser vil bli ignorert om køen er full. Dette forhindrer Notifikasjon spam.", + "LabelNotificationsMaxFailedAttemptsHelp": "Varslinger deaktiveres når sending feiles dette antallet ganger", + "LabelNotificationsMaxQueueSize": "Maksimalt antall varslinger i kø", + "LabelNotificationsMaxQueueSizeHelp": "Hendelser er begrenset til avfyre én gang per sekund. Hendelser blir ignorert om køen er full. Dette forhindrer overflod av varslinger.", "LabelNumberOfBooks": "Antall bøker", "LabelNumberOfEpisodes": "Antall episoder", + "LabelOpenIDClaims": "La følge valg være tomme for å slå av avanserte gruppe og tillatelser. Gruppen \"Bruker\" vil da også automatisk legges til.", "LabelOpenRSSFeed": "Åpne RSS Feed", "LabelOverwrite": "Overskriv", + "LabelPaginationPageXOfY": "Side {0} av {1}", "LabelPassword": "Passord", "LabelPath": "Sti", "LabelPermanent": "Fast", "LabelPermissionsAccessAllLibraries": "Har tilgang til alle bibliotek", "LabelPermissionsAccessAllTags": "Har til gang til alle tags", "LabelPermissionsAccessExplicitContent": "Har tilgang til eksplisitt material", + "LabelPermissionsCreateEreader": "Kan opprette e-leser", "LabelPermissionsDelete": "Kan slette", "LabelPermissionsDownload": "Kan laste ned", "LabelPermissionsUpdate": "Kan oppdatere", "LabelPermissionsUpload": "Kan laste opp", + "LabelPersonalYearReview": "Oppsummering av året ditt ({0})", "LabelPhotoPathURL": "Bilde sti/URL", "LabelPlayMethod": "Avspillingsmetode", + "LabelPlayerChapterNumberMarker": "{0} av {1}", "LabelPlaylists": "Spilleliste", + "LabelPodcast": "Podcast", "LabelPodcastSearchRegion": "Podcast-søkeområde", "LabelPodcastType": "Podcast type", "LabelPodcasts": "Podcaster", + "LabelPort": "Port", "LabelPrefixesToIgnore": "Prefiks som skal ignoreres (skiller ikke mellom store og små bokstaver)", "LabelPreventIndexing": "Forhindre at din feed fra å bli indeksert av iTunes og Google podcast kataloger", "LabelPrimaryEbook": "Primær ebok", "LabelProgress": "Framgang", "LabelProvider": "Tilbyder", + "LabelProviderAuthorizationValue": "Autorisasjons header-verdi", "LabelPubDate": "Publiseringsdato", "LabelPublishYear": "Publikasjonsår", + "LabelPublishedDate": "Publisert {0}", + "LabelPublishedDecade": "Tiår for utgivelse", + "LabelPublishedDecades": "Tiår for utgivelse", "LabelPublisher": "Forlegger", + "LabelPublishers": "Utgivere", "LabelRSSFeedCustomOwnerEmail": "Tilpasset eier e-post", "LabelRSSFeedCustomOwnerName": "Tilpasset eier Navn", "LabelRSSFeedOpen": "RSS Feed åpne", "LabelRSSFeedPreventIndexing": "Forhindre indeksering", - "LabelRSSFeedSlug": "RSS-informasjonskanalunderadresse", + "LabelRSSFeedSlug": "RSS-feed ID", + "LabelRSSFeedURL": "RSS-feed URL", + "LabelRandomly": "Tilfeldig", + "LabelReAddSeriesToContinueListening": "Legg til igjen til \"Fortsett å lytte\"", "LabelRead": "Les", "LabelReadAgain": "Les igjen", "LabelReadEbookWithoutProgress": "Les ebok uten å beholde fremgang", "LabelRecentSeries": "Nylige serier", "LabelRecentlyAdded": "Nylig tillagt", "LabelRecommended": "Anbefalte", + "LabelRedo": "Gjenta", + "LabelRegion": "Region", "LabelReleaseDate": "Utgivelsesdato", + "LabelRemoveAllMetadataAbs": "Fjern alle metadata.abs filer", + "LabelRemoveAllMetadataJson": "Fjern alle metadata.json filer", "LabelRemoveCover": "Fjern omslag", + "LabelRemoveMetadataFile": "Fjern metadata-filer fra biblioteks-mapper", + "LabelRemoveMetadataFileHelp": "Fjern alle metadata.json og metadata.abs i alle {0} mappene.", + "LabelRowsPerPage": "Rader per side", "LabelSearchTerm": "Søkeord", "LabelSearchTitle": "Søk tittel", "LabelSearchTitleOrASIN": "Søk tittel eller ASIN", "LabelSeason": "Sesong", + "LabelSeasonNumber": "Sesong #{0}", + "LabelSelectAll": "Velg alt", "LabelSelectAllEpisodes": "Velg alle episoder", "LabelSelectEpisodesShowing": "Velg {0} episoder vist", + "LabelSelectUsers": "Velg brukere", "LabelSendEbookToDevice": "Send Ebok til...", "LabelSequence": "Sekvens", + "LabelSerial": "Serienr.", "LabelSeries": "Serier", "LabelSeriesName": "Serier Navn", "LabelSeriesProgress": "Serier fremgang", + "LabelServerLogLevel": "Server logg-nivå", + "LabelServerYearReview": "Server - Oppsummering av året ({0})", "LabelSetEbookAsPrimary": "Sett som primær", "LabelSetEbookAsSupplementary": "Sett som supplerende", + "LabelSettingsAllowIframe": "Tillat å bygge inn i en iframe", "LabelSettingsAudiobooksOnly": "Kun lydbøker", "LabelSettingsAudiobooksOnlyHelp": "Aktivering av dette valget til ignorere ebok filer utenom de er i en lydbok mappe hvor de vil bli satt som supplerende ebøker", "LabelSettingsBookshelfViewHelp": "Skeuomorf design med hyller av ved", @@ -411,6 +554,8 @@ "LabelSettingsEnableWatcher": "Aktiver overvåker", "LabelSettingsEnableWatcherForLibrary": "Aktiver mappe overvåker for bibliotek", "LabelSettingsEnableWatcherHelp": "Aktiverer automatisk opprettelse/oppdatering av enheter når filendringer er oppdaget. *Krever restart av server*", + "LabelSettingsEpubsAllowScriptedContent": "Tillat scripting i innholdet i ebub-bøker", + "LabelSettingsEpubsAllowScriptedContentHelp": "Tillat epub-filer å kjøre script. Det er anbefalt å slå av denne innstillingen med mindre du stoler på kilden til epub-filene.", "LabelSettingsExperimentalFeatures": "Eksperimentelle funksjoner", "LabelSettingsExperimentalFeaturesHelp": "Funksjoner under utvikling som kan trenge din tilbakemelding og hjelp med testing. Klikk for å åpne GitHub diskusjon.", "LabelSettingsFindCovers": "Finn omslag", @@ -419,8 +564,12 @@ "LabelSettingsHideSingleBookSeriesHelp": "Serier som har kun en bok vil bli gjemt på serie- og hjemmeside hyllen.", "LabelSettingsHomePageBookshelfView": "Hjemmeside bruk bokhyllevisning", "LabelSettingsLibraryBookshelfView": "Bibliotek bruk bokhyllevisning", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Prosent ferdig er større enn", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Gjenværende tid er mindre enn (sekunder)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Marker som ferdig når", + "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Hopp over tidligere bøker i \"Fortsett serien\"", "LabelSettingsParseSubtitles": "Analyser undertekster", - "LabelSettingsParseSubtitlesHelp": "Trekk ut undertekster fra lydbok mappenavn.
undertekster må være separert med \" - \"
f.eks. \"Boktittel - Undertekst her\" har Undertekst \"Undertekst her\"", + "LabelSettingsParseSubtitlesHelp": "Hent undertittel fra lydbokens mappenavn.
Undertittel må være separert med \" - \"
f.eks. \"Boktittel - En lengre tittel\" har undertittel \"En lengre tittel\".", "LabelSettingsPreferMatchedMetadata": "Foretrekk funnet metadata", "LabelSettingsPreferMatchedMetadataHelp": "Funnet data vil overskrive enhetens detaljene når man bruker Kjapt søk. Som standard vil Kjapt søk kun fylle inn manglende detaljer.", "LabelSettingsSkipMatchingBooksWithASIN": "Hopp over bøker som allerede har ASIN", @@ -435,10 +584,17 @@ "LabelSettingsStoreMetadataWithItemHelp": "Som standard vil metadata bli lagret under /metadata/items, aktiveres dette valget vil metadata bli lagret i samme mappe som gjenstanden", "LabelSettingsTimeFormat": "Tid format", "LabelShare": "Dele", + "LabelShareOpen": "Åpne deling", "LabelShareURL": "Dele URL", - "LabelShowAll": "Vis alt", + "LabelShowAll": "Vis alle", + "LabelShowSeconds": "Vis sekunder", + "LabelShowSubtitles": "Vis undertitler", "LabelSize": "Størrelse", "LabelSleepTimer": "Sove-timer", + "LabelSlug": "Slug", + "LabelSortAscending": "Stigende", + "LabelSortDescending": "Synkende", + "LabelStart": "Start", "LabelStartTime": "Start Tid", "LabelStarted": "Startet", "LabelStartedAt": "Startet", @@ -459,15 +615,24 @@ "LabelStatsWeekListening": "Uker lyttet", "LabelSubtitle": "undertekster", "LabelSupportedFileTypes": "Støttede filtyper", + "LabelTag": "Tag", "LabelTags": "Tagger", "LabelTagsAccessibleToUser": "Tagger tilgjengelig for bruker", "LabelTagsNotAccessibleToUser": "Tagger ikke tilgjengelig for bruker", "LabelTasks": "Oppgaver som kjører", + "LabelTextEditorBulletedList": "Punkt-liste", + "LabelTextEditorLink": "Link", + "LabelTextEditorNumberedList": "Nummerert liste", + "LabelTextEditorUnlink": "Fjern link", "LabelTheme": "Tema", "LabelThemeDark": "Mørk", "LabelThemeLight": "Lys", "LabelTimeBase": "Tidsbase", + "LabelTimeDurationXHours": "{0} timer", + "LabelTimeDurationXMinutes": "{0} minutter", + "LabelTimeDurationXSeconds": "{0} sekunder", "LabelTimeInMinutes": "Timer i minutter", + "LabelTimeLeft": "{0} gjenstår", "LabelTimeListened": "Tid lyttet", "LabelTimeListenedToday": "Tid lyttet idag", "LabelTimeRemaining": "{0} gjennstående", @@ -475,6 +640,7 @@ "LabelTitle": "Tittel", "LabelToolsEmbedMetadata": "Bak inn metadata", "LabelToolsEmbedMetadataDescription": "Bak inn metadata i lydfilen, inkludert omslagsbilde og kapitler.", + "LabelToolsM4bEncoder": "M4B enkoder", "LabelToolsMakeM4b": "Lag M4B Lydbokfil", "LabelToolsMakeM4bDescription": "Lager en.M4B lydbokfil med innbakte omslagsbilde og kapitler.", "LabelToolsSplitM4b": "Del M4B inn i MP3er", @@ -487,39 +653,55 @@ "LabelTracksMultiTrack": "Flerspor", "LabelTracksNone": "Ingen spor", "LabelTracksSingleTrack": "Enkelspor", + "LabelTrailer": "Trailer", + "LabelType": "Type", "LabelUnabridged": "Uavkortet", + "LabelUndo": "Angre", "LabelUnknown": "Ukjent", + "LabelUnknownPublishDate": "Ukjent publiseringsdato", "LabelUpdateCover": "Oppdater omslag", "LabelUpdateCoverHelp": "Tillat overskriving av eksisterende omslag for de valgte bøkene når en lik bok er funnet", "LabelUpdateDetails": "Oppdater detaljer", "LabelUpdateDetailsHelp": "Tillat overskriving av eksisterende detaljer for de valgte bøkene når en lik bok er funnet", "LabelUpdatedAt": "Oppdatert", "LabelUploaderDragAndDrop": "Dra og slipp filer eller mapper", + "LabelUploaderDragAndDropFilesOnly": "Dra & slipp filer", "LabelUploaderDropFiles": "Slipp filer", + "LabelUploaderItemFetchMetadataHelp": "Hent tittel, forfatter og serie automatisk", + "LabelUseAdvancedOptions": "Bruk avanserte valg", "LabelUseChapterTrack": "Bruk kapittelspor", "LabelUseFullTrack": "Bruke hele sporet", + "LabelUseZeroForUnlimited": "Bruk 0 for ubegrenset", "LabelUser": "Bruker", "LabelUsername": "Brukernavn", "LabelValue": "Verdi", "LabelVersion": "Versjon", "LabelViewBookmarks": "Vis bokmerker", "LabelViewChapters": "Vis kapitler", + "LabelViewPlayerSettings": "Vis innstillinger for avspiller", "LabelViewQueue": "Vis spillerkø", "LabelVolume": "Volum", + "LabelWebRedirectURLsSubfolder": "Undermapper for videresendings-URL-er", "LabelWeekdaysToRun": "Ukedager å kjøre", + "LabelXBooks": "{0} bøker", + "LabelXItems": "{0} elementer", + "LabelYearReviewHide": "Skjul oppsummering av året", + "LabelYearReviewShow": "Vis oppsummering av året", "LabelYourAudiobookDuration": "Din lydbok lengde", "LabelYourBookmarks": "Dine bokmerker", "LabelYourPlaylists": "Dine spillelister", "LabelYourProgress": "Din fremgang", "MessageAddToPlayerQueue": "Legg til i kø", - "MessageAppriseDescription": "For å bruke denne funksjonen trenger du en instans av Apprise API kjørende eller ett api som vil håndere disse forespørslene.
Apprise API Url skal være den fulle URL stien for å sende Notifikasjonen, f.eks., hvis din API instans er hos http://192.168.1.1:8337 vil du bruke http://192.168.1.1:8337/notify.", + "MessageAppriseDescription": "For å bruke denne funksjonen trenger du en instans av Apprise API kjørende eller et API som håndterer disse forespørslene.
Apprise API URL skal være hele URL-en til varslingen, f.eks., hvis din API-instans er på http://192.168.1.1:8337 så skal du bruke http://192.168.1.1:8337/notify.", "MessageBackupsDescription": "Sikkerhetskopier inkluderer, brukerfremgang, detaljer om bibliotekgjenstander, tjener instillinger og bilder lagret under /metadata/items og /metadata/authors. Sikkerhetskopier vil ikke inkludere filer som er lagret i bibliotek mappene.", - "MessageBackupsLocationEditNote": "Merk: Endring av sikkerhetskopieringssted hverken endrer eller flytter eksisterende sikkerhetskopier", - "MessageBackupsLocationPathEmpty": "Sti til sikkerhetskopieringssted må angis", + "MessageBackupsLocationEditNote": "Viktig: Endring av mappen for sikkerhetskopi hverken endrer eller flytter eksisterende sikkerhetskopier!", + "MessageBackupsLocationNoEditNote": "NB: Mappen for sikkerhetskopi settes i en miljøvariabel og kan ikke endres her.", + "MessageBackupsLocationPathEmpty": "Mappen for sikkerhetskopiering må angis", "MessageBatchQuickMatchDescription": "Kjapt søk vil forsøke å legge til manglende omslag og metadata for de valgte gjenstandene. Aktiver dette valget for å tillate Kjapt søk til å overskrive eksisterende omslag og/eller metadata.", "MessageBookshelfNoCollections": "Du har ikke laget noen samlinger ennå", "MessageBookshelfNoRSSFeeds": "Ingen RSS feed er åpen", "MessageBookshelfNoResultsForFilter": "Ingen resultat for filter \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "Ingen resultater for søket", "MessageBookshelfNoSeries": "Du har ingen serier", "MessageChapterEndIsAfter": "Kapittel slutt er etter slutt av lydboken", "MessageChapterErrorFirstNotZero": "Første kapittel starter på 0", @@ -529,14 +711,28 @@ "MessageCheckingCron": "Sjekker cron...", "MessageConfirmCloseFeed": "Er du sikker på at du vil lukke denne feeden?", "MessageConfirmDeleteBackup": "Er du sikker på at du vil slette sikkerhetskopi for {0}?", + "MessageConfirmDeleteDevice": "Er du sikker på at du vil slette e-leser enheten \"{0}\"?", "MessageConfirmDeleteFile": "Dette vil slette filen fra filsystemet. Er du sikker?", "MessageConfirmDeleteLibrary": "Er du sikker på at du vil slette biblioteket \"{0}\" for godt?", + "MessageConfirmDeleteLibraryItem": "Nå slettes elementet fra databasen og fil-systemet. Er du sikker?", + "MessageConfirmDeleteLibraryItems": "Nå slettes {0} elementer fra databasen og fil-systemet. Er du sikker?", + "MessageConfirmDeleteMetadataProvider": "Er du sikker på at du vil slette den egendefinerte leverandøren av metadata: \"{0}\"?", + "MessageConfirmDeleteNotification": "Er du sikker på at du vil slette dette varselet?", "MessageConfirmDeleteSession": "Er du sikker på at du vil slette denne sesjonen?", + "MessageConfirmEmbedMetadataInAudioFiles": "Er du sikker på at du vil legge til metadata i {0} lyd-filer?", "MessageConfirmForceReScan": "Er du sikker på at du vil tvinge en ny skann?", "MessageConfirmMarkAllEpisodesFinished": "Er du sikker på at du vil markere alle episodene som fullført?", "MessageConfirmMarkAllEpisodesNotFinished": "Er du sikker på at du vil markere alle episodene som ikke fullført?", + "MessageConfirmMarkItemFinished": "Er du sikker på at du vil markere {0} som ferdig?", + "MessageConfirmMarkItemNotFinished": "Er du sikker på at du vil markere {0} som ikke ferdig?", "MessageConfirmMarkSeriesFinished": "Er du sikker på at du vil markere alle bøkene i serien som fullført?", "MessageConfirmMarkSeriesNotFinished": "Er du sikker på at du vil markere alle bøkene i serien som ikke fullført?", + "MessageConfirmNotificationTestTrigger": "Utløs dette varselet med test-data?", + "MessageConfirmPurgeCache": "(Purge cache) Dette vil sletter hele mappen /metadata/cache.

Er du sikker på at du du vil slette cache-mappen?", + "MessageConfirmPurgeItemsCache": "(Purge items cache) Dette vil sletter hele mappen /metadata/cache/items.
Er du sikker?", + "MessageConfirmQuickEmbed": "Advarsel! Rask innbygging av metadata tar ikke backup av lyd-filene først. Forsikre deg om at du har sikkerhetskopi av filene.

Fortsett?", + "MessageConfirmQuickMatchEpisodes": "Hurtig gjenkjenning av episoder overskriver detaljene hvis en match blir funnet. Kun episoder som ikke allerede er matchet blir oppdatert. Er du sikker?", + "MessageConfirmReScanLibraryItems": "Er du sikker på at du ønsker å skanne {0} elementer på nytt?", "MessageConfirmRemoveAllChapters": "Er du sikker på at du vil fjerne alle kapitler?", "MessageConfirmRemoveCollection": "Er du sikker på at du vil fjerne samling\"{0}\"?", "MessageConfirmRemoveEpisode": "Er du sikker på at du vil fjerne episode \"{0}\"?", @@ -593,7 +789,7 @@ "MessageNoListeningSessions": "Ingen Lyttesesjoner", "MessageNoLogs": "Ingen logger", "MessageNoMediaProgress": "Ingen mediefremgang", - "MessageNoNotifications": "Ingen notifikasjoner", + "MessageNoNotifications": "Ingen varslinger", "MessageNoPodcastsFound": "Ingen podcaster funnet", "MessageNoResults": "Ingen resultat", "MessageNoSearchResultsFor": "Ingen søkeresultat for \"{0}\"", From 2e0156d9faab22c7e834b65d8e7b80fdb3479977 Mon Sep 17 00:00:00 2001 From: ugyes Date: Fri, 20 Dec 2024 07:51:14 +0000 Subject: [PATCH 06/52] Translated using Weblate (Hungarian) Currently translated at 95.0% (1026 of 1080 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/ --- client/strings/hu.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/strings/hu.json b/client/strings/hu.json index 4aa24095..10807375 100644 --- a/client/strings/hu.json +++ b/client/strings/hu.json @@ -206,7 +206,7 @@ "HeaderUpdateDetails": "Részletek frissítése", "HeaderUpdateLibrary": "Könyvtár frissítése", "HeaderUsers": "Felhasználók", - "HeaderYearReview": "{0} év áttekintése", + "HeaderYearReview": "{0} év visszatekintése", "HeaderYourStats": "Saját statisztikák", "LabelAbridged": "Tömörített", "LabelAbridgedChecked": "Rövidített (ellenőrizve)", @@ -478,7 +478,7 @@ "LabelPermissionsDownload": "Letölthet", "LabelPermissionsUpdate": "Frissíthet", "LabelPermissionsUpload": "Feltölthet", - "LabelPersonalYearReview": "Az éved áttekintése ({0})", + "LabelPersonalYearReview": "Az évvisszatekintésed ({0})", "LabelPhotoPathURL": "Fénykép útvonal/URL", "LabelPlayMethod": "Lejátszási módszer", "LabelPlayerChapterNumberMarker": "{0} a {1} -ből", @@ -539,7 +539,7 @@ "LabelSeriesName": "Sorozat neve", "LabelSeriesProgress": "Sorozat haladása", "LabelServerLogLevel": "Kiszolgáló naplózási szint", - "LabelServerYearReview": "Szerver évértékelő ({0})", + "LabelServerYearReview": "Szerver évvisszatekintés ({0})", "LabelSetEbookAsPrimary": "Beállítás elsődlegesként", "LabelSetEbookAsSupplementary": "Beállítás kiegészítőként", "LabelSettingsAllowIframe": "A beágyazás engedélyezése egy iframe-be", @@ -684,8 +684,8 @@ "LabelWeekdaysToRun": "Futás napjai", "LabelXBooks": "{0} könyv", "LabelXItems": "{0} elem", - "LabelYearReviewHide": "Évértékelő elrejtése", - "LabelYearReviewShow": "Évértékelés megtekintése", + "LabelYearReviewHide": "Az évvisszatekintés elrejtése", + "LabelYearReviewShow": "Évvisszatekintés megtekintése", "LabelYourAudiobookDuration": "Hangoskönyv időtartama", "LabelYourBookmarks": "Könyvjelzőid", "LabelYourPlaylists": "Lejátszási listáid", @@ -910,7 +910,7 @@ "StatsTopNarrator": "TOP ELŐADÓ", "StatsTopNarrators": "TOP ELŐADÓ", "StatsTotalDuration": "A teljes időtartam…", - "StatsYearInReview": "ÉVÉRTÉKELÉS", + "StatsYearInReview": "ÉVVISSZATEKINTÉS", "ToastAccountUpdateSuccess": "Fiók frissítve", "ToastAppriseUrlRequired": "Meg kell adnia egy Apprise URL-címet", "ToastAsinRequired": "ASIN kötelező", From aa82439125ff2558bb2c3f09ee5e42e5e3b143ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98ystein=20S=2E=20Hegnander?= Date: Fri, 20 Dec 2024 06:45:05 +0000 Subject: [PATCH 07/52] =?UTF-8?q?Translated=20using=20Weblate=20(Norwegian?= =?UTF-8?q?=20Bokm=C3=A5l)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 91.9% (993 of 1080 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nb_NO/ --- client/strings/no.json | 107 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/client/strings/no.json b/client/strings/no.json index 96de90d9..ddfc2489 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -406,6 +406,7 @@ "LabelLess": "Mindre", "LabelLibrariesAccessibleToUser": "Biblioteker tilgjengelig for bruker", "LabelLibrary": "Bibliotek", + "LabelLibraryFilterSublistEmpty": "", "LabelLibraryItem": "Bibliotek enhet", "LabelLibraryName": "Bibliotek navn", "LabelLimit": "Begrensning", @@ -568,6 +569,7 @@ "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Gjenværende tid er mindre enn (sekunder)", "LabelSettingsLibraryMarkAsFinishedWhen": "Marker som ferdig når", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Hopp over tidligere bøker i \"Fortsett serien\"", + "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "\"Fortsett serie\"-siden viser første bok som ikke er påbegynt i serier der en bok er lest og ingen bøker leses nå. Ved å slå på denne innstillingen så vil man fortsette på serien etter siste leste bok, fremfor første bok som ikke er startet på i en serie.", "LabelSettingsParseSubtitles": "Analyser undertekster", "LabelSettingsParseSubtitlesHelp": "Hent undertittel fra lydbokens mappenavn.
Undertittel må være separert med \" - \"
f.eks. \"Boktittel - En lengre tittel\" har undertittel \"En lengre tittel\".", "LabelSettingsPreferMatchedMetadata": "Foretrekk funnet metadata", @@ -681,6 +683,7 @@ "LabelViewPlayerSettings": "Vis innstillinger for avspiller", "LabelViewQueue": "Vis spillerkø", "LabelVolume": "Volum", + "LabelWebRedirectURLsDescription": "Godkjenn disse URL-ene hos OAuth-tilbyder for å tillate videresending til web-appen etter innlogging:", "LabelWebRedirectURLsSubfolder": "Undermapper for videresendings-URL-er", "LabelWeekdaysToRun": "Ukedager å kjøre", "LabelXBooks": "{0} bøker", @@ -734,9 +737,12 @@ "MessageConfirmQuickMatchEpisodes": "Hurtig gjenkjenning av episoder overskriver detaljene hvis en match blir funnet. Kun episoder som ikke allerede er matchet blir oppdatert. Er du sikker?", "MessageConfirmReScanLibraryItems": "Er du sikker på at du ønsker å skanne {0} elementer på nytt?", "MessageConfirmRemoveAllChapters": "Er du sikker på at du vil fjerne alle kapitler?", + "MessageConfirmRemoveAuthor": "Er du sikker på at du vil fjerne forfatteren \"{0}\"?", "MessageConfirmRemoveCollection": "Er du sikker på at du vil fjerne samling\"{0}\"?", "MessageConfirmRemoveEpisode": "Er du sikker på at du vil fjerne episode \"{0}\"?", "MessageConfirmRemoveEpisodes": "Er du sikker på at du vil fjerne {0} episoder?", + "MessageConfirmRemoveListeningSessions": "Er du sikker på at du vil fjerne {0} lytte-sesjoner?", + "MessageConfirmRemoveMetadataFiles": "Er du sikker på at du vil fjerne alle metadata.{0}-filer i mappene for biblioteks-elementer?", "MessageConfirmRemoveNarrator": "Er du sikker på at du vil fjerne forteller \"{0}\"?", "MessageConfirmRemovePlaylist": "Er du sikker på at du vil fjerne spillelisten \"{0}\"?", "MessageConfirmRenameGenre": "Er du sikker på at du vil endre sjanger \"{0}\" til \"{1}\" for alle gjenstandene?", @@ -745,11 +751,16 @@ "MessageConfirmRenameTag": "Er du sikker på at du vil endre tag \"{0}\" til \"{1}\" for alle gjenstandene?", "MessageConfirmRenameTagMergeNote": "Notis: Denne taggen finnes allerede så de vil bli slått sammen.", "MessageConfirmRenameTagWarning": "Advarsel! En lignende tag eksisterer allerede (med forsjellige store / små bokstaver) \"{0}\".", + "MessageConfirmResetProgress": "Er du sikkert på at du vil tilbakestille fremgangen?", "MessageConfirmSendEbookToDevice": "Er du sikker på at du vil sende {0} ebok \"{1}\" til enhet \"{2}\"?", + "MessageConfirmUnlinkOpenId": "Er du sikker på at du vil koble denne brukeren fra OpenID?", "MessageDownloadingEpisode": "Laster ned episode", "MessageDragFilesIntoTrackOrder": "Dra filene i rett spor rekkefølge", + "MessageEmbedFailed": "Innbygging feilet!", "MessageEmbedFinished": "Bak inn Fullført!", + "MessageEmbedQueue": "Lagt i køen for innbygging av metadata ({0} i kø)", "MessageEpisodesQueuedForDownload": "{0} Episode(r) lagt til i kø for nedlasting", + "MessageEreaderDevices": "For å sikre sendingen av e-bøker, så må du kanskje legge til e-postadressen over som en gyldig avsender for hver enhet i listen over.", "MessageFeedURLWillBe": "Feed URL vil bli {0}", "MessageFetching": "Henter...", "MessageForceReScanDescription": "vil skanne alle filene igjen som en ny skann. Lyd fil ID3 tagger, OPF filer og tekstfiler vil bli skannet som nye.", @@ -844,30 +855,66 @@ "ToastAuthorUpdateMerged": "Forfatter slått sammen", "ToastAuthorUpdateSuccess": "Forfatter oppdatert", "ToastAuthorUpdateSuccessNoImageFound": "Forfatter oppdater (ingen bilde funnet)", + "ToastBackupAppliedSuccess": "Sikkerhetskopi slått på", "ToastBackupCreateFailed": "Mislykkes å lage sikkerhetskopi", "ToastBackupCreateSuccess": "Sikkerhetskopi opprettet", "ToastBackupDeleteFailed": "Mislykkes å slette sikkerhetskopi", "ToastBackupDeleteSuccess": "Sikkerhetskopi slettet", + "ToastBackupInvalidMaxKeep": "Ugyldig antall sikkerhetskopier ønskes beholdt", + "ToastBackupInvalidMaxSize": "Ugyldig maksimal størrelse for sikkerhetskopi", "ToastBackupRestoreFailed": "Misslykkes å gjenopprette sikkerhetskopi", "ToastBackupUploadFailed": "Misslykkes å laste opp sikkerhetskopi", "ToastBackupUploadSuccess": "Sikkerhetskopi lastet opp", + "ToastBatchDeleteFailed": "Sletting feilet på utvalget", + "ToastBatchDeleteSuccess": "Sletting av samling utført", + "ToastBatchQuickMatchFailed": "Feil ved rask integrering av metadata!", + "ToastBatchQuickMatchStarted": "Rask integrering av metadata for {0} bøker startet!", "ToastBatchUpdateFailed": "Bulk oppdatering mislykket", "ToastBatchUpdateSuccess": "Bulk oppdatering fullført", "ToastBookmarkCreateFailed": "Misslykkes å opprette bokmerke", "ToastBookmarkCreateSuccess": "Bokmerke lagt til", "ToastBookmarkRemoveSuccess": "Bokmerke fjernet", "ToastBookmarkUpdateSuccess": "Bokmerke oppdatert", + "ToastCachePurgeFailed": "Kunne ikke å slette mellomlager", + "ToastCachePurgeSuccess": "Mellomlager slettet", "ToastChaptersHaveErrors": "Kapittel har feil", "ToastChaptersMustHaveTitles": "Kapittel må ha titler", + "ToastChaptersRemoved": "Kapitler fjernet", + "ToastChaptersUpdated": "Kapitler oppdatert", + "ToastCollectionItemsAddFailed": "Feil med å legge til element(er)", + "ToastCollectionItemsAddSuccess": "Element(er) lagt til samlingen", "ToastCollectionItemsRemoveSuccess": "Gjenstand(er) fjernet fra samling", "ToastCollectionRemoveSuccess": "Samling fjernet", "ToastCollectionUpdateSuccess": "samlingupdated", + "ToastCoverUpdateFailed": "Oppdatering av bilde feilet", + "ToastDeleteFileFailed": "Kunne ikke slette fil", + "ToastDeleteFileSuccess": "Fil slettet", + "ToastDeviceAddFailed": "Kunne ikke legge til enhet", + "ToastDeviceNameAlreadyExists": "E-leser med dette navnet eksisterer allerede", + "ToastDeviceTestEmailFailed": "Kunne ikke sende test e-post", + "ToastDeviceTestEmailSuccess": "E-post for testing er sendt", + "ToastEmailSettingsUpdateSuccess": "Innstillinger for e-post oppdatert", + "ToastEncodeCancelFailed": "Kunne ikke stoppe konverteringen", + "ToastEncodeCancelSucces": "Konvertering kansellert", + "ToastEpisodeDownloadQueueClearFailed": "Kunne ikke tømme køen", + "ToastEpisodeDownloadQueueClearSuccess": "Nedlastingskø for eposider tømt", + "ToastEpisodeUpdateSuccess": "{0} episoder oppdatert", + "ToastFailedToLoadData": "Kunne ikke laste inn data", + "ToastFailedToMatch": "Kunne ikke matche", + "ToastFailedToShare": "Deling feilet", + "ToastFailedToUpdate": "Oppdatering feilet", + "ToastInvalidImageUrl": "Ugyldig URL for bilde", + "ToastInvalidMaxEpisodesToDownload": "Ugyldig maksimalt antall for nedlasting av episoder", + "ToastInvalidUrl": "Ugyldig URL", "ToastItemCoverUpdateSuccess": "Omslag oppdatert", + "ToastItemDeletedFailed": "Kunne ikke slette element", + "ToastItemDeletedSuccess": "Element slettet", "ToastItemDetailsUpdateSuccess": "Detaljer oppdatert", "ToastItemMarkedAsFinishedFailed": "Misslykkes å markere som Fullført", "ToastItemMarkedAsFinishedSuccess": "Gjenstand marker som Fullført", "ToastItemMarkedAsNotFinishedFailed": "Misslykkes å markere som Ikke Fullført", "ToastItemMarkedAsNotFinishedSuccess": "Markert som Ikke Fullført", + "ToastItemUpdateSuccess": "Element oppdatert", "ToastLibraryCreateFailed": "Misslykkes å opprette bibliotek", "ToastLibraryCreateSuccess": "Bibliotek \"{0}\" opprettet", "ToastLibraryDeleteFailed": "Misslykkes å slette bibliotek", @@ -875,25 +922,83 @@ "ToastLibraryScanFailedToStart": "Misslykkes å starte skann", "ToastLibraryScanStarted": "Bibliotek skann startet", "ToastLibraryUpdateSuccess": "Bibliotek \"{0}\" oppdatert", + "ToastMatchAllAuthorsFailed": "Kunne ikke finne match for alle forfattere", + "ToastMetadataFilesRemovedError": "Feil ved fjerning av metadata.{0}-filer", + "ToastMetadataFilesRemovedNoneFound": "Ingen metata.{0}-filer funnet i biblioteket", + "ToastMetadataFilesRemovedNoneRemoved": "Ingen metata.{0}-filer fjernet", + "ToastMetadataFilesRemovedSuccess": "{0} metata.{1}-filer fjernet", + "ToastMustHaveAtLeastOnePath": "Påkrevd med minst én mappe", + "ToastNameEmailRequired": "Navn og e-post påkrevd", + "ToastNameRequired": "Navn er påkrevd", + "ToastNewEpisodesFound": "{0} nye episoder funnet", + "ToastNewUserCreatedFailed": "Kunne ikke opprette konto: \"{0}\"", + "ToastNewUserCreatedSuccess": "Ny konto opprettet", + "ToastNewUserLibraryError": "Velg minst ett bibliotek", + "ToastNewUserPasswordError": "Passord kreves. Kun root-bruker kan ha blankt passord", + "ToastNewUserTagError": "Velg minst en tag", + "ToastNewUserUsernameError": "Skriv inn brukernavn", + "ToastNoNewEpisodesFound": "Ingen nye episoder funnet", + "ToastNoUpdatesNecessary": "Ingen oppdateringer nødvendig", + "ToastNotificationCreateFailed": "Kunne ikke opprette varsling", + "ToastNotificationDeleteFailed": "Kunne ikke slette varsling", + "ToastNotificationFailedMaximum": "Maksimalt antall forsøk som feiler må være større eller lik null (0)", + "ToastNotificationQueueMaximum": "Maksimal størrelse på varsel-kø må være større eller lik null (0)", + "ToastNotificationSettingsUpdateSuccess": "Innstillinger for varsling oppdatert", + "ToastNotificationTestTriggerFailed": "Kunne ikke utløse test-varsel", + "ToastNotificationTestTriggerSuccess": "Test-varsel utløst", + "ToastNotificationUpdateSuccess": "Varsel oppdatert", "ToastPlaylistCreateFailed": "Misslykkes å opprette spilleliste", "ToastPlaylistCreateSuccess": "Spilleliste opprettet", "ToastPlaylistRemoveSuccess": "Spilleliste fjernet", "ToastPlaylistUpdateSuccess": "Spilleliste oppdatert", "ToastPodcastCreateFailed": "Misslykkes å opprette podcast", "ToastPodcastCreateSuccess": "Podcast opprettet", + "ToastPodcastGetFeedFailed": "Kunne ikke hente podcast-feed", + "ToastPodcastNoEpisodesInFeed": "Ingen episoder funnet i RSS-feed", + "ToastPodcastNoRssFeed": "Podcast har ingen RSS-feed", + "ToastProgressIsNotBeingSynced": "Progresjon synkroniserer ikke, start avspilling på nytt", + "ToastProviderCreatedFailed": "Kunne ikke legge til tilbyder", + "ToastProviderCreatedSuccess": "Ny tilbyder lagt til", + "ToastProviderNameAndUrlRequired": "Navn og URL er påkrevd", + "ToastProviderRemoveSuccess": "Tilbyder fjernet", "ToastRSSFeedCloseFailed": "Misslykkes å lukke RSS feed", "ToastRSSFeedCloseSuccess": "RSS feed lukket", + "ToastRemoveFailed": "Kunne ikke fjerne", "ToastRemoveItemFromCollectionFailed": "Misslykkes å fjerne gjenstsand fra samling", "ToastRemoveItemFromCollectionSuccess": "Gjenstand fjernet fra samling", + "ToastRemoveItemsWithIssuesFailed": "Kunne ikke fjerne bibliotek-elementer med feil", + "ToastRemoveItemsWithIssuesSuccess": "Fjernet bibliotek-elementer med feil", + "ToastRenameFailed": "Kunne ikke endre navn", + "ToastRescanFailed": "Ny skanning feilet for {0}", + "ToastRescanRemoved": "Ny skanning utført og element fjernet", + "ToastRescanUpToDate": "Ny skanning utført og element var oppdatert", + "ToastRescanUpdated": "Ny skanning utført og element oppdatert", + "ToastScanFailed": "Kunne ikke skanne bibliotek-element", + "ToastSelectAtLeastOneUser": "Velg minst én bruker", "ToastSendEbookToDeviceFailed": "Misslykkes å sende ebok", "ToastSendEbookToDeviceSuccess": "Ebok sendt til \"{0}\"", "ToastSeriesUpdateFailed": "Misslykkes å oppdatere serie", "ToastSeriesUpdateSuccess": "Serie oppdatert", + "ToastServerSettingsUpdateSuccess": "Server-innstillinger oppdatert", + "ToastSessionCloseFailed": "Kunne ikke avslutte sesjon", "ToastSessionDeleteFailed": "Misslykkes å slette sesjon", "ToastSessionDeleteSuccess": "Sesjon slettet", + "ToastSleepTimerDone": "Søvn-timer ferdig... zZzzZz", + "ToastSlugMustChange": "Slug inneholder ugyldige tegn", + "ToastSlugRequired": "Slug påkrevd", "ToastSocketConnected": "Socket koblet til", "ToastSocketDisconnected": "Socket koblet fra", "ToastSocketFailedToConnect": "Misslykkes å koble til Socket", + "ToastSortingPrefixesEmptyError": "Må ha minst én sorteringsprefiks", + "ToastSortingPrefixesUpdateSuccess": "Sorteringsprefiks oppdatert ({0} element)", + "ToastTitleRequired": "Tittel påkrevd", + "ToastUnknownError": "Ukjent feil", + "ToastUnlinkOpenIdFailed": "Kunne ikke koble bruker fra OpenID", + "ToastUnlinkOpenIdSuccess": "Bruker koblet fra OpenID", "ToastUserDeleteFailed": "Misslykkes å slette bruker", - "ToastUserDeleteSuccess": "Bruker slettet" + "ToastUserDeleteSuccess": "Bruker slettet", + "ToastUserPasswordChangeSuccess": "Passord ble endret", + "ToastUserPasswordMismatch": "Passord må stemme overens", + "ToastUserPasswordMustChange": "Nytt passord kan ikke være identisk med gammelt passord", + "ToastUserRootRequireName": "Root-brukernavn er påkrevd" } From da7d9c10ad548b7e212f5a09a79e51e32a661edf Mon Sep 17 00:00:00 2001 From: Tamanegii Date: Sun, 22 Dec 2024 02:39:01 +0000 Subject: [PATCH 08/52] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1080 of 1080 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index e4791aff..3a8b432f 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -88,6 +88,8 @@ "ButtonSaveTracklist": "保存音轨列表", "ButtonScan": "扫描", "ButtonScanLibrary": "扫描库", + "ButtonScrollLeft": "向左滚动", + "ButtonScrollRight": "向右滚动", "ButtonSearch": "查找", "ButtonSelectFolderPath": "选择文件夹路径", "ButtonSeries": "系列", @@ -190,6 +192,7 @@ "HeaderSettingsExperimental": "实验功能", "HeaderSettingsGeneral": "通用", "HeaderSettingsScanner": "扫描", + "HeaderSettingsWebClient": "网页客户端", "HeaderSleepTimer": "睡眠计时", "HeaderStatsLargestItems": "最大的项目", "HeaderStatsLongestItems": "项目时长(小时)", @@ -542,6 +545,7 @@ "LabelServerYearReview": "服务器年度回顾 ({0})", "LabelSetEbookAsPrimary": "设置为主", "LabelSetEbookAsSupplementary": "设置为补充", + "LabelSettingsAllowIframe": "允许嵌入到 iframe 中", "LabelSettingsAudiobooksOnly": "只有有声读物", "LabelSettingsAudiobooksOnlyHelp": "启用此设置将忽略电子书文件, 除非它们位于有声读物文件夹中, 在这种情况下, 它们将被设置为补充电子书", "LabelSettingsBookshelfViewHelp": "带有木架子的拟物化设计", @@ -592,6 +596,8 @@ "LabelSize": "文件大小", "LabelSleepTimer": "睡眠定时", "LabelSlug": "Slug", + "LabelSortAscending": "升序", + "LabelSortDescending": "降序", "LabelStart": "开始", "LabelStartTime": "开始时间", "LabelStarted": "开始于", From 3cc5fae586895a314e76f5cae469694fbca8a2eb Mon Sep 17 00:00:00 2001 From: Plazec Date: Mon, 23 Dec 2024 13:58:27 +0000 Subject: [PATCH 09/52] Translated using Weblate (Czech) Currently translated at 87.9% (950 of 1080 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/ --- client/strings/cs.json | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/client/strings/cs.json b/client/strings/cs.json index d079d7a5..684a25e1 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -234,7 +234,7 @@ "LabelAppend": "Připojit", "LabelAudioBitrate": "Bitový tok zvuku (např. 128k)", "LabelAudioChannels": "Zvukové kanály (1 nebo 2)", - "LabelAudioCodec": "Kodek audia", + "LabelAudioCodec": "Audio Kodek", "LabelAuthor": "Autor", "LabelAuthorFirstLast": "Autor (jméno a příjmení)", "LabelAuthorLastFirst": "Autor (příjmení a jméno)", @@ -420,6 +420,7 @@ "LabelMatchExistingUsersBy": "Přiřadit stávající uživatele podle", "LabelMatchExistingUsersByDescription": "Slouží k propojení stávajících uživatelů. Po propojení budou uživatelé přiřazeni k jedinečnému ID od poskytovatele SSO.", "LabelMaxEpisodesToDownload": "Maximální # epizod pro stažení. Použijte 0 pro bez omezení.", + "LabelMaxEpisodesToDownloadPerCheck": "Maximální počet nových epizod ke stažení při jedné kontrole", "LabelMaxEpisodesToKeep": "Maximální počet epizod k zachování", "LabelMaxEpisodesToKeepHelp": "Hodnotou 0 není nastaven žádný maximální limit. Po automatickém stažení nové epizody se odstraní nejstarší epizoda, pokud máte více než X epizod. Při každém novém stažení se odstraní pouze 1 epizoda.", "LabelMediaPlayer": "Přehrávač médií", @@ -735,6 +736,7 @@ "MessageConfirmPurgeCache": "Vyčistit mezipaměť odstraní celý adresář na adrese /metadata/cache.

Určitě chcete odstranit adresář mezipaměti?", "MessageConfirmPurgeItemsCache": "Vyčištění mezipaměti položek odstraní celý adresář /metadata/cache/items.
Jste si jistí?", "MessageConfirmQuickEmbed": "Varování! Rychlé vložení nezálohuje vaše zvukové soubory. Ujistěte se, že máte zálohu zvukových souborů.

Chcete pokračovat?", + "MessageConfirmQuickMatchEpisodes": "Pokud je nalezena shoda při rychlém párování epizod, dojde k přepsání podrobností. Aktualizovány budou pouze nespárované epizody. Jste si jisti?", "MessageConfirmReScanLibraryItems": "Opravdu chcete znovu prohledat {0} položky?", "MessageConfirmRemoveAllChapters": "Opravdu chcete odstranit všechny kapitoly?", "MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?", @@ -742,6 +744,7 @@ "MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?", "MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?", "MessageConfirmRemoveListeningSessions": "Opravdu chcete odebrat {0} poslechových relací?", + "MessageConfirmRemoveMetadataFiles": "Jste si jisti, že chcete odstranit všechny metadata.{0} soubory ve složkách s položkami ve vaší knihovně?", "MessageConfirmRemoveNarrator": "Opravdu chcete odebrat předčítání \"{0}\"?", "MessageConfirmRemovePlaylist": "Opravdu chcete odstranit svůj playlist \"{0}\"?", "MessageConfirmRenameGenre": "Opravdu chcete přejmenovat žánr \"{0}\" na \"{1}\" pro všechny položky?", @@ -757,6 +760,7 @@ "MessageDragFilesIntoTrackOrder": "Přetáhněte soubory do správného pořadí stop", "MessageEmbedFailed": "Vložení selhalo!", "MessageEmbedFinished": "Vložení dokončeno!", + "MessageEmbedQueue": "Zařazeno do fronty pro vložení metadat ({0} ve frontě)", "MessageEpisodesQueuedForDownload": "{0} Epizody zařazené do fronty ke stažení", "MessageEreaderDevices": "Aby bylo zajištěno doručení elektronických knih, může být nutné přidat výše uvedenou e-mailovou adresu jako platného odesílatele pro každé zařízení uvedené níže.", "MessageFeedURLWillBe": "URL zdroje bude {0}", @@ -801,6 +805,7 @@ "MessageNoLogs": "Žádné protokoly", "MessageNoMediaProgress": "Žádný průběh médií", "MessageNoNotifications": "Žádná oznámení", + "MessageNoPodcastFeed": "Neplatný podcast: Žádný kanál", "MessageNoPodcastsFound": "Nebyly nalezeny žádné podcasty", "MessageNoResults": "Žádné výsledky", "MessageNoSearchResultsFor": "Nebyly nalezeny žádné výsledky hledání pro \"{0}\"", @@ -817,7 +822,10 @@ "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í", - "MessageQuickMatchDescription": "Vyplňte prázdné detaily položky a obálku prvním výsledkem shody z '{0}'. Nepřepisuje podrobnosti, pokud není povoleno nastavení serveru \"Preferovat párování metadata\".", + "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", + "MessageQuickMatchDescription": "Vyplnit prázdné detaily položky a obálky prvním výsledkem shody z '{0}'. Nepřepisuje detaily, pokud není povoleno nastavení serveru 'Preferovat shodná metadata'.", "MessageRemoveChapter": "Odstranit kapitolu", "MessageRemoveEpisodes": "Odstranit {0} epizodu", "MessageRemoveFromPlayerQueue": "Odstranit z fronty přehrávače", @@ -848,10 +856,13 @@ "MessageTaskFailedToMergeAudioFiles": "Spojení audio souborů selhalo", "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í", "MessageTaskOpmlImport": "Import OPML", "MessageTaskOpmlImportDescription": "Vytváření podcastů z {0} RSS feedů", + "MessageTaskOpmlImportFeed": "Importní zdroj OPML", "MessageTaskOpmlImportFeedDescription": "Importování RSS feedu \"{0}\"", + "MessageTaskOpmlImportFeedFailed": "Nepodařilo se získat kanál podcastu", "MessageTaskOpmlImportFeedPodcastDescription": "Vytváření podcastu \"{0}\"", "MessageTaskOpmlImportFeedPodcastExists": "Podcast se stejnou cestou již existuje", "MessageTaskOpmlImportFeedPodcastFailed": "Vytváření podcastu selhalo", From ba9277cc443be7ab11446cb4aaf384bbaba15faa Mon Sep 17 00:00:00 2001 From: pranelio Date: Tue, 24 Dec 2024 15:08:14 +0000 Subject: [PATCH 10/52] Translated using Weblate (Lithuanian) Currently translated at 65.2% (705 of 1080 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/lt/ --- client/strings/lt.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/lt.json b/client/strings/lt.json index 9fe65e3a..e1a63510 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -104,7 +104,7 @@ "ButtonViewAll": "Peržiūrėti visus", "ButtonYes": "Taip", "ErrorUploadFetchMetadataAPI": "Klaida gaunant metaduomenis", - "ErrorUploadFetchMetadataNoResults": "Nepavyko gauti metaduomenų - pabandykite atnaujinti pavadinimą ir/ar autorių.", + "ErrorUploadFetchMetadataNoResults": "Nepavyko gauti metaduomenų - pabandykite atnaujinti pavadinimą ir/ar autorių", "ErrorUploadLacksTitle": "Pavadinimas yra privalomas", "HeaderAccount": "Paskyra", "HeaderAdvanced": "Papildomi", @@ -419,7 +419,7 @@ "LabelSettingsExperimentalFeatures": "Eksperimentiniai funkcionalumai", "LabelSettingsExperimentalFeaturesHelp": "Funkcijos, kurios yra kuriamos ir laukiami jūsų komentarai. Spustelėkite, kad atidarytumėte „GitHub“ diskusiją.", "LabelSettingsFindCovers": "Rasti viršelius", - "LabelSettingsFindCoversHelp": "Jei jūsų audioknyga neturi įterpto viršelio arba viršelio paveikslėlio aplanke, bandyti rasti viršelį.
Pastaba: Tai padidins skenavimo trukmę.", + "LabelSettingsFindCoversHelp": "Jei jūsų audioknyga neturi įterpto viršelio arba viršelio paveikslėlio aplanke, bandyti rasti viršelį.
Pastaba: Tai padidins skenavimo trukmę", "LabelSettingsHideSingleBookSeries": "Slėpti serijas, turinčias tik vieną knygą", "LabelSettingsHideSingleBookSeriesHelp": "Serijos, turinčios tik vieną knygą, bus paslėptos nuo serijų puslapio ir pagrindinio puslapio lentynų.", "LabelSettingsHomePageBookshelfView": "Naudoti pagrindinio puslapio knygų lentynų vaizdą", From bacb8aeac7673f2ab27ee1a4b519487ca63b3918 Mon Sep 17 00:00:00 2001 From: Jan-Eric Myhrgren Date: Sun, 29 Dec 2024 10:41:29 +0000 Subject: [PATCH 11/52] Translated using Weblate (Swedish) Currently translated at 68.7% (743 of 1080 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sv/ --- client/strings/sv.json | 156 ++++++++++++++++++++++++++--------------- 1 file changed, 98 insertions(+), 58 deletions(-) diff --git a/client/strings/sv.json b/client/strings/sv.json index 0d156efd..7513e8db 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -13,7 +13,7 @@ "ButtonBrowseForFolder": "Bläddra efter mapp", "ButtonCancel": "Avbryt", "ButtonCancelEncode": "Avbryt kodning", - "ButtonChangeRootPassword": "Ändra rootlösenord", + "ButtonChangeRootPassword": "Ändra lösenordet för root", "ButtonCheckAndDownloadNewEpisodes": "Kontrollera och ladda ner nya avsnitt", "ButtonChooseAFolder": "Välj en mapp", "ButtonChooseFiles": "Välj filer", @@ -29,7 +29,7 @@ "ButtonEditChapters": "Redigera kapitel", "ButtonEditPodcast": "Redigera podcast", "ButtonForceReScan": "Tvinga omstart", - "ButtonFullPath": "Full sökväg", + "ButtonFullPath": "Fullständig sökväg", "ButtonHide": "Dölj", "ButtonHome": "Hem", "ButtonIssues": "Problem", @@ -42,13 +42,18 @@ "ButtonMatchAllAuthors": "Matcha alla författare", "ButtonMatchBooks": "Matcha böcker", "ButtonNevermind": "Glöm det", - "ButtonOk": "Okej", + "ButtonNext": "Nästa", + "ButtonNextChapter": "Nästa kapitel", + "ButtonOk": "Ok", "ButtonOpenFeed": "Öppna flöde", "ButtonOpenManager": "Öppna Manager", "ButtonPause": "Pausa", "ButtonPlay": "Spela", + "ButtonPlayAll": "Spela alla", "ButtonPlaying": "Spelar", "ButtonPlaylists": "Spellistor", + "ButtonPrevious": "Föregående", + "ButtonPreviousChapter": "Föregående kapitel", "ButtonPurgeAllCache": "Rensa all cache", "ButtonPurgeItemsCache": "Rensa föremåls-cache", "ButtonQueueAddItem": "Lägg till i kön", @@ -56,6 +61,9 @@ "ButtonQuickMatch": "Snabb matchning", "ButtonReScan": "Omstart", "ButtonRead": "Läs", + "ButtonReadLess": "Visa mindre", + "ButtonReadMore": "Visa mer", + "ButtonRefresh": "Uppdatera", "ButtonRemove": "Ta bort", "ButtonRemoveAll": "Ta bort alla", "ButtonRemoveAllLibraryItems": "Ta bort alla biblioteksobjekt", @@ -72,12 +80,13 @@ "ButtonScanLibrary": "Skanna bibliotek", "ButtonSearch": "Sök", "ButtonSelectFolderPath": "Välj mappens sökväg", - "ButtonSeries": "Serie", + "ButtonSeries": "Serier", "ButtonSetChaptersFromTracks": "Ställ in kapitel från spår", "ButtonShiftTimes": "Förskjut tider", "ButtonShow": "Visa", "ButtonStartM4BEncode": "Starta M4B-kodning", "ButtonStartMetadataEmbed": "Starta inbäddning av metadata", + "ButtonStats": "Statistik", "ButtonSubmit": "Skicka", "ButtonTest": "Testa", "ButtonUpload": "Ladda upp", @@ -123,7 +132,7 @@ "HeaderListeningStats": "Lyssningsstatistik", "HeaderLogin": "Logga in", "HeaderLogs": "Loggar", - "HeaderManageGenres": "Hantera genrer", + "HeaderManageGenres": "Hantera kategorier", "HeaderManageTags": "Hantera taggar", "HeaderMapDetails": "Karta detaljer", "HeaderMatch": "Matcha", @@ -154,13 +163,14 @@ "HeaderSettingsExperimental": "Experimentella funktioner", "HeaderSettingsGeneral": "Allmänt", "HeaderSettingsScanner": "Skanner", + "HeaderSettingsWebClient": "Webklient", "HeaderSleepTimer": "Sovtidtagare", - "HeaderStatsLargestItems": "Största föremål", - "HeaderStatsLongestItems": "Längsta föremål (tim)", + "HeaderStatsLargestItems": "Största objekt", + "HeaderStatsLongestItems": "Längsta objekt (tim)", "HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagar)", "HeaderStatsRecentSessions": "Senaste sessioner", - "HeaderStatsTop10Authors": "Topp 10 författare", - "HeaderStatsTop5Genres": "Topp 5 genrer", + "HeaderStatsTop10Authors": "10 populäraste författarna", + "HeaderStatsTop5Genres": "5 populäraste kategorierna", "HeaderTableOfContents": "Innehållsförteckning", "HeaderTools": "Verktyg", "HeaderUpdateAccount": "Uppdatera konto", @@ -168,7 +178,8 @@ "HeaderUpdateDetails": "Uppdatera detaljer", "HeaderUpdateLibrary": "Uppdatera bibliotek", "HeaderUsers": "Användare", - "HeaderYourStats": "Dina statistik", + "HeaderYearReview": "Sammanställning för {0}", + "HeaderYourStats": "Din statistik", "LabelAbridged": "Förkortad", "LabelAccountType": "Kontotyp", "LabelAccountTypeGuest": "Gäst", @@ -191,18 +202,23 @@ "LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)", "LabelAuthors": "Författare", "LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt", + "LabelAutoFetchMetadata": "Automatisk nedladdning av metadata", + "LabelAutoFetchMetadataHelp": "Hämtar metadata för titel, författare och serier. Kompletterande metadata får adderas efter uppladdningen.", "LabelBackToUser": "Tillbaka till användaren", - "LabelBackupLocation": "Säkerhetskopia Plats", + "LabelBackupLocation": "Plats för säkerhetskopia", "LabelBackupsEnableAutomaticBackups": "Aktivera automatiska säkerhetskopior", - "LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i /metadata/säkerhetskopior", - "LabelBackupsMaxBackupSize": "Maximal säkerhetskopiostorlek (i GB)", + "LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i \"/metadata/backups\"", + "LabelBackupsMaxBackupSize": "Maximal storlek på säkerhetskopia (i GB) (0 = obegränsad)", "LabelBackupsMaxBackupSizeHelp": "Som ett skydd mot felkonfiguration kommer säkerhetskopior att misslyckas om de överskrider den konfigurerade storleken.", "LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla", "LabelBackupsNumberToKeepHelp": "Endast en säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än detta bör du ta bort dem manuellt.", "LabelBitrate": "Bitfrekvens", "LabelBooks": "Böcker", + "LabelButtonText": "Knapptext", + "LabelByAuthor": "av {0}", "LabelChangePassword": "Ändra lösenord", "LabelChannels": "Kanaler", + "LabelChapterCount": "{0} kapitel", "LabelChapterTitle": "Kapitelrubrik", "LabelChapters": "Kapitel", "LabelChaptersFound": "hittade kapitel", @@ -215,7 +231,7 @@ "LabelConfirmPassword": "Bekräfta lösenord", "LabelContinueListening": "Fortsätt Lyssna", "LabelContinueReading": "Fortsätt Läsa", - "LabelContinueSeries": "Forsätt Serie", + "LabelContinueSeries": "Fortsätt Serie", "LabelCover": "Omslag", "LabelCoverImageURL": "URL till omslagsbild", "LabelCreatedAt": "Skapad vid", @@ -267,8 +283,8 @@ "LabelFontBoldness": "Fetstil", "LabelFontFamily": "Teckensnittsfamilj", "LabelFontScale": "Teckensnittsskala", - "LabelGenre": "Genre", - "LabelGenres": "Genrer", + "LabelGenre": "Kategori", + "LabelGenres": "Kategorier", "LabelHardDeleteFile": "Hård radering av fil", "LabelHasEbook": "Har E-bok", "LabelHasSupplementaryEbook": "Har komplimenterande E-bok", @@ -316,19 +332,19 @@ "LabelMediaType": "Mediatyp", "LabelMetaTag": "Metamärke", "LabelMetaTags": "Metamärken", - "LabelMetadataProvider": "Metadataleverantör", + "LabelMetadataProvider": "Källa för metadata", "LabelMinute": "Minut", "LabelMissing": "Saknad", "LabelMore": "Mer", "LabelMoreInfo": "Mer information", "LabelName": "Namn", - "LabelNarrator": "Berättare", - "LabelNarrators": "Berättare", + "LabelNarrator": "Uppläsare", + "LabelNarrators": "Uppläsare", "LabelNew": "Ny", "LabelNewPassword": "Nytt lösenord", "LabelNewestAuthors": "Senast tillagda författare", "LabelNewestEpisodes": "Senast tillagda avsnitt", - "LabelNextBackupDate": "Nästa säkerhetskopia datum", + "LabelNextBackupDate": "Nästa datum för säkerhetskopia", "LabelNextScheduledRun": "Nästa schemalagda körning", "LabelNoEpisodesSelected": "Inga avsnitt valda", "LabelNotFinished": "Ej avslutad", @@ -367,7 +383,7 @@ "LabelPreventIndexing": "Förhindra att ditt flöde indexeras av iTunes och Google-podcastsökmotorer", "LabelPrimaryEbook": "Primär e-bok", "LabelProgress": "Framsteg", - "LabelProvider": "Leverantör", + "LabelProvider": "Källa", "LabelPubDate": "Publiceringsdatum", "LabelPublishYear": "Publiceringsår", "LabelPublisher": "Utgivare", @@ -388,14 +404,14 @@ "LabelRemoveCover": "Ta bort omslag", "LabelSearchTerm": "Sökterm", "LabelSearchTitle": "Sök titel", - "LabelSearchTitleOrASIN": "Sök titel eller ASIN", + "LabelSearchTitleOrASIN": "Sök titel eller ASIN-kod", "LabelSeason": "Säsong", "LabelSelectAllEpisodes": "Välj alla avsnitt", "LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas", "LabelSelectUsers": "Välj användare", "LabelSendEbookToDevice": "Skicka e-bok till...", "LabelSequence": "Sekvens", - "LabelSeries": "Serie", + "LabelSeries": "Serier", "LabelSeriesName": "Serienamn", "LabelSeriesProgress": "Serieframsteg", "LabelSetEbookAsPrimary": "Ange som primär", @@ -403,7 +419,7 @@ "LabelSettingsAudiobooksOnly": "Endast ljudböcker", "LabelSettingsAudiobooksOnlyHelp": "Aktivera detta alternativ kommer att ignorera e-boksfiler om de inte finns inom en ljudboksmapp, i vilket fall de kommer att anges som kompletterande e-böcker", "LabelSettingsBookshelfViewHelp": "Skeumorfisk design med trähyllor", - "LabelSettingsChromecastSupport": "Chromecast-stöd", + "LabelSettingsChromecastSupport": "Stöd för Chromecast", "LabelSettingsDateFormat": "Datumformat", "LabelSettingsDisableWatcher": "Inaktivera Watcher", "LabelSettingsDisableWatcherForLibrary": "Inaktivera mappbevakning för bibliotek", @@ -415,24 +431,24 @@ "LabelSettingsExperimentalFeaturesHelp": "Funktioner under utveckling som behöver din feedback och hjälp med testning. Klicka för att öppna diskussionen på GitHub.", "LabelSettingsFindCovers": "Hitta omslag", "LabelSettingsFindCoversHelp": "Om din ljudbok inte har ett inbäddat omslag eller en omslagsbild i mappen kommer skannern att försöka hitta ett omslag.
Observera: Detta kommer att förlänga skannningstiden", - "LabelSettingsHideSingleBookSeries": "Dölj enboksserier", + "LabelSettingsHideSingleBookSeries": "Dölj serier med en bok", "LabelSettingsHideSingleBookSeriesHelp": "Serier som har en enda bok kommer att döljas från seriesidan och hyllsidan på startsidan.", "LabelSettingsHomePageBookshelfView": "Startsida använd bokhyllvy", "LabelSettingsLibraryBookshelfView": "Bibliotek använd bokhyllvy", "LabelSettingsParseSubtitles": "Analysera undertexter", - "LabelSettingsParseSubtitlesHelp": "Extrahera undertexter från mappnamn för ljudböcker.
Undertext måste vara åtskilda av \" - \"
t.ex. \"Boktitel - En undertitel här\" har undertiteln \"En undertitel här\"", + "LabelSettingsParseSubtitlesHelp": "Extrahera undertitlar från namnet på mappar för ljudböcker.
Undertiteln måste vara åtskilda med ett bindestreck \" - \".
Mappen \"Boktitel - En undertitel här\" har undertiteln \"En undertitel här\"", "LabelSettingsPreferMatchedMetadata": "Föredra matchad metadata", "LabelSettingsPreferMatchedMetadataHelp": "Matchad data kommer att åsidosätta objektdetaljer vid snabbmatchning. Som standard kommer snabbmatchning endast att fylla i saknade detaljer.", - "LabelSettingsSkipMatchingBooksWithASIN": "Hoppa över matchande böcker med ASIN", + "LabelSettingsSkipMatchingBooksWithASIN": "Hoppa över matchande böcker med ASIN-kod", "LabelSettingsSkipMatchingBooksWithISBN": "Hoppa över matchande böcker med ISBN", "LabelSettingsSortingIgnorePrefixes": "Ignorera prefix vid sortering", - "LabelSettingsSortingIgnorePrefixesHelp": "t.ex. för prefixet \"the\" kommer boktiteln \"The Book Title\" att sorteras som \"Book Title, The\"", + "LabelSettingsSortingIgnorePrefixesHelp": "För prefix som t.ex. \"the\" kommer boktiteln \"The Book Title\" att sorteras som \"Book Title, The\"", "LabelSettingsSquareBookCovers": "Använd fyrkantiga bokomslag", "LabelSettingsSquareBookCoversHelp": "Föredrar att använda fyrkantiga omslag över standard 1.6:1 bokomslag", "LabelSettingsStoreCoversWithItem": "Lagra omslag med objekt", - "LabelSettingsStoreCoversWithItemHelp": "Som standard lagras omslag i /metadata/items, att aktivera detta alternativ kommer att lagra omslag i din biblioteksmapp. Endast en fil med namnet \"cover\" kommer att behållas", + "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", "LabelSettingsStoreMetadataWithItem": "Lagra metadata med objekt", - "LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i /metadata/items, att aktivera detta alternativ kommer att lagra metadatafiler i dina biblioteksmappar", + "LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i mappen /metadata/items. Genom att aktivera detta alternativ kommer metadatafilerna att lagras i dina biblioteksmappar", "LabelSettingsTimeFormat": "Tidsformat", "LabelShowAll": "Visa alla", "LabelSize": "Storlek", @@ -457,7 +473,7 @@ "LabelStatsOverallHours": "Totalt antal timmar", "LabelStatsWeekListening": "Veckans lyssnande", "LabelSubtitle": "Underrubrik", - "LabelSupportedFileTypes": "Stödda filtyper", + "LabelSupportedFileTypes": "Filtyper som accepteras", "LabelTag": "Tagg", "LabelTags": "Taggar", "LabelTagsAccessibleToUser": "Taggar tillgängliga för användaren", @@ -467,17 +483,22 @@ "LabelThemeDark": "Mörkt", "LabelThemeLight": "Ljust", "LabelTimeBase": "Tidsbas", + "LabelTimeDurationXHours": "{0} timmar", + "LabelTimeDurationXMinutes": "{0} minuter", + "LabelTimeDurationXSeconds": "{0} sekunder", + "LabelTimeInMinutes": "Tid i minuter", + "LabelTimeLeft": "{0} återstår", "LabelTimeListened": "Tid lyssnad", "LabelTimeListenedToday": "Tid lyssnad idag", - "LabelTimeRemaining": "{0} kvar", + "LabelTimeRemaining": "{0} återstår", "LabelTimeToShift": "Tid att skifta i sekunder", "LabelTitle": "Titel", "LabelToolsEmbedMetadata": "Bädda in metadata", "LabelToolsEmbedMetadataDescription": "Bädda in metadata i ljudfiler, inklusive omslagsbild och kapitel.", "LabelToolsMakeM4b": "Skapa M4B ljudbok", "LabelToolsMakeM4bDescription": "Skapa en .M4B ljudboksfil med inbäddad metadata, omslagsbild och kapitel.", - "LabelToolsSplitM4b": "Dela M4B till MP3-filer", - "LabelToolsSplitM4bDescription": "Skapa MP3-filer från en M4B fil uppdelad i kapitel med inbäddad metadata, omslagsbild och kapitel.", + "LabelToolsSplitM4b": "Dela upp M4B-fil i MP3-filer", + "LabelToolsSplitM4bDescription": "Skapa MP3-filer från en M4B-fil uppdelad i kapitel med inbäddad metadata, omslagsbild och kapitel.", "LabelTotalDuration": "Total varaktighet", "LabelTotalTimeListened": "Total tid lyssnad", "LabelTrackFromFilename": "Spår från filnamn", @@ -486,6 +507,7 @@ "LabelTracksMultiTrack": "Flerspårigt", "LabelTracksNone": "Inga spår", "LabelTracksSingleTrack": "Enspårigt", + "LabelTrailer": "Trailer", "LabelType": "Typ", "LabelUnabridged": "Oavkortad", "LabelUnknown": "Okänd", @@ -496,16 +518,20 @@ "LabelUpdatedAt": "Uppdaterad vid", "LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar", "LabelUploaderDropFiles": "Släpp filer", + "LabelUploaderItemFetchMetadataHelp": "Hämtar automatiskt titel, författare och serier.", "LabelUseChapterTrack": "Använd kapitelspår", "LabelUseFullTrack": "Använd hela spåret", "LabelUser": "Användare", "LabelUsername": "Användarnamn", "LabelValue": "Värde", + "LabelVersion": "Version", "LabelViewBookmarks": "Visa bokmärken", "LabelViewChapters": "Visa kapitel", "LabelViewQueue": "Visa spellista", "LabelVolume": "Volym", "LabelWeekdaysToRun": "Vardagar att köra", + "LabelYearReviewHide": "Dölj sammanställning för året", + "LabelYearReviewShow": "Visa sammanställning för året", "LabelYourAudiobookDuration": "Din ljudboks varaktighet", "LabelYourBookmarks": "Dina bokmärken", "LabelYourPlaylists": "Dina spellistor", @@ -535,22 +561,22 @@ "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 inte avslutade?", "MessageConfirmMarkSeriesFinished": "Är du säker på att du vill markera alla böcker i denna serie som avslutade?", - "MessageConfirmMarkSeriesNotFinished": "Är du säker på att du vill markera alla böcker i denna serie som inte avslutade?", - "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?", + "MessageConfirmMarkSeriesNotFinished": "Är du säker på att du vill markera alla böcker i denna serie som ej avslutade?", + "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?", "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?", - "MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort berättaren \"{0}\"?", + "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å genren \"{0}\" till \"{1}\" för alla objekt?", - "MessageConfirmRenameGenreMergeNote": "Observera: Den här genren finns redan, så de kommer att slås samman.", - "MessageConfirmRenameGenreWarning": "Varning! En liknande genre med annat skrivsätt finns redan \"{0}\".", + "MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på kategori \"{0}\" till \"{1}\" för alla objekt?", + "MessageConfirmRenameGenreMergeNote": "OBS: Den här kategorin finns redan, så de kommer att slås samman.", + "MessageConfirmRenameGenreWarning": "Varning! En liknande kategori med annat skrivsätt finns redan \"{0}\".", "MessageConfirmRenameTag": "Är du säker på att du vill byta namn på taggen \"{0}\" till \"{1}\" för alla objekt?", - "MessageConfirmRenameTagMergeNote": "Observera: Den här taggen finns redan, så de kommer att slås samman.", - "MessageConfirmRenameTagWarning": "Varning! En liknande tagg med annat skrivsätt finns redan \"{0}\".", + "MessageConfirmRenameTagMergeNote": "OBS: Den här taggen finns redan, så de kommer att slås samman.", + "MessageConfirmRenameTagWarning": "VARNING! En liknande tagg med annat skrivsätt finns redan \"{0}\".", "MessageConfirmSendEbookToDevice": "Är du säker på att du vill skicka {0} e-bok \"{1}\" till enheten \"{2}\"?", "MessageDownloadingEpisode": "Laddar ner avsnitt", "MessageDragFilesIntoTrackOrder": "Dra filer till rätt spårordning", @@ -574,7 +600,7 @@ "MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som inte avslutade", "MessageMarkAsFinished": "Markera som avslutad", "MessageMarkAsNotFinished": "Markera som inte avslutad", - "MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från den valda sökleverantören och fylla i tomma detaljer och omslagskonst. Överskriver inte detaljer.", + "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", "MessageNoBackups": "Inga säkerhetskopior", @@ -588,7 +614,7 @@ "MessageNoEpisodeMatchesFound": "Inga matchande avsnitt hittades", "MessageNoEpisodes": "Inga avsnitt", "MessageNoFoldersAvailable": "Inga mappar tillgängliga", - "MessageNoGenres": "Inga genrer", + "MessageNoGenres": "Inga kategorier", "MessageNoIssues": "Inga problem", "MessageNoItems": "Inga objekt", "MessageNoItemsFound": "Inga objekt hittades", @@ -637,7 +663,7 @@ "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 mediefiler hanteras som separata biblioteksobjekt.", + "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.", "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", @@ -645,29 +671,43 @@ "PlaceholderNewPlaylist": "Nytt spellistanamn", "PlaceholderSearch": "Sök...", "PlaceholderSearchEpisode": "Sök avsnitt...", + "StatsTopAuthor": "POPULÄRAST FÖRFATTAREN", + "StatsTopAuthors": "POPULÄRASTE FÖRFATTARNA", + "StatsTopGenre": "Populäraste kategorin", + "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", + "ToastAsinRequired": "En ASIN-kod krävs", "ToastAuthorImageRemoveSuccess": "Författarens bild borttagen", + "ToastAuthorNotFound": "Författaren \"{0}\" kunde inte identifieras", + "ToastAuthorRemoveSuccess": "Författaren har raderats", + "ToastAuthorSearchNotFound": "Författaren kunde inte identifieras", "ToastAuthorUpdateMerged": "Författaren sammanslagen", "ToastAuthorUpdateSuccess": "Författaren uppdaterad", "ToastAuthorUpdateSuccessNoImageFound": "Författaren uppdaterad (ingen bild hittad)", "ToastBackupCreateFailed": "Det gick inte att skapa en säkerhetskopia", - "ToastBackupCreateSuccess": "Säkerhetskopia skapad", - "ToastBackupDeleteFailed": "Det gick inte att ta bort säkerhetskopian", - "ToastBackupDeleteSuccess": "Säkerhetskopan borttagen", - "ToastBackupRestoreFailed": "Det gick inte att återställa säkerhetskopan", - "ToastBackupUploadFailed": "Det gick inte att ladda upp säkerhetskopan", - "ToastBackupUploadSuccess": "Säkerhetskopan uppladdad", + "ToastBackupCreateSuccess": "Säkerhetskopian har skapats", + "ToastBackupDeleteFailed": "Det gick inte att radera säkerhetskopian", + "ToastBackupDeleteSuccess": "Säkerhetskopian har raderats", + "ToastBackupInvalidMaxKeep": "Felaktigt antal kopior av backup har angivits", + "ToastBackupInvalidMaxSize": "Felaktig storlek på backup har angivits", + "ToastBackupRestoreFailed": "Det gick inte att återställa säkerhetskopian", + "ToastBackupUploadFailed": "Det gick inte att ladda upp säkerhetskopian", + "ToastBackupUploadSuccess": "Säkerhetskopian uppladdad", "ToastBatchUpdateFailed": "Batchuppdateringen misslyckades", "ToastBatchUpdateSuccess": "Batchuppdateringen lyckades", "ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket", - "ToastBookmarkCreateSuccess": "Bokmärket tillagt", - "ToastBookmarkRemoveSuccess": "Bokmärket borttaget", - "ToastBookmarkUpdateSuccess": "Bokmärket uppdaterat", + "ToastBookmarkCreateSuccess": "Bokmärket har adderats", + "ToastBookmarkRemoveSuccess": "Bokmärket har raderats", + "ToastBookmarkUpdateSuccess": "Bokmärket har uppdaterats", "ToastChaptersHaveErrors": "Kapitlen har fel", "ToastChaptersMustHaveTitles": "Kapitel måste ha titlar", "ToastCollectionItemsRemoveSuccess": "Objekt borttagna från samlingen", - "ToastCollectionRemoveSuccess": "Samlingen borttagen", - "ToastCollectionUpdateSuccess": "Samlingen uppdaterad", + "ToastCollectionRemoveSuccess": "Samlingen har raderats", + "ToastCollectionUpdateSuccess": "Samlingen har uppdaterats", "ToastItemCoverUpdateSuccess": "Objektets omslag uppdaterat", "ToastItemDetailsUpdateSuccess": "Objektdetaljer uppdaterade", "ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera som färdig", @@ -693,8 +733,8 @@ "ToastRemoveItemFromCollectionSuccess": "Objektet borttaget från samlingen", "ToastSendEbookToDeviceFailed": "Misslyckades med att skicka e-boken till enheten", "ToastSendEbookToDeviceSuccess": "E-boken skickad till enheten \"{0}\"", - "ToastSeriesUpdateFailed": "Serieuppdateringen misslyckades", - "ToastSeriesUpdateSuccess": "Serieuppdateringen lyckades", + "ToastSeriesUpdateFailed": "Uppdateringen av serier misslyckades", + "ToastSeriesUpdateSuccess": "Uppdateringen av serierna lyckades", "ToastSessionDeleteFailed": "Misslyckades med att ta bort sessionen", "ToastSessionDeleteSuccess": "Sessionen borttagen", "ToastSocketConnected": "Socket ansluten", From 2464aac2bfeefa6571dae97aaccf5bf38c9c6e61 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 29 Dec 2024 17:11:46 -0600 Subject: [PATCH 12/52] Version bump v2.17.6 --- 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 807976bd..992600cd 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.17.5", + "version": "2.17.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.17.5", + "version": "2.17.6", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 6f9d9d44..6338c1b1 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.17.5", + "version": "2.17.6", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index efa917dc..c3452537 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.17.5", + "version": "2.17.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.17.5", + "version": "2.17.6", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 2e9c9709..d7b19037 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.17.5", + "version": "2.17.6", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 476933a144f5c1038cda742f71f96bd1694d9d6d Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 30 Dec 2024 16:54:48 -0600 Subject: [PATCH 13/52] Refactor Collection model/controller to not use old Collection object, remove --- server/Database.js | 5 - server/controllers/CollectionController.js | 128 +++++++++------ server/models/Collection.js | 176 +++++---------------- server/models/CollectionBook.js | 9 -- server/models/LibraryItem.js | 2 +- server/objects/Collection.js | 115 -------------- 6 files changed, 122 insertions(+), 313 deletions(-) delete mode 100644 server/objects/Collection.js diff --git a/server/Database.js b/server/Database.js index afb09dae..070c89d7 100644 --- a/server/Database.js +++ b/server/Database.js @@ -406,11 +406,6 @@ class Database { return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook))) } - createBulkCollectionBooks(collectionBooks) { - if (!this.sequelize) return false - return this.models.collectionBook.bulkCreate(collectionBooks) - } - createPlaylistMediaItem(playlistMediaItem) { if (!this.sequelize) return false return this.models.playlistMediaItem.create(playlistMediaItem) diff --git a/server/controllers/CollectionController.js b/server/controllers/CollectionController.js index 3e35c08b..23f8796f 100644 --- a/server/controllers/CollectionController.js +++ b/server/controllers/CollectionController.js @@ -5,13 +5,17 @@ const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') const RssFeedManager = require('../managers/RssFeedManager') -const Collection = require('../objects/Collection') /** * @typedef RequestUserObject * @property {import('../models/User')} user * * @typedef {Request & RequestUserObject} RequestWithUser + * + * @typedef RequestEntityObject + * @property {import('../models/Collection')} collection + * + * @typedef {RequestWithUser & RequestEntityObject} CollectionControllerRequest */ class CollectionController { @@ -25,36 +29,68 @@ class CollectionController { * @param {Response} res */ async create(req, res) { - const newCollection = new Collection() - req.body.userId = req.user.id - if (!newCollection.setData(req.body)) { + const reqBody = req.body || {} + + // Validation + if (!reqBody.name || !reqBody.libraryId) { return res.status(400).send('Invalid collection data') } + const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string') + if (!libraryItemIds.length) { + return res.status(400).send('Invalid collection data. No books') + } - // Create collection record - await Database.collectionModel.createFromOld(newCollection) - - // Get library items in collection - const libraryItemsInCollection = await Database.libraryItemModel.getForCollection(newCollection) - - // Create collectionBook records - let order = 1 - const collectionBooksToAdd = [] - for (const libraryItemId of newCollection.books) { - const libraryItem = libraryItemsInCollection.find((li) => li.id === libraryItemId) - if (libraryItem) { - collectionBooksToAdd.push({ - collectionId: newCollection.id, - bookId: libraryItem.media.id, - order: order++ - }) + // Load library items + const libraryItems = await Database.libraryItemModel.findAll({ + attributes: ['id', 'mediaId', 'mediaType', 'libraryId'], + where: { + id: libraryItemIds, + libraryId: reqBody.libraryId, + mediaType: 'book' } - } - if (collectionBooksToAdd.length) { - await Database.createBulkCollectionBooks(collectionBooksToAdd) + }) + if (libraryItems.length !== libraryItemIds.length) { + return res.status(400).send('Invalid collection data. Invalid books') } - const jsonExpanded = newCollection.toJSONExpanded(libraryItemsInCollection) + /** @type {import('../models/Collection')} */ + let newCollection = null + + const transaction = await Database.sequelize.transaction() + try { + // Create collection + newCollection = await Database.collectionModel.create( + { + libraryId: reqBody.libraryId, + name: reqBody.name, + description: reqBody.description || null + }, + { transaction } + ) + + // Create collectionBooks + const collectionBookPayloads = libraryItemIds.map((llid, index) => { + const libraryItem = libraryItems.find((li) => li.id === llid) + return { + collectionId: newCollection.id, + bookId: libraryItem.mediaId, + order: index + 1 + } + }) + await Database.collectionBookModel.bulkCreate(collectionBookPayloads, { transaction }) + + await transaction.commit() + } catch (error) { + await transaction.rollback() + Logger.error('[CollectionController] create:', error) + return res.status(500).send('Failed to create collection') + } + + // Load books expanded + newCollection.books = await newCollection.getBooksExpandedWithLibraryItem() + + // Note: The old collection model stores expanded libraryItems in the books property + const jsonExpanded = newCollection.toOldJSONExpanded() SocketAuthority.emitter('collection_added', jsonExpanded) res.json(jsonExpanded) } @@ -75,7 +111,7 @@ class CollectionController { /** * GET: /api/collections/:id * - * @param {RequestWithUser} req + * @param {CollectionControllerRequest} req * @param {Response} res */ async findOne(req, res) { @@ -94,7 +130,7 @@ class CollectionController { * PATCH: /api/collections/:id * Update collection * - * @param {RequestWithUser} req + * @param {CollectionControllerRequest} req * @param {Response} res */ async update(req, res) { @@ -158,7 +194,7 @@ class CollectionController { * * @this {import('../routers/ApiRouter')} * - * @param {RequestWithUser} req + * @param {CollectionControllerRequest} req * @param {Response} res */ async delete(req, res) { @@ -178,7 +214,7 @@ class CollectionController { * Add a single book to a collection * Req.body { id: } * - * @param {RequestWithUser} req + * @param {CollectionControllerRequest} req * @param {Response} res */ async addBook(req, res) { @@ -212,7 +248,7 @@ class CollectionController { * Remove a single book from a collection. Re-order books * TODO: bookId is actually libraryItemId. Clients need updating to use bookId * - * @param {RequestWithUser} req + * @param {CollectionControllerRequest} req * @param {Response} res */ async removeBook(req, res) { @@ -257,29 +293,31 @@ class CollectionController { * Add multiple books to collection * Req.body { books: } * - * @param {RequestWithUser} req + * @param {CollectionControllerRequest} req * @param {Response} res */ async addBatch(req, res) { // filter out invalid libraryItemIds const bookIdsToAdd = (req.body.books || []).filter((b) => !!b && typeof b == 'string') if (!bookIdsToAdd.length) { - return res.status(500).send('Invalid request body') + return res.status(400).send('Invalid request body') } // Get library items associated with ids const libraryItems = await Database.libraryItemModel.findAll({ + attributes: ['id', 'mediaId', 'mediaType', 'libraryId'], where: { - id: { - [Sequelize.Op.in]: bookIdsToAdd - } - }, - include: { - model: Database.bookModel + id: bookIdsToAdd, + libraryId: req.collection.libraryId, + mediaType: 'book' } }) + if (!libraryItems.length) { + return res.status(400).send('Invalid request body. No valid books') + } // Get collection books already in collection + /** @type {import('../models/CollectionBook')[]} */ const collectionBooks = await req.collection.getCollectionBooks() let order = collectionBooks.length + 1 @@ -288,10 +326,10 @@ class CollectionController { // Check and set new collection books to add for (const libraryItem of libraryItems) { - if (!collectionBooks.some((cb) => cb.bookId === libraryItem.media.id)) { + if (!collectionBooks.some((cb) => cb.bookId === libraryItem.mediaId)) { collectionBooksToAdd.push({ collectionId: req.collection.id, - bookId: libraryItem.media.id, + bookId: libraryItem.mediaId, order: order++ }) hasUpdated = true @@ -302,7 +340,8 @@ class CollectionController { let jsonExpanded = null if (hasUpdated) { - await Database.createBulkCollectionBooks(collectionBooksToAdd) + await Database.collectionBookModel.bulkCreate(collectionBooksToAdd) + jsonExpanded = await req.collection.getOldJsonExpanded() SocketAuthority.emitter('collection_updated', jsonExpanded) } else { @@ -316,7 +355,7 @@ class CollectionController { * Remove multiple books from collection * Req.body { books: } * - * @param {RequestWithUser} req + * @param {CollectionControllerRequest} req * @param {Response} res */ async removeBatch(req, res) { @@ -329,9 +368,7 @@ class CollectionController { // Get library items associated with ids const libraryItems = await Database.libraryItemModel.findAll({ where: { - id: { - [Sequelize.Op.in]: bookIdsToRemove - } + id: bookIdsToRemove }, include: { model: Database.bookModel @@ -339,6 +376,7 @@ class CollectionController { }) // Get collection books already in collection + /** @type {import('../models/CollectionBook')[]} */ const collectionBooks = await req.collection.getCollectionBooks({ order: [['order', 'ASC']] }) diff --git a/server/models/Collection.js b/server/models/Collection.js index e01ad90a..c8f62e69 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -1,7 +1,5 @@ const { DataTypes, Model, Sequelize } = require('sequelize') -const oldCollection = require('../objects/Collection') - class Collection extends Model { constructor(values, options) { super(values, options) @@ -26,12 +24,12 @@ class Collection extends Model { } /** - * Get all old collections toJSONExpanded, items filtered for user permissions + * Get all toOldJSONExpanded, items filtered for user permissions * * @param {import('./User')} user * @param {string} [libraryId] * @param {string[]} [include] - * @returns {Promise} oldCollection.toJSONExpanded + * @async */ static async getOldCollectionsJsonExpanded(user, libraryId, include) { let collectionWhere = null @@ -79,8 +77,6 @@ class Collection extends Model { // TODO: Handle user permission restrictions on initial query return collections .map((c) => { - const oldCollection = this.getOldCollection(c) - // Filter books using user permissions const books = c.books?.filter((b) => { @@ -95,20 +91,14 @@ class Collection extends Model { return true }) || [] - // Map to library items - const libraryItems = books.map((b) => { - const libraryItem = b.libraryItem - delete b.libraryItem - libraryItem.media = b - return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem) - }) - // Users with restricted permissions will not see this collection - if (!books.length && oldCollection.books.length) { + if (!books.length && c.books.length) { return null } - const collectionExpanded = oldCollection.toJSONExpanded(libraryItems) + this.books = books + + const collectionExpanded = c.toOldJSONExpanded() // Map feed if found if (c.feeds?.length) { @@ -153,69 +143,6 @@ class Collection extends Model { }) } - /** - * Get old collection from Collection - * @param {Collection} collectionExpanded - * @returns {oldCollection} - */ - static getOldCollection(collectionExpanded) { - const libraryItemIds = collectionExpanded.books?.map((b) => b.libraryItem?.id || null).filter((lid) => lid) || [] - return new oldCollection({ - id: collectionExpanded.id, - libraryId: collectionExpanded.libraryId, - name: collectionExpanded.name, - description: collectionExpanded.description, - books: libraryItemIds, - lastUpdate: collectionExpanded.updatedAt.valueOf(), - createdAt: collectionExpanded.createdAt.valueOf() - }) - } - - /** - * - * @param {oldCollection} oldCollection - * @returns {Promise} - */ - static createFromOld(oldCollection) { - const collection = this.getFromOld(oldCollection) - return this.create(collection) - } - - static getFromOld(oldCollection) { - return { - id: oldCollection.id, - name: oldCollection.name, - description: oldCollection.description, - libraryId: oldCollection.libraryId - } - } - - static removeById(collectionId) { - return this.destroy({ - where: { - id: collectionId - } - }) - } - - /** - * Get old collection by id - * @param {string} collectionId - * @returns {Promise} returns null if not found - */ - static async getOldById(collectionId) { - if (!collectionId) return null - const collection = await this.findByPk(collectionId, { - include: { - model: this.sequelize.models.book, - include: this.sequelize.models.libraryItem - }, - order: [[this.sequelize.models.book, this.sequelize.models.collectionBook, 'order', 'ASC']] - }) - if (!collection) return null - return this.getOldCollection(collection) - } - /** * Remove all collections belonging to library * @param {string} libraryId @@ -286,64 +213,37 @@ class Collection extends Model { } /** - * Get old collection toJSONExpanded, items filtered for user permissions + * Get toOldJSONExpanded, items filtered for user permissions * * @param {import('./User')|null} user * @param {string[]} [include] - * @returns {Promise} oldCollection.toJSONExpanded + * @async */ async getOldJsonExpanded(user, include) { - this.books = - (await this.getBooks({ - include: [ - { - model: this.sequelize.models.libraryItem - }, - { - model: this.sequelize.models.author, - through: { - attributes: [] - } - }, - { - model: this.sequelize.models.series, - through: { - attributes: ['sequence'] - } - } - ], - order: [Sequelize.literal('`collectionBook.order` ASC')] - })) || [] + this.books = await this.getBooksExpandedWithLibraryItem() // Filter books using user permissions // TODO: Handle user permission restrictions on initial query - const books = - this.books?.filter((b) => { - if (user) { - if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) { - return false - } - if (b.explicit === true && !user.canAccessExplicitContent) { - return false - } + if (user) { + const books = this.books.filter((b) => { + if (b.tags?.length && !user.checkCanAccessLibraryItemWithTags(b.tags)) { + return false + } + if (b.explicit === true && !user.canAccessExplicitContent) { + return false } return true - }) || [] + }) - // Map to library items - const libraryItems = books.map((b) => { - const libraryItem = b.libraryItem - delete b.libraryItem - libraryItem.media = b - return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem) - }) + // Users with restricted permissions will not see this collection + if (!books.length && this.books.length) { + return null + } - // Users with restricted permissions will not see this collection - if (!books.length && this.books.length) { - return null + this.books = books } - const collectionExpanded = this.toOldJSONExpanded(libraryItems) + const collectionExpanded = this.toOldJSONExpanded() if (include?.includes('rssfeed')) { const feeds = await this.getFeeds() @@ -357,10 +257,10 @@ class Collection extends Model { /** * - * @param {string[]} libraryItemIds + * @param {string[]} [libraryItemIds=[]] * @returns */ - toOldJSON(libraryItemIds) { + toOldJSON(libraryItemIds = []) { return { id: this.id, libraryId: this.libraryId, @@ -372,19 +272,19 @@ class Collection extends Model { } } - /** - * - * @param {import('../objects/LibraryItem')} oldLibraryItems - * @returns - */ - toOldJSONExpanded(oldLibraryItems) { - const json = this.toOldJSON(oldLibraryItems.map((li) => li.id)) - json.books = json.books - .map((libraryItemId) => { - const book = oldLibraryItems.find((li) => li.id === libraryItemId) - return book ? book.toJSONExpanded() : null - }) - .filter((b) => !!b) + toOldJSONExpanded() { + if (!this.books) { + throw new Error('Books are required to expand Collection') + } + + const json = this.toOldJSON() + json.books = this.books.map((book) => { + const libraryItem = book.libraryItem + delete book.libraryItem + libraryItem.media = book + return this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONExpanded() + }) + return json } } diff --git a/server/models/CollectionBook.js b/server/models/CollectionBook.js index e04da3b2..e706d68c 100644 --- a/server/models/CollectionBook.js +++ b/server/models/CollectionBook.js @@ -16,15 +16,6 @@ class CollectionBook extends Model { this.createdAt } - static removeByIds(collectionId, bookId) { - return this.destroy({ - where: { - bookId, - collectionId - } - }) - } - static init(sequelize) { super.init( { diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 8ebed1d5..bed96631 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -123,7 +123,7 @@ class LibraryItem extends Model { } /** - * Currently unused because this is too slow and uses too much mem + * * @param {import('sequelize').WhereOptions} [where] * @returns {Array} old library items */ diff --git a/server/objects/Collection.js b/server/objects/Collection.js deleted file mode 100644 index 970d714b..00000000 --- a/server/objects/Collection.js +++ /dev/null @@ -1,115 +0,0 @@ -const uuidv4 = require("uuid").v4 - -class Collection { - constructor(collection) { - this.id = null - this.libraryId = null - - this.name = null - this.description = null - - this.cover = null - this.coverFullPath = null - this.books = [] - - this.lastUpdate = null - this.createdAt = null - - if (collection) { - this.construct(collection) - } - } - - toJSON() { - return { - id: this.id, - libraryId: this.libraryId, - name: this.name, - description: this.description, - cover: this.cover, - coverFullPath: this.coverFullPath, - books: [...this.books], - lastUpdate: this.lastUpdate, - createdAt: this.createdAt - } - } - - toJSONExpanded(libraryItems, minifiedBooks = false) { - const json = this.toJSON() - json.books = json.books.map(bookId => { - const book = libraryItems.find(li => li.id === bookId) - return book ? minifiedBooks ? book.toJSONMinified() : book.toJSONExpanded() : null - }).filter(b => !!b) - return json - } - - // Expanded and filtered out items not accessible to user - toJSONExpandedForUser(user, libraryItems) { - const json = this.toJSON() - json.books = json.books.map(libraryItemId => { - const libraryItem = libraryItems.find(li => li.id === libraryItemId) - return libraryItem ? libraryItem.toJSONExpanded() : null - }).filter(li => { - return li && user.checkCanAccessLibraryItem(li) - }) - return json - } - - construct(collection) { - this.id = collection.id - this.libraryId = collection.libraryId - this.name = collection.name - this.description = collection.description || null - this.cover = collection.cover || null - this.coverFullPath = collection.coverFullPath || null - this.books = collection.books ? [...collection.books] : [] - this.lastUpdate = collection.lastUpdate || null - this.createdAt = collection.createdAt || null - } - - setData(data) { - if (!data.libraryId || !data.name) { - return false - } - this.id = uuidv4() - this.libraryId = data.libraryId - this.name = data.name - this.description = data.description || null - this.cover = data.cover || null - this.coverFullPath = data.coverFullPath || null - this.books = data.books ? [...data.books] : [] - this.lastUpdate = Date.now() - this.createdAt = Date.now() - return true - } - - addBook(bookId) { - this.books.push(bookId) - this.lastUpdate = Date.now() - } - - removeBook(bookId) { - this.books = this.books.filter(bid => bid !== bookId) - this.lastUpdate = Date.now() - } - - update(payload) { - let hasUpdates = false - for (const key in payload) { - if (key === 'books') { - if (payload.books && this.books.join(',') !== payload.books.join(',')) { - this.books = [...payload.books] - hasUpdates = true - } - } else if (this[key] !== undefined && this[key] !== payload[key]) { - hasUpdates = true - this[key] = payload[key] - } - } - if (hasUpdates) { - this.lastUpdate = Date.now() - } - return hasUpdates - } -} -module.exports = Collection \ No newline at end of file From 88a0e75576902f25635784328693591f2f7149af Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 30 Dec 2024 17:07:41 -0600 Subject: [PATCH 14/52] Remove collection add/create modal toasts --- client/components/modals/collections/AddCreateModal.vue | 7 +------ client/strings/bg.json | 1 - client/strings/bn.json | 2 -- client/strings/ca.json | 2 -- client/strings/cs.json | 1 - client/strings/da.json | 1 - client/strings/de.json | 2 -- client/strings/en-us.json | 2 -- client/strings/es.json | 2 -- client/strings/et.json | 1 - client/strings/fr.json | 2 -- client/strings/he.json | 1 - client/strings/hr.json | 2 -- client/strings/hu.json | 1 - client/strings/it.json | 2 -- client/strings/lt.json | 2 -- client/strings/nl.json | 2 -- client/strings/no.json | 2 -- client/strings/pl.json | 1 - client/strings/pt-br.json | 1 - client/strings/ru.json | 2 -- client/strings/sl.json | 2 -- client/strings/sv.json | 1 - client/strings/uk.json | 2 -- client/strings/vi-vn.json | 1 - client/strings/zh-cn.json | 2 -- client/strings/zh-tw.json | 1 - 27 files changed, 1 insertion(+), 47 deletions(-) diff --git a/client/components/modals/collections/AddCreateModal.vue b/client/components/modals/collections/AddCreateModal.vue index 7c22525f..94a7f0e7 100644 --- a/client/components/modals/collections/AddCreateModal.vue +++ b/client/components/modals/collections/AddCreateModal.vue @@ -138,7 +138,6 @@ export default { .$post(`/api/collections/${collection.id}/batch/remove`, { books: this.selectedBookIds }) .then((updatedCollection) => { console.log(`Books removed from collection`, updatedCollection) - this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess) this.processing = false }) .catch((error) => { @@ -152,7 +151,6 @@ export default { .$delete(`/api/collections/${collection.id}/book/${this.selectedLibraryItemId}`) .then((updatedCollection) => { console.log(`Book removed from collection`, updatedCollection) - this.$toast.success(this.$strings.ToastCollectionItemsRemoveSuccess) this.processing = false }) .catch((error) => { @@ -167,12 +165,11 @@ export default { this.processing = true if (this.showBatchCollectionModal) { - // BATCH Remove books + // BATCH Add books this.$axios .$post(`/api/collections/${collection.id}/batch/add`, { books: this.selectedBookIds }) .then((updatedCollection) => { console.log(`Books added to collection`, updatedCollection) - this.$toast.success(this.$strings.ToastCollectionItemsAddSuccess) this.processing = false }) .catch((error) => { @@ -187,7 +184,6 @@ export default { .$post(`/api/collections/${collection.id}/book`, { id: this.selectedLibraryItemId }) .then((updatedCollection) => { console.log(`Book added to collection`, updatedCollection) - this.$toast.success(this.$strings.ToastCollectionItemsAddSuccess) this.processing = false }) .catch((error) => { @@ -214,7 +210,6 @@ export default { .$post('/api/collections', newCollection) .then((data) => { console.log('New Collection Created', data) - this.$toast.success(`Collection "${data.name}" created`) this.processing = false this.newCollectionName = '' }) diff --git a/client/strings/bg.json b/client/strings/bg.json index 8e124d06..bc4db4f6 100644 --- a/client/strings/bg.json +++ b/client/strings/bg.json @@ -729,7 +729,6 @@ "ToastBookmarkUpdateSuccess": "Отметката е обновена", "ToastChaptersHaveErrors": "Главите имат грешки", "ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия", - "ToastCollectionItemsRemoveSuccess": "Елемент(и) премахнати от колекция", "ToastCollectionRemoveSuccess": "Колекцията е премахната", "ToastCollectionUpdateSuccess": "Колекцията е обновена", "ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена", diff --git a/client/strings/bn.json b/client/strings/bn.json index 16f8a447..e88db2e5 100644 --- a/client/strings/bn.json +++ b/client/strings/bn.json @@ -951,8 +951,6 @@ "ToastChaptersRemoved": "অধ্যায়গুলো মুছে ফেলা হয়েছে", "ToastChaptersUpdated": "অধ্যায় আপডেট করা হয়েছে", "ToastCollectionItemsAddFailed": "আইটেম(গুলি) সংগ্রহে যোগ করা ব্যর্থ হয়েছে", - "ToastCollectionItemsAddSuccess": "আইটেম(গুলি) সংগ্রহে যোগ করা সফল হয়েছে", - "ToastCollectionItemsRemoveSuccess": "আইটেম(গুলি) সংগ্রহ থেকে সরানো হয়েছে", "ToastCollectionRemoveSuccess": "সংগ্রহ সরানো হয়েছে", "ToastCollectionUpdateSuccess": "সংগ্রহ আপডেট করা হয়েছে", "ToastCoverUpdateFailed": "কভার আপডেট ব্যর্থ হয়েছে", diff --git a/client/strings/ca.json b/client/strings/ca.json index f7e85ae2..bebb17e9 100644 --- a/client/strings/ca.json +++ b/client/strings/ca.json @@ -904,8 +904,6 @@ "ToastChaptersRemoved": "Capítols eliminats", "ToastChaptersUpdated": "Capítols actualitzats", "ToastCollectionItemsAddFailed": "Error en afegir elements a la col·lecció", - "ToastCollectionItemsAddSuccess": "Elements afegits a la col·lecció", - "ToastCollectionItemsRemoveSuccess": "Elements eliminats de la col·lecció", "ToastCollectionRemoveSuccess": "Col·lecció eliminada", "ToastCollectionUpdateSuccess": "Col·lecció actualitzada", "ToastCoverUpdateFailed": "Error en actualitzar la portada", diff --git a/client/strings/cs.json b/client/strings/cs.json index 684a25e1..3db3ddaa 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -943,7 +943,6 @@ "ToastChaptersHaveErrors": "Kapitoly obsahují chyby", "ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy", "ToastChaptersRemoved": "Kapitoly odstraněny", - "ToastCollectionItemsRemoveSuccess": "Položky odstraněny z kolekce", "ToastCollectionRemoveSuccess": "Kolekce odstraněna", "ToastCollectionUpdateSuccess": "Kolekce aktualizována", "ToastCoverUpdateFailed": "Aktualizace obálky selhala", diff --git a/client/strings/da.json b/client/strings/da.json index 0f7af2c4..0f7b1eed 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -640,7 +640,6 @@ "ToastBookmarkUpdateSuccess": "Bogmærke opdateret", "ToastChaptersHaveErrors": "Kapitler har fejl", "ToastChaptersMustHaveTitles": "Kapitler skal have titler", - "ToastCollectionItemsRemoveSuccess": "Element(er) fjernet fra samlingen", "ToastCollectionRemoveSuccess": "Samling fjernet", "ToastCollectionUpdateSuccess": "Samling opdateret", "ToastItemCoverUpdateSuccess": "Varens omslag opdateret", diff --git a/client/strings/de.json b/client/strings/de.json index db298c75..6ac20fab 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -959,8 +959,6 @@ "ToastChaptersRemoved": "Kapitel entfernt", "ToastChaptersUpdated": "Kapitel aktualisiert", "ToastCollectionItemsAddFailed": "Das Hinzufügen von Element(en) zur Sammlung ist fehlgeschlagen", - "ToastCollectionItemsAddSuccess": "Element(e) erfolgreich zur Sammlung hinzugefügt", - "ToastCollectionItemsRemoveSuccess": "Medien aus der Sammlung entfernt", "ToastCollectionRemoveSuccess": "Sammlung entfernt", "ToastCollectionUpdateSuccess": "Sammlung aktualisiert", "ToastCoverUpdateFailed": "Cover-Update fehlgeschlagen", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index eee94abf..e94e5197 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -961,8 +961,6 @@ "ToastChaptersRemoved": "Chapters removed", "ToastChaptersUpdated": "Chapters updated", "ToastCollectionItemsAddFailed": "Item(s) added to collection failed", - "ToastCollectionItemsAddSuccess": "Item(s) added to collection success", - "ToastCollectionItemsRemoveSuccess": "Item(s) removed from collection", "ToastCollectionRemoveSuccess": "Collection removed", "ToastCollectionUpdateSuccess": "Collection updated", "ToastCoverUpdateFailed": "Cover update failed", diff --git a/client/strings/es.json b/client/strings/es.json index fa4f8c45..4196b6dd 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -959,8 +959,6 @@ "ToastChaptersRemoved": "Capítulos eliminados", "ToastChaptersUpdated": "Capítulos actualizados", "ToastCollectionItemsAddFailed": "Artículo(s) añadido(s) a la colección fallido(s)", - "ToastCollectionItemsAddSuccess": "Artículo(s) añadido(s) a la colección correctamente", - "ToastCollectionItemsRemoveSuccess": "Elementos(s) removidos de la colección", "ToastCollectionRemoveSuccess": "Colección removida", "ToastCollectionUpdateSuccess": "Colección actualizada", "ToastCoverUpdateFailed": "Error al actualizar la cubierta", diff --git a/client/strings/et.json b/client/strings/et.json index 8d256c01..835666f8 100644 --- a/client/strings/et.json +++ b/client/strings/et.json @@ -713,7 +713,6 @@ "ToastBookmarkUpdateSuccess": "Järjehoidja värskendatud", "ToastChaptersHaveErrors": "Peatükkidel on vigu", "ToastChaptersMustHaveTitles": "Peatükkidel peab olema pealkiri", - "ToastCollectionItemsRemoveSuccess": "Üksus(ed) eemaldatud kogumist", "ToastCollectionRemoveSuccess": "Kogum eemaldatud", "ToastCollectionUpdateSuccess": "Kogum värskendatud", "ToastItemCoverUpdateSuccess": "Üksuse kaas värskendatud", diff --git a/client/strings/fr.json b/client/strings/fr.json index 2c92bf7c..2dfe5b0b 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -953,8 +953,6 @@ "ToastChaptersRemoved": "Chapitres supprimés", "ToastChaptersUpdated": "Chapitres mis à jour", "ToastCollectionItemsAddFailed": "Échec de l’ajout de(s) élément(s) à la collection", - "ToastCollectionItemsAddSuccess": "Ajout de(s) élément(s) à la collection réussi", - "ToastCollectionItemsRemoveSuccess": "Élément(s) supprimé(s) de la collection", "ToastCollectionRemoveSuccess": "Collection supprimée", "ToastCollectionUpdateSuccess": "Collection mise à jour", "ToastCoverUpdateFailed": "Échec de la mise à jour de la couverture", diff --git a/client/strings/he.json b/client/strings/he.json index a93d3f05..71a6df9e 100644 --- a/client/strings/he.json +++ b/client/strings/he.json @@ -744,7 +744,6 @@ "ToastBookmarkUpdateSuccess": "הסימניה עודכנה בהצלחה", "ToastChaptersHaveErrors": "פרקים מכילים שגיאות", "ToastChaptersMustHaveTitles": "פרקים חייבים לכלול כותרות", - "ToastCollectionItemsRemoveSuccess": "הפריט(ים) הוסרו מהאוסף בהצלחה", "ToastCollectionRemoveSuccess": "האוסף הוסר בהצלחה", "ToastCollectionUpdateSuccess": "האוסף עודכן בהצלחה", "ToastItemCoverUpdateSuccess": "כריכת הפריט עודכנה בהצלחה", diff --git a/client/strings/hr.json b/client/strings/hr.json index bc66b8a5..8b824128 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -959,8 +959,6 @@ "ToastChaptersRemoved": "Poglavlja uklonjena", "ToastChaptersUpdated": "Poglavlja su ažurirana", "ToastCollectionItemsAddFailed": "Neuspješno dodavanje stavki u zbirku", - "ToastCollectionItemsAddSuccess": "Uspješno dodavanje stavki u zbirku", - "ToastCollectionItemsRemoveSuccess": "Stavke izbrisane iz zbirke", "ToastCollectionRemoveSuccess": "Zbirka izbrisana", "ToastCollectionUpdateSuccess": "Zbirka ažurirana", "ToastCoverUpdateFailed": "Ažuriranje naslovnice nije uspjelo", diff --git a/client/strings/hu.json b/client/strings/hu.json index 10807375..25f9a9be 100644 --- a/client/strings/hu.json +++ b/client/strings/hu.json @@ -945,7 +945,6 @@ "ToastChaptersMustHaveTitles": "A fejezeteknek címekkel kell rendelkezniük", "ToastChaptersRemoved": "Fejezetek eltávolítva", "ToastChaptersUpdated": "Fejezetek frissítve", - "ToastCollectionItemsRemoveSuccess": "Elem(ek) eltávolítva a gyűjteményből", "ToastCollectionRemoveSuccess": "Gyűjtemény eltávolítva", "ToastCollectionUpdateSuccess": "Gyűjtemény frissítve", "ToastCoverUpdateFailed": "A borító frissítése nem sikerült", diff --git a/client/strings/it.json b/client/strings/it.json index 70490e3b..633dea89 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -950,8 +950,6 @@ "ToastChaptersRemoved": "Capitoli rimossi", "ToastChaptersUpdated": "Capitoli aggiornati", "ToastCollectionItemsAddFailed": "l'aggiunta dell'elemento(i) alla raccolta non è riuscito", - "ToastCollectionItemsAddSuccess": "L'aggiunta dell'elemento(i) alla raccolta è riuscito", - "ToastCollectionItemsRemoveSuccess": "Oggetto(i) rimossi dalla Raccolta", "ToastCollectionRemoveSuccess": "Collezione rimossa", "ToastCollectionUpdateSuccess": "Raccolta aggiornata", "ToastCoverUpdateFailed": "Aggiornamento cover fallito", diff --git a/client/strings/lt.json b/client/strings/lt.json index e1a63510..8c902f5a 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -666,8 +666,6 @@ "ToastChaptersMustHaveTitles": "Skyriai turi turėti pavadinimus", "ToastChaptersRemoved": "Skyriai pašalinti", "ToastCollectionItemsAddFailed": "Nepavyko pridėti į kolekciją", - "ToastCollectionItemsAddSuccess": "Pridėta į kolekciją", - "ToastCollectionItemsRemoveSuccess": "Elementai pašalinti iš kolekcijos", "ToastCollectionRemoveSuccess": "Kolekcija pašalinta", "ToastCollectionUpdateSuccess": "Kolekcija atnaujinta", "ToastCoverUpdateFailed": "Viršelio atnaujinimas nepavyko", diff --git a/client/strings/nl.json b/client/strings/nl.json index bc5a40ca..97bb1d01 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -946,8 +946,6 @@ "ToastChaptersRemoved": "Hoofdstukken verwijderd", "ToastChaptersUpdated": "Hoofdstukken bijgewerkt", "ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt", - "ToastCollectionItemsAddSuccess": "Item(s) toegevoegd aan collectie gelukt", - "ToastCollectionItemsRemoveSuccess": "Onderdeel (of onderdelen) verwijderd uit collectie", "ToastCollectionRemoveSuccess": "Collectie verwijderd", "ToastCollectionUpdateSuccess": "Collectie bijgewerkt", "ToastCoverUpdateFailed": "Cover update mislukt", diff --git a/client/strings/no.json b/client/strings/no.json index ddfc2489..553a852e 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -882,8 +882,6 @@ "ToastChaptersRemoved": "Kapitler fjernet", "ToastChaptersUpdated": "Kapitler oppdatert", "ToastCollectionItemsAddFailed": "Feil med å legge til element(er)", - "ToastCollectionItemsAddSuccess": "Element(er) lagt til samlingen", - "ToastCollectionItemsRemoveSuccess": "Gjenstand(er) fjernet fra samling", "ToastCollectionRemoveSuccess": "Samling fjernet", "ToastCollectionUpdateSuccess": "samlingupdated", "ToastCoverUpdateFailed": "Oppdatering av bilde feilet", diff --git a/client/strings/pl.json b/client/strings/pl.json index 85c7b769..d8cf9848 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -772,7 +772,6 @@ "ToastBookmarkCreateSuccess": "Dodano zakładkę", "ToastBookmarkRemoveSuccess": "Zakładka została usunięta", "ToastBookmarkUpdateSuccess": "Zaktualizowano zakładkę", - "ToastCollectionItemsRemoveSuccess": "Przedmiot(y) zostały usunięte z kolekcji", "ToastCollectionRemoveSuccess": "Kolekcja usunięta", "ToastCollectionUpdateSuccess": "Zaktualizowano kolekcję", "ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę", diff --git a/client/strings/pt-br.json b/client/strings/pt-br.json index 7df7c47d..b6d7e7e3 100644 --- a/client/strings/pt-br.json +++ b/client/strings/pt-br.json @@ -735,7 +735,6 @@ "ToastCachePurgeSuccess": "Cache apagado com sucesso", "ToastChaptersHaveErrors": "Capítulos com erro", "ToastChaptersMustHaveTitles": "Capítulos precisam ter títulos", - "ToastCollectionItemsRemoveSuccess": "Item(ns) removidos da coleção", "ToastCollectionRemoveSuccess": "Coleção removida", "ToastCollectionUpdateSuccess": "Coleção atualizada", "ToastDeleteFileFailed": "Falha ao apagar arquivo", diff --git a/client/strings/ru.json b/client/strings/ru.json index 3e07ea02..41129bde 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -959,8 +959,6 @@ "ToastChaptersRemoved": "Удалены главы", "ToastChaptersUpdated": "Обновленные главы", "ToastCollectionItemsAddFailed": "Не удалось добавить элемент(ы) в коллекцию", - "ToastCollectionItemsAddSuccess": "Элемент(ы) добавлены в коллекцию", - "ToastCollectionItemsRemoveSuccess": "Элемент(ы), удалены из коллекции", "ToastCollectionRemoveSuccess": "Коллекция удалена", "ToastCollectionUpdateSuccess": "Коллекция обновлена", "ToastCoverUpdateFailed": "Не удалось обновить обложку", diff --git a/client/strings/sl.json b/client/strings/sl.json index a12643f4..3c304eaa 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -959,8 +959,6 @@ "ToastChaptersRemoved": "Poglavja so odstranjena", "ToastChaptersUpdated": "Poglavja so posodobljena", "ToastCollectionItemsAddFailed": "Dodajanje elementov v zbirko ni uspelo", - "ToastCollectionItemsAddSuccess": "Dodajanje elementov v zbirko je bilo uspešno", - "ToastCollectionItemsRemoveSuccess": "Elementi so bili odstranjeni iz zbirke", "ToastCollectionRemoveSuccess": "Zbirka je bila odstranjena", "ToastCollectionUpdateSuccess": "Zbirka je bila posodobljena", "ToastCoverUpdateFailed": "Posodobitev naslovnice ni uspela", diff --git a/client/strings/sv.json b/client/strings/sv.json index 7513e8db..8e60e2cd 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -705,7 +705,6 @@ "ToastBookmarkUpdateSuccess": "Bokmärket har uppdaterats", "ToastChaptersHaveErrors": "Kapitlen har fel", "ToastChaptersMustHaveTitles": "Kapitel måste ha titlar", - "ToastCollectionItemsRemoveSuccess": "Objekt borttagna från samlingen", "ToastCollectionRemoveSuccess": "Samlingen har raderats", "ToastCollectionUpdateSuccess": "Samlingen har uppdaterats", "ToastItemCoverUpdateSuccess": "Objektets omslag uppdaterat", diff --git a/client/strings/uk.json b/client/strings/uk.json index 3d0150f5..4b745264 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -959,8 +959,6 @@ "ToastChaptersRemoved": "Розділи видалені", "ToastChaptersUpdated": "Розділи оновлені", "ToastCollectionItemsAddFailed": "Не вдалося додати елемент(и) до колекції", - "ToastCollectionItemsAddSuccess": "Елемент(и) успішно додано до колекції", - "ToastCollectionItemsRemoveSuccess": "Елемент(и) видалено з добірки", "ToastCollectionRemoveSuccess": "Добірку видалено", "ToastCollectionUpdateSuccess": "Добірку оновлено", "ToastCoverUpdateFailed": "Не вдалося оновити обкладинку", diff --git a/client/strings/vi-vn.json b/client/strings/vi-vn.json index 8b0ca165..a176dba3 100644 --- a/client/strings/vi-vn.json +++ b/client/strings/vi-vn.json @@ -683,7 +683,6 @@ "ToastBookmarkUpdateSuccess": "Đánh dấu đã được cập nhật", "ToastChaptersHaveErrors": "Các chương có lỗi", "ToastChaptersMustHaveTitles": "Các chương phải có tiêu đề", - "ToastCollectionItemsRemoveSuccess": "Mục đã được xóa khỏi bộ sưu tập", "ToastCollectionRemoveSuccess": "Bộ sưu tập đã được xóa", "ToastCollectionUpdateSuccess": "Bộ sưu tập đã được cập nhật", "ToastItemCoverUpdateSuccess": "Ảnh bìa mục đã được cập nhật", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 3a8b432f..472ef84e 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -959,8 +959,6 @@ "ToastChaptersRemoved": "已删除章节", "ToastChaptersUpdated": "章节已更新", "ToastCollectionItemsAddFailed": "项目添加到收藏夹失败", - "ToastCollectionItemsAddSuccess": "项目添加到收藏夹成功", - "ToastCollectionItemsRemoveSuccess": "项目从收藏夹移除", "ToastCollectionRemoveSuccess": "收藏夹已删除", "ToastCollectionUpdateSuccess": "收藏夹已更新", "ToastCoverUpdateFailed": "封面更新失败", diff --git a/client/strings/zh-tw.json b/client/strings/zh-tw.json index 080d1bac..96f878c2 100644 --- a/client/strings/zh-tw.json +++ b/client/strings/zh-tw.json @@ -727,7 +727,6 @@ "ToastBookmarkUpdateSuccess": "書籤已更新", "ToastChaptersHaveErrors": "章節有錯誤", "ToastChaptersMustHaveTitles": "章節必須有標題", - "ToastCollectionItemsRemoveSuccess": "項目從收藏夾移除", "ToastCollectionRemoveSuccess": "收藏夾已刪除", "ToastCollectionUpdateSuccess": "收藏夾已更新", "ToastItemCoverUpdateSuccess": "項目封面已更新", From 9785bc02ea8085f3981b8797635179eb11cdfec0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 31 Dec 2024 17:01:42 -0600 Subject: [PATCH 15/52] Update Playlist model & controller to remove usage of old Playlist object, remove old Playlist --- server/Database.js | 10 - server/controllers/CollectionController.js | 3 + server/controllers/PlaylistController.js | 495 ++++++++++++--------- server/models/Playlist.js | 297 ++++++------- server/models/PlaylistMediaItem.js | 5 + server/models/PodcastEpisode.js | 56 +++ server/objects/Playlist.js | 148 ------ 7 files changed, 501 insertions(+), 513 deletions(-) delete mode 100644 server/objects/Playlist.js diff --git a/server/Database.js b/server/Database.js index 070c89d7..bd14fbd5 100644 --- a/server/Database.js +++ b/server/Database.js @@ -406,16 +406,6 @@ class Database { return Promise.all(oldBooks.map((oldBook) => this.models.book.saveFromOld(oldBook))) } - createPlaylistMediaItem(playlistMediaItem) { - if (!this.sequelize) return false - return this.models.playlistMediaItem.create(playlistMediaItem) - } - - createBulkPlaylistMediaItems(playlistMediaItems) { - if (!this.sequelize) return false - return this.models.playlistMediaItem.bulkCreate(playlistMediaItems) - } - async createLibraryItem(oldLibraryItem) { if (!this.sequelize) return false await oldLibraryItem.saveMetadata() diff --git a/server/controllers/CollectionController.js b/server/controllers/CollectionController.js index 23f8796f..6986f2b7 100644 --- a/server/controllers/CollectionController.js +++ b/server/controllers/CollectionController.js @@ -35,6 +35,9 @@ class CollectionController { if (!reqBody.name || !reqBody.libraryId) { return res.status(400).send('Invalid collection data') } + if (reqBody.description && typeof reqBody.description !== 'string') { + return res.status(400).send('Invalid collection description') + } const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string') if (!libraryItemIds.length) { return res.status(400).send('Invalid collection data. No books') diff --git a/server/controllers/PlaylistController.js b/server/controllers/PlaylistController.js index 5b84fe16..ee4fef5e 100644 --- a/server/controllers/PlaylistController.js +++ b/server/controllers/PlaylistController.js @@ -3,13 +3,16 @@ const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') -const Playlist = require('../objects/Playlist') - /** * @typedef RequestUserObject * @property {import('../models/User')} user * * @typedef {Request & RequestUserObject} RequestWithUser + * + * @typedef RequestEntityObject + * @property {import('../models/Playlist')} playlist + * + * @typedef {RequestWithUser & RequestEntityObject} PlaylistControllerRequest */ class PlaylistController { @@ -23,48 +26,103 @@ class PlaylistController { * @param {Response} res */ async create(req, res) { - const oldPlaylist = new Playlist() - req.body.userId = req.user.id - const success = oldPlaylist.setData(req.body) - if (!success) { - return res.status(400).send('Invalid playlist request data') + const reqBody = req.body || {} + + // Validation + if (!reqBody.name || !reqBody.libraryId) { + return res.status(400).send('Invalid playlist data') + } + if (reqBody.description && typeof reqBody.description !== 'string') { + return res.status(400).send('Invalid playlist description') + } + const items = reqBody.items || [] + const isPodcast = items.some((i) => i.episodeId) + const libraryItemIds = new Set() + for (const item of items) { + if (!item.libraryItemId || typeof item.libraryItemId !== 'string') { + return res.status(400).send('Invalid playlist item') + } + if (isPodcast && (!item.episodeId || typeof item.episodeId !== 'string')) { + return res.status(400).send('Invalid playlist item episodeId') + } else if (!isPodcast && item.episodeId) { + return res.status(400).send('Invalid playlist item episodeId') + } + libraryItemIds.add(item.libraryItemId) } - // Create Playlist record - const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist) - - // Lookup all library items in playlist - const libraryItemIds = oldPlaylist.items.map((i) => i.libraryItemId).filter((i) => i) - const libraryItemsInPlaylist = await Database.libraryItemModel.findAll({ + // Load library items + const libraryItems = await Database.libraryItemModel.findAll({ + attributes: ['id', 'mediaId', 'mediaType', 'libraryId'], where: { - id: libraryItemIds + id: Array.from(libraryItemIds), + libraryId: reqBody.libraryId, + mediaType: isPodcast ? 'podcast' : 'book' } }) + if (libraryItems.length !== libraryItemIds.size) { + return res.status(400).send('Invalid playlist data. Invalid items') + } - // Create playlistMediaItem records - const mediaItemsToAdd = [] - let order = 1 - for (const mediaItemObj of oldPlaylist.items) { - const libraryItem = libraryItemsInPlaylist.find((li) => li.id === mediaItemObj.libraryItemId) - if (!libraryItem) continue - - mediaItemsToAdd.push({ - mediaItemId: mediaItemObj.episodeId || libraryItem.mediaId, - mediaItemType: mediaItemObj.episodeId ? 'podcastEpisode' : 'book', - playlistId: oldPlaylist.id, - order: order++ + // Validate podcast episodes + if (isPodcast) { + const podcastEpisodeIds = items.map((i) => i.episodeId) + const podcastEpisodes = await Database.podcastEpisodeModel.findAll({ + attributes: ['id'], + where: { + id: podcastEpisodeIds + } }) - } - if (mediaItemsToAdd.length) { - await Database.createBulkPlaylistMediaItems(mediaItemsToAdd) + if (podcastEpisodes.length !== podcastEpisodeIds.length) { + return res.status(400).send('Invalid playlist data. Invalid podcast episodes') + } } - const jsonExpanded = await newPlaylist.getOldJsonExpanded() - SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded) - res.json(jsonExpanded) + const transaction = await Database.sequelize.transaction() + try { + // Create playlist + const newPlaylist = await Database.playlistModel.create( + { + libraryId: reqBody.libraryId, + userId: req.user.id, + name: reqBody.name, + description: reqBody.description || null + }, + { transaction } + ) + + // Create playlistMediaItems + const playlistItemPayloads = [] + for (const [index, item] of items.entries()) { + const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId) + playlistItemPayloads.push({ + playlistId: newPlaylist.id, + mediaItemId: item.episodeId || libraryItem.mediaId, + mediaItemType: item.episodeId ? 'podcastEpisode' : 'book', + order: index + 1 + }) + } + + await Database.playlistMediaItemModel.bulkCreate(playlistItemPayloads, { transaction }) + + await transaction.commit() + + newPlaylist.playlistMediaItems = await newPlaylist.getMediaItemsExpandedWithLibraryItem() + + const jsonExpanded = newPlaylist.toOldJSONExpanded() + SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded) + res.json(jsonExpanded) + } catch (error) { + await transaction.rollback() + Logger.error('[PlaylistController] create:', error) + res.status(500).send('Failed to create playlist') + } } /** + * @deprecated - Use /api/libraries/:libraryId/playlists + * This is not used by Abs web client or mobile apps + * TODO: Remove this endpoint or refactor it and make it the primary + * * GET: /api/playlists * Get all playlists for user * @@ -72,68 +130,89 @@ class PlaylistController { * @param {Response} res */ async findAllForUser(req, res) { - const playlistsForUser = await Database.playlistModel.findAll({ - where: { - userId: req.user.id - } - }) - const playlists = [] - for (const playlist of playlistsForUser) { - const jsonExpanded = await playlist.getOldJsonExpanded() - playlists.push(jsonExpanded) - } + const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id, req.params.libraryId) res.json({ - playlists + playlists: playlistsForUser }) } /** * GET: /api/playlists/:id * - * @param {RequestWithUser} req + * @param {PlaylistControllerRequest} req * @param {Response} res */ async findOne(req, res) { - const jsonExpanded = await req.playlist.getOldJsonExpanded() - res.json(jsonExpanded) + req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem() + res.json(req.playlist.toOldJSONExpanded()) } /** * PATCH: /api/playlists/:id * Update playlist * - * @param {RequestWithUser} req + * Used for updating name and description or reordering items + * + * @param {PlaylistControllerRequest} req * @param {Response} res */ async update(req, res) { - const updatedPlaylist = req.playlist.set(req.body) - let wasUpdated = false - const changed = updatedPlaylist.changed() - if (changed?.length) { - await req.playlist.save() - Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`) - wasUpdated = true + // Validation + const reqBody = req.body || {} + if (reqBody.libraryId || reqBody.userId) { + // Could allow support for this if needed with additional validation + return res.status(400).send('Invalid playlist data. Cannot update libraryId or userId') + } + if (reqBody.name && typeof reqBody.name !== 'string') { + return res.status(400).send('Invalid playlist name') + } + if (reqBody.description && typeof reqBody.description !== 'string') { + return res.status(400).send('Invalid playlist description') + } + if (reqBody.items && (!Array.isArray(reqBody.items) || reqBody.items.some((i) => !i.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string')))) { + return res.status(400).send('Invalid playlist items') } - // If array of items is passed in then update order of playlist media items - const libraryItemIds = req.body.items?.map((i) => i.libraryItemId).filter((i) => i) || [] - if (libraryItemIds.length) { + const playlistUpdatePayload = {} + if (reqBody.name) playlistUpdatePayload.name = reqBody.name + if (reqBody.description) playlistUpdatePayload.description = reqBody.description + + // Update name and description + let wasUpdated = false + if (Object.keys(playlistUpdatePayload).length) { + req.playlist.set(playlistUpdatePayload) + const changed = req.playlist.changed() + if (changed?.length) { + await req.playlist.save() + Logger.debug(`[PlaylistController] Updated playlist ${req.playlist.id} keys [${changed.join(',')}]`) + wasUpdated = true + } + } + + // If array of items is set then update order of playlist media items + if (reqBody.items?.length) { + const libraryItemIds = Array.from(new Set(reqBody.items.map((i) => i.libraryItemId))) const libraryItems = await Database.libraryItemModel.findAll({ + attributes: ['id', 'mediaId', 'mediaType'], where: { id: libraryItemIds } }) - const existingPlaylistMediaItems = await updatedPlaylist.getPlaylistMediaItems({ + if (libraryItems.length !== libraryItemIds.length) { + return res.status(400).send('Invalid playlist items. Items not found') + } + /** @type {import('../models/PlaylistMediaItem')[]} */ + const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({ order: [['order', 'ASC']] }) + if (existingPlaylistMediaItems.length !== reqBody.items.length) { + return res.status(400).send('Invalid playlist items. Length mismatch') + } // Set an array of mediaItemId const newMediaItemIdOrder = [] - for (const item of req.body.items) { + for (const item of reqBody.items) { const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId) - if (!libraryItem) { - continue - } const mediaItemId = item.episodeId || libraryItem.mediaId newMediaItemIdOrder.push(mediaItemId) } @@ -146,21 +225,21 @@ class PlaylistController { }) // Update order on playlistMediaItem records - let order = 1 - for (const playlistMediaItem of existingPlaylistMediaItems) { - if (playlistMediaItem.order !== order) { + for (const [index, playlistMediaItem] of existingPlaylistMediaItems.entries()) { + if (playlistMediaItem.order !== index + 1) { await playlistMediaItem.update({ - order + order: index + 1 }) wasUpdated = true } - order++ } } - const jsonExpanded = await updatedPlaylist.getOldJsonExpanded() + req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem() + + const jsonExpanded = req.playlist.toOldJSONExpanded() if (wasUpdated) { - SocketAuthority.clientEmitter(updatedPlaylist.userId, 'playlist_updated', jsonExpanded) + SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded) } res.json(jsonExpanded) } @@ -169,11 +248,13 @@ class PlaylistController { * DELETE: /api/playlists/:id * Remove playlist * - * @param {RequestWithUser} req + * @param {PlaylistControllerRequest} req * @param {Response} res */ async delete(req, res) { - const jsonExpanded = await req.playlist.getOldJsonExpanded() + req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem() + const jsonExpanded = req.playlist.toOldJSONExpanded() + await req.playlist.destroy() SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded) res.sendStatus(200) @@ -183,12 +264,13 @@ class PlaylistController { * POST: /api/playlists/:id/item * Add item to playlist * - * @param {RequestWithUser} req + * This is not used by Abs web client or mobile apps. Only the batch endpoints are used. + * + * @param {PlaylistControllerRequest} req * @param {Response} res */ async addItem(req, res) { - const oldPlaylist = await Database.playlistModel.getById(req.playlist.id) - const itemToAdd = req.body + const itemToAdd = req.body || {} if (!itemToAdd.libraryItemId) { return res.status(400).send('Request body has no libraryItemId') @@ -198,12 +280,9 @@ class PlaylistController { if (!libraryItem) { return res.status(400).send('Library item not found') } - if (libraryItem.libraryId !== oldPlaylist.libraryId) { + if (libraryItem.libraryId !== req.playlist.libraryId) { return res.status(400).send('Library item in different library') } - if (oldPlaylist.containsItem(itemToAdd)) { - return res.status(400).send('Item already in playlist') - } if ((itemToAdd.episodeId && !libraryItem.isPodcast) || (libraryItem.isPodcast && !itemToAdd.episodeId)) { return res.status(400).send('Invalid item to add for this library type') } @@ -211,15 +290,38 @@ class PlaylistController { return res.status(400).send('Episode not found in library item') } - const playlistMediaItem = { - playlistId: oldPlaylist.id, - mediaItemId: itemToAdd.episodeId || libraryItem.media.id, - mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book', - order: oldPlaylist.items.length + 1 + req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem() + + if (req.playlist.checkHasMediaItem(itemToAdd.libraryItemId, itemToAdd.episodeId)) { + return res.status(400).send('Item already in playlist') + } + + const jsonExpanded = req.playlist.toOldJSONExpanded() + + const playlistMediaItem = { + playlistId: req.playlist.id, + mediaItemId: itemToAdd.episodeId || libraryItem.media.id, + mediaItemType: itemToAdd.episodeId ? 'podcastEpisode' : 'book', + order: req.playlist.playlistMediaItems.length + 1 + } + await Database.playlistMediaItemModel.create(playlistMediaItem) + + // 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) + jsonExpanded.items.push({ + episodeId: itemToAdd.episodeId, + episode: episode.toJSONExpanded(), + libraryItemId: libraryItem.id, + libraryItem: libraryItem.toJSONMinified() + }) + } else { + jsonExpanded.items.push({ + libraryItemId: libraryItem.id, + libraryItem: libraryItem.toJSONExpanded() + }) } - await Database.createPlaylistMediaItem(playlistMediaItem) - const jsonExpanded = await req.playlist.getOldJsonExpanded() SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_updated', jsonExpanded) res.json(jsonExpanded) } @@ -228,43 +330,36 @@ class PlaylistController { * DELETE: /api/playlists/:id/item/:libraryItemId/:episodeId? * Remove item from playlist * - * @param {RequestWithUser} req + * @param {PlaylistControllerRequest} req * @param {Response} res */ async removeItem(req, res) { - const oldLibraryItem = await Database.libraryItemModel.getOldById(req.params.libraryItemId) - if (!oldLibraryItem) { - return res.status(404).send('Library item not found') + req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem() + + let playlistMediaItem = null + if (req.params.episodeId) { + playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === req.params.episodeId) + } else { + playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === req.params.libraryItemId) } - - // Get playlist media items - const mediaItemId = req.params.episodeId || oldLibraryItem.media.id - const playlistMediaItems = await req.playlist.getPlaylistMediaItems({ - order: [['order', 'ASC']] - }) - - // Check if media item to delete is in playlist - const mediaItemToRemove = playlistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId) - if (!mediaItemToRemove) { + if (!playlistMediaItem) { return res.status(404).send('Media item not found in playlist') } // Remove record - await mediaItemToRemove.destroy() + await playlistMediaItem.destroy() + req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id) // Update playlist media items order - let order = 1 - for (const mediaItem of playlistMediaItems) { - if (mediaItem.mediaItemId === mediaItemId) continue - if (mediaItem.order !== order) { + for (const [index, mediaItem] of req.playlist.playlistMediaItems.entries()) { + if (mediaItem.order !== index + 1) { await mediaItem.update({ - order + order: index + 1 }) } - order++ } - const jsonExpanded = await req.playlist.getOldJsonExpanded() + const jsonExpanded = req.playlist.toOldJSONExpanded() // Playlist is removed when there are no items if (!jsonExpanded.items.length) { @@ -282,64 +377,68 @@ class PlaylistController { * POST: /api/playlists/:id/batch/add * Batch add playlist items * - * @param {RequestWithUser} req + * @param {PlaylistControllerRequest} req * @param {Response} res */ async addBatch(req, res) { - if (!req.body.items?.length) { - return res.status(400).send('Invalid request body') - } - const itemsToAdd = req.body.items - - const libraryItemIds = itemsToAdd.map((i) => i.libraryItemId).filter((i) => i) - if (!libraryItemIds.length) { - return res.status(400).send('Invalid request body') + if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) { + return res.status(400).send('Invalid request body items') } // Find all library items - const libraryItems = await Database.libraryItemModel.findAll({ - where: { - id: libraryItemIds - } - }) + const libraryItemIds = new Set(req.body.items.map((i) => i.libraryItemId).filter((i) => i)) - // Get all existing playlist media items - const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({ - order: [['order', 'ASC']] - }) + const oldLibraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ id: Array.from(libraryItemIds) }) + if (oldLibraryItems.length !== libraryItemIds.size) { + return res.status(400).send('Invalid request body items') + } + + req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem() const mediaItemsToAdd = [] + const jsonExpanded = req.playlist.toOldJSONExpanded() // Setup array of playlistMediaItem records to add - let order = existingPlaylistMediaItems.length + 1 - for (const item of itemsToAdd) { - const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId) - if (!libraryItem) { - return res.status(404).send('Item not found with id ' + item.libraryItemId) + let order = req.playlist.playlistMediaItems.length + 1 + for (const item of req.body.items) { + const libraryItem = oldLibraryItems.find((li) => li.id === item.libraryItemId) + + const mediaItemId = item.episodeId || libraryItem.media.id + if (req.playlist.playlistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) { + // Already exists in playlist + continue } else { - const mediaItemId = item.episodeId || libraryItem.mediaId - if (existingPlaylistMediaItems.some((pmi) => pmi.mediaItemId === mediaItemId)) { - // Already exists in playlist - continue + mediaItemsToAdd.push({ + playlistId: req.playlist.id, + mediaItemId, + mediaItemType: item.episodeId ? 'podcastEpisode' : 'book', + order: order++ + }) + + // 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) + jsonExpanded.items.push({ + episodeId: item.episodeId, + episode: episode.toJSONExpanded(), + libraryItemId: libraryItem.id, + libraryItem: libraryItem.toJSONMinified() + }) } else { - mediaItemsToAdd.push({ - playlistId: req.playlist.id, - mediaItemId, - mediaItemType: item.episodeId ? 'podcastEpisode' : 'book', - order: order++ + jsonExpanded.items.push({ + libraryItemId: libraryItem.id, + libraryItem: libraryItem.toJSONExpanded() }) } } } - let jsonExpanded = null if (mediaItemsToAdd.length) { - await Database.createBulkPlaylistMediaItems(mediaItemsToAdd) - jsonExpanded = await req.playlist.getOldJsonExpanded() + await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd) + SocketAuthority.clientEmitter(req.playlist.userId, 'playlist_updated', jsonExpanded) - } else { - jsonExpanded = await req.playlist.getOldJsonExpanded() } + res.json(jsonExpanded) } @@ -347,50 +446,40 @@ class PlaylistController { * POST: /api/playlists/:id/batch/remove * Batch remove playlist items * - * @param {RequestWithUser} req + * @param {PlaylistControllerRequest} req * @param {Response} res */ async removeBatch(req, res) { - if (!req.body.items?.length) { - return res.status(400).send('Invalid request body') + if (!req.body.items?.length || !Array.isArray(req.body.items) || req.body.items.some((i) => !i?.libraryItemId || typeof i.libraryItemId !== 'string' || (i.episodeId && typeof i.episodeId !== 'string'))) { + return res.status(400).send('Invalid request body items') } - const itemsToRemove = req.body.items - const libraryItemIds = itemsToRemove.map((i) => i.libraryItemId).filter((i) => i) - if (!libraryItemIds.length) { - return res.status(400).send('Invalid request body') - } - - // Find all library items - const libraryItems = await Database.libraryItemModel.findAll({ - where: { - id: libraryItemIds - } - }) - - // Get all existing playlist media items for playlist - const existingPlaylistMediaItems = await req.playlist.getPlaylistMediaItems({ - order: [['order', 'ASC']] - }) - let numMediaItems = existingPlaylistMediaItems.length + req.playlist.playlistMediaItems = await req.playlist.getMediaItemsExpandedWithLibraryItem() // Remove playlist media items let hasUpdated = false - for (const item of itemsToRemove) { - const libraryItem = libraryItems.find((li) => li.id === item.libraryItemId) - if (!libraryItem) continue - const mediaItemId = item.episodeId || libraryItem.mediaId - const existingMediaItem = existingPlaylistMediaItems.find((pmi) => pmi.mediaItemId === mediaItemId) - if (!existingMediaItem) continue - await existingMediaItem.destroy() + for (const item of req.body.items) { + let playlistMediaItem = null + if (item.episodeId) { + playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItemId === item.episodeId) + } else { + playlistMediaItem = req.playlist.playlistMediaItems.find((pmi) => pmi.mediaItem.libraryItem?.id === item.libraryItemId) + } + if (!playlistMediaItem) { + Logger.warn(`[PlaylistController] Playlist item not found in playlist ${req.playlist.id}`, item) + continue + } + + await playlistMediaItem.destroy() + req.playlist.playlistMediaItems = req.playlist.playlistMediaItems.filter((pmi) => pmi.id !== playlistMediaItem.id) + hasUpdated = true - numMediaItems-- } - const jsonExpanded = await req.playlist.getOldJsonExpanded() + const jsonExpanded = req.playlist.toOldJSONExpanded() if (hasUpdated) { // Playlist is removed when there are no items - if (!numMediaItems) { + if (!req.playlist.playlistMediaItems.length) { Logger.info(`[PlaylistController] Playlist "${req.playlist.name}" has no more items - removing it`) await req.playlist.destroy() SocketAuthority.clientEmitter(jsonExpanded.userId, 'playlist_removed', jsonExpanded) @@ -425,33 +514,41 @@ class PlaylistController { return res.status(400).send('Collection has no books') } - const oldPlaylist = new Playlist() - oldPlaylist.setData({ - userId: req.user.id, - libraryId: collection.libraryId, - name: collection.name, - description: collection.description || null - }) + const transaction = await Database.sequelize.transaction() + try { + const playlist = await Database.playlistModel.create( + { + userId: req.user.id, + libraryId: collection.libraryId, + name: collection.name, + description: collection.description || null + }, + { transaction } + ) - // Create Playlist record - const newPlaylist = await Database.playlistModel.createFromOld(oldPlaylist) + const mediaItemsToAdd = [] + for (const [index, libraryItem] of collectionExpanded.books.entries()) { + mediaItemsToAdd.push({ + playlistId: playlist.id, + mediaItemId: libraryItem.media.id, + mediaItemType: 'book', + order: index + 1 + }) + } + await Database.playlistMediaItemModel.bulkCreate(mediaItemsToAdd, { transaction }) - // Create PlaylistMediaItem records - const mediaItemsToAdd = [] - let order = 1 - for (const libraryItem of collectionExpanded.books) { - mediaItemsToAdd.push({ - playlistId: newPlaylist.id, - mediaItemId: libraryItem.media.id, - mediaItemType: 'book', - order: order++ - }) + await transaction.commit() + + playlist.playlistMediaItems = await playlist.getMediaItemsExpandedWithLibraryItem() + + const jsonExpanded = playlist.toOldJSONExpanded() + SocketAuthority.clientEmitter(playlist.userId, 'playlist_added', jsonExpanded) + res.json(jsonExpanded) + } catch (error) { + await transaction.rollback() + Logger.error('[PlaylistController] createFromCollection:', error) + res.status(500).send('Failed to create playlist') } - await Database.createBulkPlaylistMediaItems(mediaItemsToAdd) - - const jsonExpanded = await newPlaylist.getOldJsonExpanded() - SocketAuthority.clientEmitter(newPlaylist.userId, 'playlist_added', jsonExpanded) - res.json(jsonExpanded) } /** diff --git a/server/models/Playlist.js b/server/models/Playlist.js index 490e8087..7817211f 100644 --- a/server/models/Playlist.js +++ b/server/models/Playlist.js @@ -1,8 +1,6 @@ -const { DataTypes, Model, Op, literal } = require('sequelize') +const { DataTypes, Model, Op } = require('sequelize') const Logger = require('../Logger') -const oldPlaylist = require('../objects/Playlist') - class Playlist extends Model { constructor(values, options) { super(values, options) @@ -21,134 +19,23 @@ class Playlist extends Model { this.createdAt /** @type {Date} */ this.updatedAt - } - static getOldPlaylist(playlistExpanded) { - const items = playlistExpanded.playlistMediaItems - .map((pmi) => { - const mediaItem = pmi.mediaItem || pmi.dataValues?.mediaItem - const libraryItemId = mediaItem?.podcast?.libraryItem?.id || mediaItem?.libraryItem?.id || null - if (!libraryItemId) { - Logger.error(`[Playlist] Invalid playlist media item - No library item id found`, JSON.stringify(pmi, null, 2)) - return null - } - return { - episodeId: pmi.mediaItemType === 'podcastEpisode' ? pmi.mediaItemId : '', - libraryItemId - } - }) - .filter((pmi) => pmi) + // Expanded properties - return new oldPlaylist({ - id: playlistExpanded.id, - libraryId: playlistExpanded.libraryId, - userId: playlistExpanded.userId, - name: playlistExpanded.name, - description: playlistExpanded.description, - items, - lastUpdate: playlistExpanded.updatedAt.valueOf(), - createdAt: playlistExpanded.createdAt.valueOf() - }) + /** @type {import('./PlaylistMediaItem')[]} - only set when expanded */ + this.playlistMediaItems } /** - * Get old playlist toJSONExpanded - * @param {string[]} [include] - * @returns {Promise} oldPlaylist.toJSONExpanded - */ - async getOldJsonExpanded(include) { - this.playlistMediaItems = - (await this.getPlaylistMediaItems({ - include: [ - { - model: this.sequelize.models.book, - include: this.sequelize.models.libraryItem - }, - { - model: this.sequelize.models.podcastEpisode, - include: { - model: this.sequelize.models.podcast, - include: this.sequelize.models.libraryItem - } - } - ], - order: [['order', 'ASC']] - })) || [] - - const oldPlaylist = this.sequelize.models.playlist.getOldPlaylist(this) - const libraryItemIds = oldPlaylist.items.map((i) => i.libraryItemId) - - let libraryItems = await this.sequelize.models.libraryItem.getAllOldLibraryItems({ - id: libraryItemIds - }) - - const playlistExpanded = oldPlaylist.toJSONExpanded(libraryItems) - - return playlistExpanded - } - - static createFromOld(oldPlaylist) { - const playlist = this.getFromOld(oldPlaylist) - return this.create(playlist) - } - - static getFromOld(oldPlaylist) { - return { - id: oldPlaylist.id, - name: oldPlaylist.name, - description: oldPlaylist.description, - userId: oldPlaylist.userId, - libraryId: oldPlaylist.libraryId - } - } - - static removeById(playlistId) { - return this.destroy({ - where: { - id: playlistId - } - }) - } - - /** - * Get playlist by id - * @param {string} playlistId - * @returns {Promise} returns null if not found - */ - static async getById(playlistId) { - if (!playlistId) return null - const playlist = await this.findByPk(playlistId, { - include: { - model: this.sequelize.models.playlistMediaItem, - include: [ - { - model: this.sequelize.models.book, - include: this.sequelize.models.libraryItem - }, - { - model: this.sequelize.models.podcastEpisode, - include: { - model: this.sequelize.models.podcast, - include: this.sequelize.models.libraryItem - } - } - ] - }, - order: [['playlistMediaItems', 'order', 'ASC']] - }) - if (!playlist) return null - return this.getOldPlaylist(playlist) - } - - /** - * Get old playlists for user and optionally for library + * Get old playlists for user and library * * @param {string} userId - * @param {string} [libraryId] - * @returns {Promise} + * @param {string} libraryId + * @async */ - static async getOldPlaylistsForUserAndLibrary(userId, libraryId = null) { + static async getOldPlaylistsForUserAndLibrary(userId, libraryId) { if (!userId && !libraryId) return [] + const whereQuery = {} if (userId) { whereQuery.userId = userId @@ -163,7 +50,23 @@ class Playlist extends Model { include: [ { model: this.sequelize.models.book, - include: this.sequelize.models.libraryItem + include: [ + { + model: this.sequelize.models.libraryItem + }, + { + model: this.sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: this.sequelize.models.series, + through: { + attributes: ['sequence'] + } + } + ] }, { model: this.sequelize.models.podcastEpisode, @@ -174,42 +77,13 @@ class Playlist extends Model { } ] }, - order: [ - [literal('name COLLATE NOCASE'), 'ASC'], - ['playlistMediaItems', 'order', 'ASC'] - ] + order: [['playlistMediaItems', 'order', 'ASC']] }) - const oldPlaylists = [] - for (const playlistExpanded of playlistsExpanded) { - const oldPlaylist = this.getOldPlaylist(playlistExpanded) - const libraryItems = [] - for (const pmi of playlistExpanded.playlistMediaItems) { - let mediaItem = pmi.mediaItem || pmi.dataValues.mediaItem + // Sort by name asc + playlistsExpanded.sort((a, b) => a.name.localeCompare(b.name)) - if (!mediaItem) { - Logger.error(`[Playlist] Invalid playlist media item - No media item found`, JSON.stringify(mediaItem, null, 2)) - continue - } - let libraryItem = mediaItem.libraryItem || mediaItem.podcast?.libraryItem - - if (mediaItem.podcast) { - libraryItem.media = mediaItem.podcast - libraryItem.media.podcastEpisodes = [mediaItem] - delete mediaItem.podcast.libraryItem - } else { - libraryItem.media = mediaItem - delete mediaItem.libraryItem - } - - const oldLibraryItem = this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem) - libraryItems.push(oldLibraryItem) - } - const oldPlaylistJson = oldPlaylist.toJSONExpanded(libraryItems) - oldPlaylists.push(oldPlaylistJson) - } - - return oldPlaylists + return playlistsExpanded.map((playlist) => playlist.toOldJSONExpanded()) } /** @@ -345,6 +219,117 @@ class Playlist extends Model { } }) } + + /** + * Get all media items in playlist expanded with library item + * + * @returns {Promise} + */ + getMediaItemsExpandedWithLibraryItem() { + return this.getPlaylistMediaItems({ + include: [ + { + model: this.sequelize.models.book, + include: [ + { + model: this.sequelize.models.libraryItem + }, + { + model: this.sequelize.models.author, + through: { + attributes: [] + } + }, + { + model: this.sequelize.models.series, + through: { + attributes: ['sequence'] + } + } + ] + }, + { + model: this.sequelize.models.podcastEpisode, + include: [ + { + model: this.sequelize.models.podcast, + include: this.sequelize.models.libraryItem + } + ] + } + ], + order: [['order', 'ASC']] + }) + } + + /** + * Get playlists toOldJSONExpanded + * + * @async + */ + async getOldJsonExpanded() { + this.playlistMediaItems = await this.getMediaItemsExpandedWithLibraryItem() + return this.toOldJSONExpanded() + } + + /** + * Old model used libraryItemId instead of bookId + * + * @param {string} libraryItemId + * @param {string} [episodeId] + */ + checkHasMediaItem(libraryItemId, episodeId) { + if (!this.playlistMediaItems) { + throw new Error('playlistMediaItems are required to check Playlist') + } + if (episodeId) { + return this.playlistMediaItems.some((pmi) => pmi.mediaItemId === episodeId) + } + return this.playlistMediaItems.some((pmi) => pmi.mediaItem.libraryItem.id === libraryItemId) + } + + toOldJSON() { + return { + id: this.id, + name: this.name, + libraryId: this.libraryId, + userId: this.userId, + description: this.description, + lastUpdate: this.updatedAt.valueOf(), + createdAt: this.createdAt.valueOf() + } + } + + toOldJSONExpanded() { + if (!this.playlistMediaItems) { + throw new Error('playlistMediaItems are required to expand Playlist') + } + + const json = this.toOldJSON() + json.items = this.playlistMediaItems.map((pmi) => { + if (pmi.mediaItemType === 'book') { + const libraryItem = pmi.mediaItem.libraryItem + delete pmi.mediaItem.libraryItem + libraryItem.media = pmi.mediaItem + return { + libraryItemId: libraryItem.id, + libraryItem: this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONExpanded() + } + } + + const libraryItem = pmi.mediaItem.podcast.libraryItem + delete pmi.mediaItem.podcast.libraryItem + libraryItem.media = pmi.mediaItem.podcast + return { + episodeId: pmi.mediaItemId, + episode: pmi.mediaItem.toOldJSONExpanded(libraryItem.id), + libraryItemId: libraryItem.id, + libraryItem: this.sequelize.models.libraryItem.getOldLibraryItem(libraryItem).toJSONMinified() + } + }) + + return json + } } module.exports = Playlist diff --git a/server/models/PlaylistMediaItem.js b/server/models/PlaylistMediaItem.js index 1c53bea1..b9f76c5e 100644 --- a/server/models/PlaylistMediaItem.js +++ b/server/models/PlaylistMediaItem.js @@ -16,6 +16,11 @@ class PlaylistMediaItem extends Model { this.playlistId /** @type {Date} */ this.createdAt + + // Expanded properties + + /** @type {import('./Book')|import('./PodcastEpisode')} - only set when expanded */ + this.mediaItem } static removeByIds(playlistId, mediaItemId) { diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 1f99361a..1fa32da7 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -170,6 +170,62 @@ class PodcastEpisode extends Model { }) PodcastEpisode.belongsTo(podcast) } + + /** + * AudioTrack object used in old model + * + * @returns {import('./Book').AudioFileObject|null} + */ + get track() { + if (!this.audioFile) return null + const track = structuredClone(this.audioFile) + track.startOffset = 0 + track.title = this.audioFile.metadata.title + return track + } + + toOldJSON(libraryItemId) { + let enclosure = null + if (this.enclosureURL) { + enclosure = { + url: this.enclosureURL, + type: this.enclosureType, + length: this.enclosureSize !== null ? String(this.enclosureSize) : null + } + } + + return { + libraryItemId: libraryItemId, + 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?.map((ch) => ({ ...ch })) || [], + audioFile: this.audioFile || null, + publishedAt: this.publishedAt?.valueOf() || null, + addedAt: this.createdAt.valueOf(), + updatedAt: this.updatedAt.valueOf() + } + } + + toOldJSONExpanded(libraryItemId) { + const json = this.toOldJSON(libraryItemId) + + json.audioTrack = this.track + json.size = this.audioFile?.metadata.size || 0 + json.duration = this.audioFile?.duration || 0 + + return json + } } module.exports = PodcastEpisode diff --git a/server/objects/Playlist.js b/server/objects/Playlist.js deleted file mode 100644 index c4b3357b..00000000 --- a/server/objects/Playlist.js +++ /dev/null @@ -1,148 +0,0 @@ -const uuidv4 = require("uuid").v4 - -class Playlist { - constructor(playlist) { - this.id = null - this.libraryId = null - this.userId = null - - this.name = null - this.description = null - - this.coverPath = null - - // Array of objects like { libraryItemId: "", episodeId: "" } (episodeId optional) - this.items = [] - - this.lastUpdate = null - this.createdAt = null - - if (playlist) { - this.construct(playlist) - } - } - - toJSON() { - return { - id: this.id, - libraryId: this.libraryId, - userId: this.userId, - name: this.name, - description: this.description, - coverPath: this.coverPath, - items: [...this.items], - lastUpdate: this.lastUpdate, - createdAt: this.createdAt - } - } - - // Expands the items array - toJSONExpanded(libraryItems) { - var json = this.toJSON() - json.items = json.items.map(item => { - const libraryItem = libraryItems.find(li => li.id === item.libraryItemId) - if (!libraryItem) { - // Not found - return null - } - if (item.episodeId) { - if (!libraryItem.isPodcast) { - // Invalid - return null - } - const episode = libraryItem.media.episodes.find(ep => ep.id === item.episodeId) - if (!episode) { - // Not found - return null - } - - return { - ...item, - episode: episode.toJSONExpanded(), - libraryItem: libraryItem.toJSONMinified() - } - } else { - return { - ...item, - libraryItem: libraryItem.toJSONExpanded() - } - } - }).filter(i => i) - return json - } - - construct(playlist) { - this.id = playlist.id - this.libraryId = playlist.libraryId - this.userId = playlist.userId - this.name = playlist.name - this.description = playlist.description || null - this.coverPath = playlist.coverPath || null - this.items = playlist.items ? playlist.items.map(i => ({ ...i })) : [] - this.lastUpdate = playlist.lastUpdate || null - this.createdAt = playlist.createdAt || null - } - - setData(data) { - if (!data.userId || !data.libraryId || !data.name) { - return false - } - this.id = uuidv4() - this.userId = data.userId - this.libraryId = data.libraryId - this.name = data.name - this.description = data.description || null - this.coverPath = data.coverPath || null - this.items = data.items ? data.items.map(i => ({ ...i })) : [] - this.lastUpdate = Date.now() - this.createdAt = Date.now() - return true - } - - addItem(libraryItemId, episodeId = null) { - this.items.push({ - libraryItemId, - episodeId: episodeId || null - }) - this.lastUpdate = Date.now() - } - - removeItem(libraryItemId, episodeId = null) { - if (episodeId) this.items = this.items.filter(i => i.libraryItemId !== libraryItemId || i.episodeId !== episodeId) - else this.items = this.items.filter(i => i.libraryItemId !== libraryItemId) - this.lastUpdate = Date.now() - } - - update(payload) { - let hasUpdates = false - for (const key in payload) { - if (key === 'items') { - if (payload.items && JSON.stringify(payload.items) !== JSON.stringify(this.items)) { - this.items = payload.items.map(i => ({ ...i })) - hasUpdates = true - } - } else if (this[key] !== undefined && this[key] !== payload[key]) { - hasUpdates = true - this[key] = payload[key] - } - } - if (hasUpdates) { - this.lastUpdate = Date.now() - } - return hasUpdates - } - - containsItem(item) { - if (item.episodeId) return this.items.some(i => i.libraryItemId === item.libraryItemId && i.episodeId === item.episodeId) - return this.items.some(i => i.libraryItemId === item.libraryItemId) - } - - hasItemsForLibraryItem(libraryItemId) { - return this.items.some(i => i.libraryItemId === libraryItemId) - } - - removeItemsForLibraryItem(libraryItemId) { - this.items = this.items.filter(i => i.libraryItemId !== libraryItemId) - } -} -module.exports = Playlist \ No newline at end of file From 777c59458dda17f4923d0d07829ac488e8e0db18 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 31 Dec 2024 17:11:31 -0600 Subject: [PATCH 16/52] Fix find all playlist endpoint --- server/controllers/PlaylistController.js | 4 ++-- server/models/PlaylistMediaItem.js | 9 --------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/server/controllers/PlaylistController.js b/server/controllers/PlaylistController.js index ee4fef5e..8c13ecb2 100644 --- a/server/controllers/PlaylistController.js +++ b/server/controllers/PlaylistController.js @@ -121,7 +121,7 @@ class PlaylistController { /** * @deprecated - Use /api/libraries/:libraryId/playlists * This is not used by Abs web client or mobile apps - * TODO: Remove this endpoint or refactor it and make it the primary + * TODO: Remove this endpoint or make it the primary * * GET: /api/playlists * Get all playlists for user @@ -130,7 +130,7 @@ class PlaylistController { * @param {Response} res */ async findAllForUser(req, res) { - const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id, req.params.libraryId) + const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id) res.json({ playlists: playlistsForUser }) diff --git a/server/models/PlaylistMediaItem.js b/server/models/PlaylistMediaItem.js index b9f76c5e..2eac036b 100644 --- a/server/models/PlaylistMediaItem.js +++ b/server/models/PlaylistMediaItem.js @@ -23,15 +23,6 @@ class PlaylistMediaItem extends Model { this.mediaItem } - static removeByIds(playlistId, mediaItemId) { - return this.destroy({ - where: { - playlistId, - mediaItemId - } - }) - } - getMediaItem(options) { if (!this.mediaItemType) return Promise.resolve(null) const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaItemType)}` From 25b7f005c64bf0b3523228e242d958e5e81bbf9a Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 31 Dec 2024 17:15:11 -0600 Subject: [PATCH 17/52] Remove unnecessary playlist toasts --- client/components/modals/playlists/AddCreateModal.vue | 3 --- client/components/tables/playlist/ItemTableRow.vue | 1 - 2 files changed, 4 deletions(-) diff --git a/client/components/modals/playlists/AddCreateModal.vue b/client/components/modals/playlists/AddCreateModal.vue index e089324f..d1f910b8 100644 --- a/client/components/modals/playlists/AddCreateModal.vue +++ b/client/components/modals/playlists/AddCreateModal.vue @@ -130,7 +130,6 @@ export default { .$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects }) .then((updatedPlaylist) => { console.log(`Items removed from playlist`, updatedPlaylist) - this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess) this.processing = false }) .catch((error) => { @@ -148,7 +147,6 @@ export default { .$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects }) .then((updatedPlaylist) => { console.log(`Items added to playlist`, updatedPlaylist) - this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess) this.processing = false }) .catch((error) => { @@ -174,7 +172,6 @@ export default { .$post('/api/playlists', newPlaylist) .then((data) => { console.log('New playlist created', data) - this.$toast.success(this.$strings.ToastPlaylistCreateSuccess + ': ' + data.name) this.processing = false this.newPlaylistName = '' }) diff --git a/client/components/tables/playlist/ItemTableRow.vue b/client/components/tables/playlist/ItemTableRow.vue index 9f1a7e87..0cb73f9c 100644 --- a/client/components/tables/playlist/ItemTableRow.vue +++ b/client/components/tables/playlist/ItemTableRow.vue @@ -218,7 +218,6 @@ export default { this.$toast.success(this.$strings.ToastPlaylistRemoveSuccess) } else { console.log(`Item removed from playlist`, updatedPlaylist) - this.$toast.success(this.$strings.ToastPlaylistUpdateSuccess) } }) .catch((error) => { From 1c2ee09f1868ca47c4eb45d1dbb83ee3f854a58e Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 31 Dec 2024 17:41:09 -0600 Subject: [PATCH 18/52] Fix user stats heatmap to use range of currently showing data only --- client/components/stats/Heatmap.vue | 60 ++++++++++++++--------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/client/components/stats/Heatmap.vue b/client/components/stats/Heatmap.vue index fe235bdd..46ac9609 100644 --- a/client/components/stats/Heatmap.vue +++ b/client/components/stats/Heatmap.vue @@ -193,46 +193,46 @@ export default { buildData() { this.data = [] - var maxValue = 0 - var minValue = 0 - Object.values(this.daysListening).forEach((val) => { - if (val > maxValue) maxValue = val - if (!minValue || val < minValue) minValue = val - }) - const range = maxValue - minValue + 0.01 + let maxValue = 0 + let minValue = 0 + const dates = [] for (let i = 0; i < this.daysToShow + 1; i++) { - const col = Math.floor(i / 7) - const row = i % 7 - const date = i === 0 ? this.firstWeekStart : this.$addDaysToDate(this.firstWeekStart, i) const dateString = this.$formatJsDate(date, 'yyyy-MM-dd') - const datePretty = this.$formatJsDate(date, 'MMM d, yyyy') - const monthString = this.$formatJsDate(date, 'MMM') - const value = this.daysListening[dateString] || 0 - const x = col * 13 - const y = row * 13 + const dateObj = { + col: Math.floor(i / 7), + row: i % 7, + date, + dateString, + datePretty: this.$formatJsDate(date, 'MMM d, yyyy'), + monthString: this.$formatJsDate(date, 'MMM'), + dayOfMonth: Number(dateString.split('-').pop()), + yearString: dateString.split('-').shift(), + value: this.daysListening[dateString] || 0 + } + dates.push(dateObj) - var bgColor = this.bgColors[0] - var outlineColor = this.outlineColors[0] - if (value) { + if (dateObj.value) { + if (dateObj.value > maxValue) maxValue = dateObj.value + if (!minValue || dateObj.value < minValue) minValue = dateObj.value + } + } + const range = maxValue - minValue + 0.01 + + for (const dateObj of dates) { + let bgColor = this.bgColors[0] + let outlineColor = this.outlineColors[0] + if (dateObj.value) { outlineColor = this.outlineColors[1] - var percentOfAvg = (value - minValue) / range - var bgIndex = Math.floor(percentOfAvg * 4) + 1 + const percentOfAvg = (dateObj.value - minValue) / range + const bgIndex = Math.floor(percentOfAvg * 4) + 1 bgColor = this.bgColors[bgIndex] || 'red' } this.data.push({ - date, - dateString, - datePretty, - monthString, - dayOfMonth: Number(dateString.split('-').pop()), - yearString: dateString.split('-').shift(), - value, - col, - row, - style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;` + ...dateObj, + style: `transform:translate(${dateObj.col * 13}px,${dateObj.row * 13}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;` }) } From 754c1211683b1587e3a9296e5778a193176d4f53 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 1 Jan 2025 07:34:29 +0200 Subject: [PATCH 19/52] Add libraryItem size index --- server/migrations/v2.17.7-add-indices.js | 81 ++++++++++++++++++++++++ server/models/LibraryItem.js | 3 + 2 files changed, 84 insertions(+) create mode 100644 server/migrations/v2.17.7-add-indices.js diff --git a/server/migrations/v2.17.7-add-indices.js b/server/migrations/v2.17.7-add-indices.js new file mode 100644 index 00000000..3d70ba20 --- /dev/null +++ b/server/migrations/v2.17.7-add-indices.js @@ -0,0 +1,81 @@ +/** + * @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.17.7' +const migrationName = `${migrationVersion}-add-indices` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This upward migration adds some indices to the libraryItems and books tables to improve query performance + * + * @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 addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size']) + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This downward migration script removes the indices added in the upward migration script + * + * @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', 'size']) + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +/** + * Utility function to add an index to a table. If the index already 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) { + try { + logger.info(`${loggerPrefix} adding index [${columns.join(', ')}] to table "${tableName}"`) + await queryInterface.addIndex(tableName, columns) + logger.info(`${loggerPrefix} added index [${columns.join(', ')}] to table "${tableName}"`) + } catch (error) { + if (error.name === 'SequelizeDatabaseError' && error.message.includes('already exists')) { + logger.info(`${loggerPrefix} index [${columns.join(', ')}] 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}"`) +} + +module.exports = { up, down } diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index bed96631..2aa41b70 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -1061,6 +1061,9 @@ class LibraryItem extends Model { { fields: ['libraryId', 'mediaType'] }, + { + fields: ['libraryId', 'mediaType', 'size'] + }, { fields: ['libraryId', 'mediaId', 'mediaType'] }, From 0444829a9f99b1d42ec00ce228f8c22eaa116e05 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 1 Jan 2025 08:37:57 +0200 Subject: [PATCH 20/52] Add index on duration --- server/migrations/v2.17.7-add-indices.js | 2 ++ server/models/Book.js | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/server/migrations/v2.17.7-add-indices.js b/server/migrations/v2.17.7-add-indices.js index 3d70ba20..b3821de8 100644 --- a/server/migrations/v2.17.7-add-indices.js +++ b/server/migrations/v2.17.7-add-indices.js @@ -22,6 +22,7 @@ async function up({ context: { queryInterface, logger } }) { logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) await addIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size']) + await addIndex(queryInterface, logger, 'books', ['duration']) logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) } @@ -37,6 +38,7 @@ async function down({ context: { queryInterface, logger } }) { logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) await removeIndex(queryInterface, logger, 'libraryItems', ['libraryId', 'mediaType', 'size']) + await removeIndex(queryInterface, logger, 'books', ['duration']) logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) } diff --git a/server/models/Book.js b/server/models/Book.js index f7341db9..a904f536 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -321,10 +321,10 @@ class Book extends Model { // }, { fields: ['publishedYear'] + }, + { + fields: ['duration'] } - // { - // fields: ['duration'] - // } ] } ) From 46247ecf7897af954c965d58ca25b37bda72f4da Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 1 Jan 2025 08:41:27 +0200 Subject: [PATCH 21/52] Update migrations changelog --- server/migrations/changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/server/migrations/changelog.md b/server/migrations/changelog.md index c2de4693..3b5a5626 100644 --- a/server/migrations/changelog.md +++ b/server/migrations/changelog.md @@ -12,3 +12,4 @@ Please add a record of every database migration that you create to this file. Th | v2.17.4 | v2.17.4-use-subfolder-for-oidc-redirect-uris | Save subfolder to OIDC redirect URIs to support existing installations | | 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 | From 9fa00a1904b2aae7c9f633bcb00e6a15a28d1665 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 1 Jan 2025 08:55:40 -0600 Subject: [PATCH 22/52] Fix Share media player not using media session API #3768 --- client/pages/share/_slug.vue | 94 +++++++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/client/pages/share/_slug.vue b/client/pages/share/_slug.vue index 6bce2f8a..e7d00f00 100644 --- a/client/pages/share/_slug.vue +++ b/client/pages/share/_slug.vue @@ -110,6 +110,84 @@ export default { } }, methods: { + mediaSessionPlay() { + console.log('Media session play') + this.play() + }, + mediaSessionPause() { + console.log('Media session pause') + this.pause() + }, + mediaSessionStop() { + console.log('Media session stop') + this.pause() + }, + mediaSessionSeekBackward() { + console.log('Media session seek backward') + this.jumpBackward() + }, + mediaSessionSeekForward() { + console.log('Media session seek forward') + this.jumpForward() + }, + mediaSessionSeekTo(e) { + console.log('Media session seek to', e) + if (e.seekTime !== null && !isNaN(e.seekTime)) { + this.seek(e.seekTime) + } + }, + mediaSessionPreviousTrack() { + if (this.$refs.audioPlayer) { + this.$refs.audioPlayer.prevChapter() + } + }, + mediaSessionNextTrack() { + if (this.$refs.audioPlayer) { + this.$refs.audioPlayer.nextChapter() + } + }, + updateMediaSessionPlaybackState() { + if ('mediaSession' in navigator) { + navigator.mediaSession.playbackState = this.isPlaying ? 'playing' : 'paused' + } + }, + setMediaSession() { + // https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API + if ('mediaSession' in navigator) { + const chapterInfo = [] + if (this.chapters.length > 0) { + this.chapters.forEach((chapter) => { + chapterInfo.push({ + title: chapter.title, + startTime: chapter.start + }) + }) + } + + navigator.mediaSession.metadata = new MediaMetadata({ + title: this.mediaItemShare.playbackSession.displayTitle || 'No title', + artist: this.mediaItemShare.playbackSession.displayAuthor || 'Unknown', + artwork: [ + { + src: this.coverUrl + } + ], + chapterInfo + }) + console.log('Set media session metadata', navigator.mediaSession.metadata) + + navigator.mediaSession.setActionHandler('play', this.mediaSessionPlay) + navigator.mediaSession.setActionHandler('pause', this.mediaSessionPause) + navigator.mediaSession.setActionHandler('stop', this.mediaSessionStop) + navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward) + navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward) + navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo) + navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward) + navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward) + } else { + console.warn('Media session not available') + } + }, async coverImageLoaded(e) { if (!this.playbackSession.coverPath) return const fac = new FastAverageColor() @@ -126,8 +204,19 @@ export default { }) }, playPause() { + if (this.isPlaying) { + this.pause() + } else { + this.play() + } + }, + play() { if (!this.localAudioPlayer || !this.hasLoaded) return - this.localAudioPlayer.playPause() + this.localAudioPlayer.play() + }, + pause() { + if (!this.localAudioPlayer || !this.hasLoaded) return + this.localAudioPlayer.pause() }, jumpForward() { if (!this.localAudioPlayer || !this.hasLoaded) return @@ -213,6 +302,7 @@ export default { } else { this.stopPlayInterval() } + this.updateMediaSessionPlaybackState() }, playerTimeUpdate(time) { this.setCurrentTime(time) @@ -276,6 +366,8 @@ export default { this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this)) this.localAudioPlayer.on('error', this.playerError.bind(this)) this.localAudioPlayer.on('finished', this.playerFinished.bind(this)) + + this.setMediaSession() }, beforeDestroy() { window.removeEventListener('resize', this.resize) From 86809dcc62bcd422976c06e758edb1924c178c86 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 1 Jan 2025 09:02:31 -0600 Subject: [PATCH 23/52] Update audio player to pass chapterInfo to media session API --- .../components/app/MediaPlayerContainer.vue | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/client/components/app/MediaPlayerContainer.vue b/client/components/app/MediaPlayerContainer.vue index ed8971f7..989fc062 100644 --- a/client/components/app/MediaPlayerContainer.vue +++ b/client/components/app/MediaPlayerContainer.vue @@ -374,19 +374,27 @@ export default { return } + // https://developer.mozilla.org/en-US/docs/Web/API/Media_Session_API if ('mediaSession' in navigator) { - var coverImageSrc = this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true) - const artwork = [ - { - src: coverImageSrc - } - ] + const chapterInfo = [] + if (this.chapters.length) { + this.chapters.forEach((chapter) => { + chapterInfo.push({ + title: chapter.title, + startTime: chapter.start + }) + }) + } navigator.mediaSession.metadata = new MediaMetadata({ title: this.title, artist: this.playerHandler.displayAuthor || this.mediaMetadata.authorName || 'Unknown', album: this.mediaMetadata.seriesName || '', - artwork + artwork: [ + { + src: this.$store.getters['globals/getLibraryItemCoverSrc'](this.streamLibraryItem, '/Logo.png', true) + } + ] }) console.log('Set media session metadata', navigator.mediaSession.metadata) From 5201625d38aa2dcda85fb3df3b270ae9adb3c021 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 1 Jan 2025 11:32:39 -0600 Subject: [PATCH 24/52] Fix FeedEpisodes using a new ID when updating #3757 --- server/managers/RssFeedManager.js | 20 ++++++++++--- server/models/Feed.js | 26 +++++++++++----- server/models/FeedEpisode.js | 49 +++++++++++++++++++++++-------- 3 files changed, 71 insertions(+), 24 deletions(-) diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index cedf0dfb..abfa445f 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -98,11 +98,22 @@ class RssFeedManager { podcastId: feed.entity.mediaId }, attributes: ['id', 'updatedAt'], - order: [['createdAt', 'DESC']] + order: [['updatedAt', 'DESC']] }) + if (mostRecentPodcastEpisode && mostRecentPodcastEpisode.updatedAt > newEntityUpdatedAt) { newEntityUpdatedAt = mostRecentPodcastEpisode.updatedAt } + } else { + const book = await Database.bookModel.findOne({ + where: { + id: feed.entity.mediaId + }, + attributes: ['id', 'updatedAt'] + }) + if (book && book.updatedAt > newEntityUpdatedAt) { + newEntityUpdatedAt = book.updatedAt + } } return newEntityUpdatedAt > feed.entityUpdatedAt @@ -111,7 +122,7 @@ class RssFeedManager { attributes: ['id', 'updatedAt'], include: { model: Database.bookModel, - attributes: ['id'], + attributes: ['id', 'updatedAt'], through: { attributes: [] }, @@ -125,8 +136,9 @@ class RssFeedManager { let newEntityUpdatedAt = feed.entity.updatedAt const mostRecentItemUpdatedAt = feed.entity.books.reduce((mostRecent, book) => { - if (book.libraryItem.updatedAt > mostRecent) { - return book.libraryItem.updatedAt + let updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt + if (updatedAt > mostRecent) { + return updatedAt } return mostRecent }, 0) diff --git a/server/models/Feed.js b/server/models/Feed.js index d8f8553c..a6ccff22 100644 --- a/server/models/Feed.js +++ b/server/models/Feed.js @@ -107,6 +107,9 @@ class Feed extends Model { entityUpdatedAt = libraryItem.media.podcastEpisodes.reduce((mostRecent, episode) => { return episode.updatedAt > mostRecent ? episode.updatedAt : mostRecent }, entityUpdatedAt) + } else if (libraryItem.media.updatedAt > entityUpdatedAt) { + // Book feeds will use Book.updatedAt if more recent + entityUpdatedAt = libraryItem.media.updatedAt } const feedObj = { @@ -472,6 +475,8 @@ class Feed extends Model { /** @type {typeof import('./FeedEpisode')} */ const feedEpisodeModel = this.sequelize.models.feedEpisode + this.feedEpisodes = await this.getFeedEpisodes() + let feedObj = null let feedEpisodeCreateFunc = null let feedEpisodeCreateFuncEntity = null @@ -516,17 +521,24 @@ class Feed extends Model { try { const updatedFeed = await this.update(feedObj, { transaction }) - // Remove existing feed episodes - await feedEpisodeModel.destroy({ - where: { - feedId: this.id - }, - transaction - }) + const existingFeedEpisodeIds = this.feedEpisodes.map((ep) => ep.id) // Create new feed episodes updatedFeed.feedEpisodes = await feedEpisodeCreateFunc(feedEpisodeCreateFuncEntity, updatedFeed, this.slug, transaction) + const newFeedEpisodeIds = updatedFeed.feedEpisodes.map((ep) => ep.id) + const feedEpisodeIdsToRemove = existingFeedEpisodeIds.filter((epid) => !newFeedEpisodeIds.includes(epid)) + + if (feedEpisodeIdsToRemove.length) { + Logger.info(`[Feed] Removing ${feedEpisodeIdsToRemove.length} episodes from feed ${this.id}`) + await feedEpisodeModel.destroy({ + where: { + id: feedEpisodeIdsToRemove + }, + transaction + }) + } + await transaction.commit() return updatedFeed diff --git a/server/models/FeedEpisode.js b/server/models/FeedEpisode.js index 0d1a3a48..5825dd4e 100644 --- a/server/models/FeedEpisode.js +++ b/server/models/FeedEpisode.js @@ -53,9 +53,10 @@ class FeedEpisode extends Model { * @param {import('./Feed')} feed * @param {string} slug * @param {import('./PodcastEpisode')} episode + * @param {string} [existingEpisodeId] */ - static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode) { - const episodeId = uuidv4() + static getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode, existingEpisodeId = null) { + const episodeId = existingEpisodeId || uuidv4() return { id: episodeId, title: episode.title, @@ -94,11 +95,18 @@ class FeedEpisode extends Model { libraryItemExpanded.media.podcastEpisodes.sort((a, b) => new Date(a.pubDate) - new Date(b.pubDate)) } + let numExisting = 0 for (const episode of libraryItemExpanded.media.podcastEpisodes) { - feedEpisodeObjs.push(this.getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode)) + // Check for existing episode by filepath + const existingEpisode = feed.feedEpisodes?.find((feedEpisode) => { + return feedEpisode.filePath === episode.audioFile.metadata.path + }) + numExisting = existingEpisode ? numExisting + 1 : numExisting + + feedEpisodeObjs.push(this.getFeedEpisodeObjFromPodcastEpisode(libraryItemExpanded, feed, slug, episode, existingEpisode?.id)) } - Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`) - return this.bulkCreate(feedEpisodeObjs, { transaction }) + Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`) + return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] }) } /** @@ -127,11 +135,12 @@ class FeedEpisode extends Model { * @param {string} slug * @param {import('./Book').AudioFileObject} audioTrack * @param {boolean} useChapterTitles + * @param {string} [existingEpisodeId] */ - static getFeedEpisodeObjFromAudiobookTrack(book, pubDateStart, feed, slug, audioTrack, useChapterTitles) { + 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 - let episodeId = uuidv4() + let episodeId = existingEpisodeId || uuidv4() // e.g. Track 1 will have a pub date before Track 2 const audiobookPubDate = date.format(new Date(pubDateStart.valueOf() + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]') @@ -179,11 +188,18 @@ class FeedEpisode extends Model { const useChapterTitles = this.checkUseChapterTitlesForEpisodes(libraryItemExpanded.media) const feedEpisodeObjs = [] + let numExisting = 0 for (const track of libraryItemExpanded.media.trackList) { - feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded.media, libraryItemExpanded.createdAt, feed, slug, track, useChapterTitles)) + // Check for existing episode by filepath + const existingEpisode = feed.feedEpisodes?.find((episode) => { + return episode.filePath === track.metadata.path + }) + numExisting = existingEpisode ? numExisting + 1 : numExisting + + feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(libraryItemExpanded.media, libraryItemExpanded.createdAt, feed, slug, track, useChapterTitles, existingEpisode?.id)) } - Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`) - return this.bulkCreate(feedEpisodeObjs, { transaction }) + Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`) + return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] }) } /** @@ -200,14 +216,21 @@ class FeedEpisode extends Model { }).libraryItem.createdAt const feedEpisodeObjs = [] + let numExisting = 0 for (const book of books) { const useChapterTitles = this.checkUseChapterTitlesForEpisodes(book) for (const track of book.trackList) { - feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(book, earliestLibraryItemCreatedAt, feed, slug, track, useChapterTitles)) + // Check for existing episode by filepath + const existingEpisode = feed.feedEpisodes?.find((episode) => { + return episode.filePath === track.metadata.path + }) + numExisting = existingEpisode ? numExisting + 1 : numExisting + + feedEpisodeObjs.push(this.getFeedEpisodeObjFromAudiobookTrack(book, earliestLibraryItemCreatedAt, feed, slug, track, useChapterTitles, existingEpisode?.id)) } } - Logger.info(`[FeedEpisode] Creating ${feedEpisodeObjs.length} episodes for feed ${feed.id}`) - return this.bulkCreate(feedEpisodeObjs, { transaction }) + Logger.info(`[FeedEpisode] Upserting ${feedEpisodeObjs.length} episodes for feed ${feed.id} (${numExisting} existing)`) + return this.bulkCreate(feedEpisodeObjs, { transaction, updateOnDuplicate: ['title', 'author', 'description', 'siteURL', 'enclosureURL', 'enclosureType', 'enclosureSize', 'pubDate', 'season', 'episode', 'episodeType', 'duration', 'filePath', 'explicit'] }) } /** From e7f7d1a573bd88e6d7d7fa5514a6178d91aaea92 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 1 Jan 2025 12:06:01 -0600 Subject: [PATCH 25/52] Fix refresh feed when book is deleted and belonged to a series/collection --- server/managers/RssFeedManager.js | 17 ++++++++++------- server/models/Feed.js | 8 ++++---- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index abfa445f..de009c3d 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -122,7 +122,7 @@ class RssFeedManager { attributes: ['id', 'updatedAt'], include: { model: Database.bookModel, - attributes: ['id', 'updatedAt'], + attributes: ['id', 'audioFiles', 'updatedAt'], through: { attributes: [] }, @@ -133,14 +133,16 @@ class RssFeedManager { } }) + const totalBookTracks = feed.entity.books.reduce((total, book) => total + book.includedAudioFiles.length, 0) + if (feed.feedEpisodes.length !== totalBookTracks) { + return true + } + let newEntityUpdatedAt = feed.entity.updatedAt const mostRecentItemUpdatedAt = feed.entity.books.reduce((mostRecent, book) => { let updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt - if (updatedAt > mostRecent) { - return updatedAt - } - return mostRecent + return updatedAt > mostRecent ? updatedAt : mostRecent }, 0) if (mostRecentItemUpdatedAt > newEntityUpdatedAt) { @@ -163,6 +165,9 @@ class RssFeedManager { let feed = await Database.feedModel.findOne({ where: { slug: req.params.slug + }, + include: { + model: Database.feedEpisodeModel } }) if (!feed) { @@ -175,8 +180,6 @@ class RssFeedManager { if (feedRequiresUpdate) { Logger.info(`[RssFeedManager] Feed "${feed.title}" requires update - updating feed`) feed = await feed.updateFeedForEntity() - } else { - feed.feedEpisodes = await feed.getFeedEpisodes() } const xml = feed.buildXml(req.originalHostPrefix) diff --git a/server/models/Feed.js b/server/models/Feed.js index a6ccff22..41bca449 100644 --- a/server/models/Feed.js +++ b/server/models/Feed.js @@ -190,7 +190,8 @@ class Feed extends Model { const booksWithTracks = collectionExpanded.books.filter((book) => book.includedAudioFiles.length) const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => { - return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent + const updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt + return updatedAt > mostRecent ? updatedAt : mostRecent }, collectionExpanded.updatedAt) const firstBookWithCover = booksWithTracks.find((book) => book.coverPath) @@ -278,7 +279,8 @@ class Feed extends Model { static getFeedObjForSeries(userId, seriesExpanded, slug, serverAddress, feedOptions = null) { const booksWithTracks = seriesExpanded.books.filter((book) => book.includedAudioFiles.length) const entityUpdatedAt = booksWithTracks.reduce((mostRecent, book) => { - return book.libraryItem.updatedAt > mostRecent ? book.libraryItem.updatedAt : mostRecent + const updatedAt = book.libraryItem.updatedAt > book.updatedAt ? book.libraryItem.updatedAt : book.updatedAt + return updatedAt > mostRecent ? updatedAt : mostRecent }, seriesExpanded.updatedAt) const firstBookWithCover = booksWithTracks.find((book) => book.coverPath) @@ -475,8 +477,6 @@ class Feed extends Model { /** @type {typeof import('./FeedEpisode')} */ const feedEpisodeModel = this.sequelize.models.feedEpisode - this.feedEpisodes = await this.getFeedEpisodes() - let feedObj = null let feedEpisodeCreateFunc = null let feedEpisodeCreateFuncEntity = null From f3918a47e14160ce02c79364cc8c04d10023b8c0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 1 Jan 2025 12:48:58 -0600 Subject: [PATCH 26/52] Auto formatting --- server/Server.js | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/server/Server.js b/server/Server.js index 9bbdb486..e9e77f00 100644 --- a/server/Server.js +++ b/server/Server.js @@ -56,23 +56,24 @@ class Server { global.AllowCors = process.env.ALLOW_CORS === '1' if (process.env.EXP_PROXY_SUPPORT === '1') { - Logger.info(`[Server] Experimental Proxy Support Enabled, SSRF Request Filter was Disabled`); + // https://github.com/advplyr/audiobookshelf/pull/3754 + Logger.info(`[Server] Experimental Proxy Support Enabled, SSRF Request Filter was Disabled`) global.DisableSsrfRequestFilter = () => true - - axios.defaults.maxRedirects = 0; + + axios.defaults.maxRedirects = 0 axios.interceptors.response.use( - response => response, - error => { + (response) => response, + (error) => { if ([301, 302].includes(error.response?.status)) { return axios({ ...error.config, - url: error.response.headers.location, - }); + url: error.response.headers.location + }) } - - return Promise.reject(error); + + return Promise.reject(error) } - ); + ) } else if (process.env.DISABLE_SSRF_REQUEST_FILTER === '1') { Logger.info(`[Server] SSRF Request Filter Disabled`) global.DisableSsrfRequestFilter = () => true From ed17dd9b512f5813b9df41b64e86a74ed097bdf0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 1 Jan 2025 13:49:22 -0600 Subject: [PATCH 27/52] Fix user stats heatmap caption text to be accurate --- client/components/stats/Heatmap.vue | 7 +++++-- client/strings/bg.json | 1 - client/strings/bn.json | 1 - client/strings/cs.json | 1 - client/strings/da.json | 1 - client/strings/de.json | 1 - client/strings/en-us.json | 2 +- client/strings/es.json | 1 - client/strings/et.json | 1 - client/strings/fr.json | 1 - client/strings/he.json | 1 - client/strings/hr.json | 1 - client/strings/hu.json | 1 - client/strings/it.json | 1 - client/strings/lt.json | 1 - client/strings/nl.json | 1 - client/strings/no.json | 1 - client/strings/pl.json | 1 - client/strings/pt-br.json | 1 - client/strings/ru.json | 1 - client/strings/sl.json | 1 - client/strings/sv.json | 1 - client/strings/uk.json | 1 - client/strings/vi-vn.json | 1 - client/strings/zh-cn.json | 1 - client/strings/zh-tw.json | 1 - 26 files changed, 6 insertions(+), 27 deletions(-) diff --git a/client/components/stats/Heatmap.vue b/client/components/stats/Heatmap.vue index 46ac9609..4e491621 100644 --- a/client/components/stats/Heatmap.vue +++ b/client/components/stats/Heatmap.vue @@ -1,7 +1,7 @@