From 733afc3e297dc691d8b3c6f0f22b861d2b7e8323 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 19 May 2025 17:37:11 -0500 Subject: [PATCH 001/171] Update edit series sequence to show error when sequence has spaces #4314 --- .../modals/EditSeriesInputInnerModal.vue | 16 +++++++++++++++- client/components/widgets/SeriesInputWidget.vue | 5 ++++- client/strings/en-us.json | 1 + 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/client/components/modals/EditSeriesInputInnerModal.vue b/client/components/modals/EditSeriesInputInnerModal.vue index 6190551e..bd568321 100644 --- a/client/components/modals/EditSeriesInputInnerModal.vue +++ b/client/components/modals/EditSeriesInputInnerModal.vue @@ -14,6 +14,7 @@ +
{{ error }}
{{ $strings.ButtonSubmit }}
@@ -34,12 +35,17 @@ export default { existingSeriesNames: { type: Array, default: () => [] + }, + originalSeriesSequence: { + type: String, + default: null } }, data() { return { el: null, - content: null + content: null, + error: null } }, watch: { @@ -85,10 +91,17 @@ export default { } }, submitSeriesForm() { + this.error = null + if (this.$refs.newSeriesSelect) { this.$refs.newSeriesSelect.blur() } + if (this.selectedSeries.sequence !== this.originalSeriesSequence && this.selectedSeries.sequence.includes(' ')) { + this.error = this.$strings.MessageSeriesSequenceCannotContainSpaces + return + } + this.$emit('submit') }, clickClose() { @@ -100,6 +113,7 @@ export default { } }, setShow() { + this.error = null if (!this.el || !this.content) { this.init() } diff --git a/client/components/widgets/SeriesInputWidget.vue b/client/components/widgets/SeriesInputWidget.vue index 916b108d..d6c8cf9f 100644 --- a/client/components/widgets/SeriesInputWidget.vue +++ b/client/components/widgets/SeriesInputWidget.vue @@ -2,7 +2,7 @@
- +
@@ -18,6 +18,7 @@ export default { data() { return { selectedSeries: null, + originalSeriesSequence: null, showSeriesForm: false } }, @@ -59,6 +60,7 @@ export default { ..._series } + this.originalSeriesSequence = _series.sequence this.showSeriesForm = true }, addNewSeries() { @@ -68,6 +70,7 @@ export default { sequence: '' } + this.originalSeriesSequence = null this.showSeriesForm = true }, submitSeriesForm() { diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 47b64c1a..939eb9f4 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -856,6 +856,7 @@ "MessageScheduleRunEveryWeekdayAtTime": "Run every {0} at {1}", "MessageSearchResultsFor": "Search results for", "MessageSelected": "{0} selected", + "MessageSeriesSequenceCannotContainSpaces": "Series sequence cannot contain spaces", "MessageServerCouldNotBeReached": "Server could not be reached", "MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name", "MessageShareExpirationWillBe": "Expiration will be {0}", From cae874ef0577acbd2bdf4dc02f601caaaadebddf Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 20 May 2025 17:44:13 -0500 Subject: [PATCH 002/171] Update max allowed json request size #4250 --- client/components/modals/podcast/EpisodeFeed.vue | 4 ++-- server/Server.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/components/modals/podcast/EpisodeFeed.vue b/client/components/modals/podcast/EpisodeFeed.vue index 08f2f38c..7ec14ccd 100644 --- a/client/components/modals/podcast/EpisodeFeed.vue +++ b/client/components/modals/podcast/EpisodeFeed.vue @@ -244,8 +244,8 @@ export default { const sizeInMb = payloadSize / 1024 / 1024 const sizeInMbPretty = sizeInMb.toFixed(2) + 'MB' console.log('Request size', sizeInMb) - if (sizeInMb > 4.99) { - return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`) + if (sizeInMb > 9.99) { + return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 10Mb`) } this.processing = true diff --git a/server/Server.js b/server/Server.js index ef6ecb8a..17c959c0 100644 --- a/server/Server.js +++ b/server/Server.js @@ -310,7 +310,7 @@ class Server { }) ) router.use(express.urlencoded({ extended: true, limit: '5mb' })) - router.use(express.json({ limit: '5mb' })) + router.use(express.json({ limit: '10mb' })) router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router) router.use('/hls', this.hlsRouter.router) From 6d1f0b27df782b464de8cb3d04c51b02b34c91d5 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 22 May 2025 17:30:38 -0500 Subject: [PATCH 003/171] Fix MediaProgress not using the lastUpdate time sent for local progress syncs --- server/models/MediaProgress.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js index a4a4185c..af3cbc5f 100644 --- a/server/models/MediaProgress.js +++ b/server/models/MediaProgress.js @@ -246,9 +246,10 @@ class MediaProgress extends Model { // For local sync if (progressPayload.lastUpdate) { this.updatedAt = progressPayload.lastUpdate + this.changed('updatedAt', true) } - return this.save() + return this.save({ silent: !!progressPayload.lastUpdate }) } } From d03c338b489f2c10f977da34651d8f28321b0f50 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 24 May 2025 17:09:58 -0500 Subject: [PATCH 004/171] Fix log for podcast rss feed with no guid #4325 --- server/utils/podcastUtils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index a7ecce8a..3a1df198 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -205,7 +205,7 @@ function extractEpisodeData(item) { } else if (typeof guidItem?._ === 'string') { episode.guid = guidItem._ } else { - Logger.error(`[podcastUtils] Invalid guid ${item['guid']} for ${episode.enclosure.url}`) + Logger.error(`[podcastUtils] Invalid guid for ${episode.enclosure.url}`, item['guid']) } } From f05a513767cbc1640fa54f547c645a89133c61d5 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 25 May 2025 16:12:35 -0500 Subject: [PATCH 005/171] Fix m4b encoder bitrate preset selection #4337 --- client/components/widgets/EncoderOptionsCard.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/widgets/EncoderOptionsCard.vue b/client/components/widgets/EncoderOptionsCard.vue index 9306274d..005563b4 100644 --- a/client/components/widgets/EncoderOptionsCard.vue +++ b/client/components/widgets/EncoderOptionsCard.vue @@ -162,7 +162,7 @@ export default { } else { // Find closest bitrate rounding up const bitratesToMatch = [32, 64, 128, 192] - const closestBitrate = bitratesToMatch.find((bitrate) => bitrate >= this.currentBitrate) + const closestBitrate = bitratesToMatch.find((bitrate) => bitrate >= this.currentBitrate) || 192 this.selectedBitrate = closestBitrate + 'k' } From 6ce1806359d9560b7aab45252d846434d8b79c4e Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 26 May 2025 16:56:50 -0500 Subject: [PATCH 006/171] Update pathexists file system API endpoint --- client/pages/upload/index.vue | 5 +- server/controllers/FileSystemController.js | 74 ++++++++++++++-------- 2 files changed, 48 insertions(+), 31 deletions(-) diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index 2c9442ca..eef05b60 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -359,15 +359,14 @@ export default { // Check if path already exists before starting upload // uploading fails if path already exists for (const item of items) { - const filepath = Path.join(this.selectedFolder.fullPath, item.directory) const exists = await this.$axios - .$post(`/api/filesystem/pathexists`, { filepath, directory: item.directory, folderPath: this.selectedFolder.fullPath }) + .$post(`/api/filesystem/pathexists`, { directory: item.directory, folderPath: this.selectedFolder.fullPath }) .then((data) => { if (data.exists) { if (data.libraryItemTitle) { this.$toast.error(this.$getString('ToastUploaderItemExistsInSubdirectoryError', [data.libraryItemTitle])) } else { - this.$toast.error(this.$getString('ToastUploaderFilepathExistsError', [filepath])) + this.$toast.error(this.$getString('ToastUploaderFilepathExistsError', [Path.join(this.selectedFolder.fullPath, item.directory)])) } } return data.exists diff --git a/server/controllers/FileSystemController.js b/server/controllers/FileSystemController.js index d0b190a4..7629f9ee 100644 --- a/server/controllers/FileSystemController.js +++ b/server/controllers/FileSystemController.js @@ -84,49 +84,67 @@ class FileSystemController { */ async checkPathExists(req, res) { if (!req.user.canUpload) { - Logger.error(`[FileSystemController] Non-admin user "${req.user.username}" attempting to check path exists`) + Logger.error(`[FileSystemController] User "${req.user.username}" without upload permissions attempting to check path exists`) return res.sendStatus(403) } - const { filepath, directory, folderPath } = req.body + const { directory, folderPath } = req.body - if (!filepath?.length || typeof filepath !== 'string') { + if (!directory?.length || typeof directory !== 'string' || !folderPath?.length || typeof folderPath !== 'string') { + Logger.error(`[FileSystemController] Invalid request body: ${JSON.stringify(req.body)}`) + return res.status(400).json({ + error: 'Invalid request body' + }) + } + + // Check that library folder exists + const libraryFolder = await Database.libraryFolderModel.findOne({ + where: { + path: folderPath + } + }) + + if (!libraryFolder) { + Logger.error(`[FileSystemController] Library folder not found: ${folderPath}`) + return res.sendStatus(404) + } + + const filepath = Path.posix.join(libraryFolder.path, directory) + // Ensure filepath is inside library folder (prevents directory traversal) + if (!filepath.startsWith(libraryFolder.path)) { + Logger.error(`[FileSystemController] Filepath is not inside library folder: ${filepath}`) return res.sendStatus(400) } - const exists = await fs.pathExists(filepath) - - if (exists) { + if (await fs.pathExists(filepath)) { return res.json({ exists: true }) } - // If directory and folderPath are passed in, check if a library item exists in a subdirectory + // Check if a library item exists in a subdirectory // See: https://github.com/advplyr/audiobookshelf/issues/4146 - if (typeof directory === 'string' && typeof folderPath === 'string' && directory.length > 0 && folderPath.length > 0) { - const cleanedDirectory = directory.split('/').filter(Boolean).join('/') - if (cleanedDirectory.includes('/')) { - // Can only be 2 levels deep - const possiblePaths = [] - const subdir = Path.dirname(directory) - possiblePaths.push(fileUtils.filePathToPOSIX(Path.join(folderPath, subdir))) - if (subdir.includes('/')) { - possiblePaths.push(fileUtils.filePathToPOSIX(Path.join(folderPath, Path.dirname(subdir)))) - } + const cleanedDirectory = directory.split('/').filter(Boolean).join('/') + if (cleanedDirectory.includes('/')) { + // Can only be 2 levels deep + const possiblePaths = [] + const subdir = Path.dirname(directory) + possiblePaths.push(fileUtils.filePathToPOSIX(Path.join(folderPath, subdir))) + if (subdir.includes('/')) { + possiblePaths.push(fileUtils.filePathToPOSIX(Path.join(folderPath, Path.dirname(subdir)))) + } - const libraryItem = await Database.libraryItemModel.findOne({ - where: { - path: possiblePaths - } + const libraryItem = await Database.libraryItemModel.findOne({ + where: { + path: possiblePaths + } + }) + + if (libraryItem) { + return res.json({ + exists: true, + libraryItemTitle: libraryItem.title }) - - if (libraryItem) { - return res.json({ - exists: true, - libraryItemTitle: libraryItem.title - }) - } } } From 1119ddef8a12ddc64d566a6c52fd30a32a6b3dca Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 27 May 2025 17:56:27 -0500 Subject: [PATCH 007/171] Add RSS Feed Open filter for podcast libraries to match book libraries #4335 --- client/components/controls/LibraryFilterSelect.vue | 5 +++++ server/utils/queries/libraryItemsPodcastFilters.js | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/client/components/controls/LibraryFilterSelect.vue b/client/components/controls/LibraryFilterSelect.vue index 4fd72827..f5eec41a 100644 --- a/client/components/controls/LibraryFilterSelect.vue +++ b/client/components/controls/LibraryFilterSelect.vue @@ -276,6 +276,11 @@ export default { text: this.$strings.ButtonIssues, value: 'issues', sublist: false + }, + { + text: this.$strings.LabelRSSFeedOpen, + value: 'feed-open', + sublist: false } ] }, diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 68097c1b..33bac28f 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -149,11 +149,12 @@ module.exports = { libraryId } const libraryItemIncludes = [] - if (includeRSSFeed) { + if (filterGroup === 'feed-open' || includeRSSFeed) { + const rssFeedRequired = filterGroup === 'feed-open' libraryItemIncludes.push({ model: Database.feedModel, - required: filterGroup === 'feed-open', - separate: true + required: rssFeedRequired, + separate: !rssFeedRequired }) } if (filterGroup === 'issues') { From 68a39449a283b7a9229b3f993cff0f832d75b8aa Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Sat, 17 May 2025 18:30:05 +0200 Subject: [PATCH 008/171] Translated using Weblate (German) Currently translated at 99.6% (1100 of 1104 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/de.json b/client/strings/de.json index b08db87c..03b850c4 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -974,6 +974,7 @@ "ToastCachePurgeFailed": "Cache leeren fehlgeschlagen", "ToastCachePurgeSuccess": "Cache geleert", "ToastChaptersHaveErrors": "Kapitel sind fehlerhaft", + "ToastChaptersInvalidShiftAmountLast": "Die Verschiebung ist nicht möglich, da die Startzeit des letzten Kapitels über die Gesamtdauer dieses Hörbuchs hinausgehen würde.", "ToastChaptersMustHaveTitles": "Kapitel benötigen eindeutige Namen", "ToastChaptersRemoved": "Kapitel entfernt", "ToastChaptersUpdated": "Kapitel aktualisiert", From 6425d95deb10db6b8492662499233af50d3e1df9 Mon Sep 17 00:00:00 2001 From: Usama Khalil Date: Sat, 17 May 2025 19:12:58 +0200 Subject: [PATCH 009/171] Translated using Weblate (Arabic) Currently translated at 27.4% (303 of 1104 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/ --- client/strings/ar.json | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/client/strings/ar.json b/client/strings/ar.json index b616e255..17fd494e 100644 --- a/client/strings/ar.json +++ b/client/strings/ar.json @@ -30,8 +30,8 @@ "ButtonEditChapters": "تعديل الفصول", "ButtonEditPodcast": "تعديل البودكاست", "ButtonEnable": "تفعيل", - "ButtonFireAndFail": "النار والفشل", - "ButtonFireOnTest": "حادثة إطلاق النار", + "ButtonFireAndFail": "محاولة فاشلة", + "ButtonFireOnTest": "تشغيل حدث الاختبار", "ButtonForceReScan": "فرض إعادة المسح", "ButtonFullPath": "المسار الكامل", "ButtonHide": "إخفاء", @@ -258,6 +258,40 @@ "LabelStatsItemsFinished": "العناصر المنتهية", "LabelStatsMinutes": "دقائق", "LabelStatsMinutesListening": "دقائق الاستماع", + "LabelStatsWeekListening": "استماع هذا الأسبوع", + "LabelTag": "علامة", + "LabelTags": "علامات", + "LabelTheme": "النمط", + "LabelThemeDark": "غامق", + "LabelThemeLight": "فاتح", + "LabelTimeRemaining": "{0} متبقية", + "LabelTitle": "عنوان", + "LabelTracks": "المسارات", + "LabelType": "نوع", + "LabelUnknown": "مجهول", + "LabelUser": "مستخدم", + "LabelUsername": "اسم المستخدم", + "LabelYearReviewHide": "إخفاء ملخص العام", + "LabelYearReviewShow": "عرض ملخص العام", + "LabelYourBookmarks": "علاماتك المرجعية", + "LabelYourProgress": "تقدمك", + "MessageDownloadingEpisode": "جاري تنزيل الحلقة", + "MessageEpisodesQueuedForDownload": "تمت إضافة {0} حلقة (حلقات) إلى قائمة انتظار التنزيل", + "MessageFeedURLWillBe": "سيكون رابط التغذية هو {0}", + "MessageFetching": "جاري الجلب...", + "MessageLoading": "جاري التحميل...", + "MessageMarkAsFinished": "وضع علامة \"تم الإنتهاء\"", + "MessageNoBookmarks": "لا توجد علامات مرجعية", + "MessageNoChapters": "لا توجد فصول", + "MessageNoCollections": "لا توجد مجموعات", + "MessageNoItems": "لا توجد عناصر", + "MessageNoItemsFound": "لم يتم العثور على عناصر", + "MessageNoListeningSessions": "لا توجد جلسات استماع", + "MessageNoPodcastsFound": "لم يتم العثور على أي بودكاست", + "MessageNoUpdatesWereNecessary": "لا حاجة لأي تحديثات", + "MessageNoUserPlaylists": "ليست لديك أي قوائم تشغيل", + "MessagePodcastSearchField": "أدخل مصطلح البحث أو عنوان URL لخلاصة RSS", + "MessageReportBugsAndContribute": "أبلغ عن الأخطاء، واطلب الميزات، وساهم في", "NoteRSSFeedPodcastAppsHttps": "تحذير: تتطلب معظم تطبيقات البث الصوتي أن يكون عنوان URL لخلاصة RSS يستخدم HTTPS", "NoteRSSFeedPodcastAppsPubDate": "تحذير: حلقة واحدة أو أكثر من حلقاتك ليس لها تاريخ نشر. بعض تطبيقات البودكاست تتطلب هذا.", "ToastBookmarkCreateFailed": "فشل في إنشاء الإشارة المرجعية", From 273866fe92a61e3199503805e081c41a282e6fe1 Mon Sep 17 00:00:00 2001 From: Usama Khalil Date: Sat, 17 May 2025 21:35:33 +0200 Subject: [PATCH 010/171] Translated using Weblate (Arabic) Currently translated at 36.5% (404 of 1104 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/ --- client/strings/ar.json | 101 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/client/strings/ar.json b/client/strings/ar.json index 17fd494e..717a88a0 100644 --- a/client/strings/ar.json +++ b/client/strings/ar.json @@ -10,6 +10,8 @@ "ButtonApplyChapters": "حفظ الفصول", "ButtonAuthors": "المؤلفون", "ButtonBack": "الرجوع", + "ButtonBatchEditPopulateFromExisting": "تعبئة من الموجود", + "ButtonBatchEditPopulateMapDetails": "تعبئة تفاصيل الخريطة", "ButtonBrowseForFolder": "البحث عن المجلد", "ButtonCancel": "إلغاء", "ButtonCancelEncode": "إلغاء الترميز", @@ -88,6 +90,8 @@ "ButtonSaveTracklist": "حفظ قائمة التشغيل", "ButtonScan": "تَحَقُق", "ButtonScanLibrary": "تَحَقُق من المكتبة", + "ButtonScrollLeft": "تمرير لليسار", + "ButtonScrollRight": "تمرير لليمين", "ButtonSearch": "بحث", "ButtonSelectFolderPath": "حدد مسار المجلد", "ButtonSeries": "سلسلة", @@ -153,30 +157,127 @@ "HeaderLogs": "السجلات", "HeaderManageGenres": "إدارة الانواع", "HeaderManageTags": "إدارة العلامات", + "HeaderMapDetails": "تفاصيل الخريطة", + "HeaderMatch": "مطابقة", + "HeaderMetadataOrderOfPrecedence": "ترتيب أولوية البيانات الوصفية", + "HeaderMetadataToEmbed": "البيانات الوصفية المراد تضمينها", + "HeaderNewAccount": "حساب جديد", + "HeaderNewLibrary": "مكتبة جديدة", + "HeaderNotificationCreate": "إنشاء إشعار", + "HeaderNotificationUpdate": "تحديث إشعار", + "HeaderNotifications": "إشعارات", + "HeaderOpenIDConnectAuthentication": "مصادقة OpenID Connect", + "HeaderOpenListeningSessions": "جلسات الاستماع المفتوحة", "HeaderOpenRSSFeed": "فتح تغذية RSS", + "HeaderOtherFiles": "ملفات أخرى", + "HeaderPasswordAuthentication": "مصادقة كلمة المرور", + "HeaderPermissions": "الصلاحيات", + "HeaderPlayerQueue": "قائمة انتظار المشغل", + "HeaderPlayerSettings": "إعدادات المشغل", "HeaderPlaylist": "قائمة تشغيل", "HeaderPlaylistItems": "عناصر قائمة التشغيل", + "HeaderPodcastsToAdd": "بودكاست لإضافتها", + "HeaderPresets": "إعدادات مسبقة", + "HeaderPreviewCover": "معاينة الغلاف", "HeaderRSSFeedGeneral": "تفاصيل RSS", "HeaderRSSFeedIsOpen": "مغذي RSS مفتوح", + "HeaderRSSFeeds": "خلاصات RSS", + "HeaderRemoveEpisode": "إزالة حلقة", + "HeaderRemoveEpisodes": "إزالة {0} حلقات", + "HeaderSavedMediaProgress": "تقدم الوسائط المحفوظة", + "HeaderSchedule": "جَدْوَل", + "HeaderScheduleEpisodeDownloads": "جدولة التنزيلات التلقائية للحلقات", + "HeaderScheduleLibraryScans": "جدولة عمليات المسح التلقائي للمكتبة", + "HeaderSession": "الجلسة", + "HeaderSetBackupSchedule": "تعيين جدول النسخ الاحتياطي", "HeaderSettings": "إعدادات", + "HeaderSettingsDisplay": "عرض", + "HeaderSettingsExperimental": "ميزات تجريبية", + "HeaderSettingsGeneral": "عام", + "HeaderSettingsScanner": "إعدادات المسح", + "HeaderSettingsWebClient": "عميل الويب", "HeaderSleepTimer": "مؤقت النوم", + "HeaderStatsLargestItems": "أكبر العناصر حجماً", + "HeaderStatsLongestItems": "أطول العناصر (بالساعات)", "HeaderStatsMinutesListeningChart": "الدقائق المسموعة (آخر 7 أيام)", "HeaderStatsRecentSessions": "الجلسات الأخيرة", + "HeaderStatsTop10Authors": "أفضل 10 مؤلفين", + "HeaderStatsTop5Genres": "أفضل 5 أنواع", "HeaderTableOfContents": "جدول المحتويات", + "HeaderTools": "أدوات", + "HeaderUpdateAccount": "تحديث الحساب", + "HeaderUpdateAuthor": "تحديث المؤلف", + "HeaderUpdateDetails": "تحديث التفاصيل", + "HeaderUpdateLibrary": "تحديث المكتبة", + "HeaderUsers": "المستخدمون", + "HeaderYearReview": "ملخص عام {0}", "HeaderYourStats": "إحصائياتك", + "LabelAbridged": "مختصر", + "LabelAbridgedChecked": "مختصر (محدد)", + "LabelAbridgedUnchecked": "غير مختصر (غير محدد)", + "LabelAccessibleBy": "يمكن الوصول إليه بواسطة", + "LabelAccountType": "نوع الحساب", + "LabelAccountTypeAdmin": "مدير", + "LabelAccountTypeGuest": "ضيف", + "LabelAccountTypeUser": "مستخدم", + "LabelActivities": "النشاطات", + "LabelActivity": "نشاط", + "LabelAddToCollection": "إضافة إلى المجموعة", + "LabelAddToCollectionBatch": "إضافة {0} كتابًا إلى المجموعة", "LabelAddToPlaylist": "أضف إلى قائمة التشغيل", + "LabelAddToPlaylistBatch": "إضافة {0} عناصر إلى قائمة التشغيل", "LabelAddedAt": "أضيفت على", "LabelAddedDate": "تمت الإضافة", + "LabelAdminUsersOnly": "للمستخدمين المديرين فقط", "LabelAll": "الكل", + "LabelAllEpisodesDownloaded": "تم تنزيل جميع الحلقات", + "LabelAllUsers": "جميع المستخدمين", + "LabelAllUsersExcludingGuests": "جميع المستخدمين باستثناء الضيوف", + "LabelAllUsersIncludingGuests": "جميع المستخدمين بما في ذلك الضيوف", + "LabelAlreadyInYourLibrary": "موجود بالفعل في مكتبتك", + "LabelApiToken": "رمز API", + "LabelAppend": "إلحاق", + "LabelAudioBitrate": "معدل بت الصوت (على سبيل المثال 128 كيلو بايت)", + "LabelAudioChannels": "قنوات الصوت (1 أو 2)", + "LabelAudioCodec": "برنامج ترميز الصوت", "LabelAuthor": "المؤلف", "LabelAuthorFirstLast": "المؤلف (الاسم الأول الأخير)", "LabelAuthorLastFirst": "المؤلف (الاسم الأخير، الأول)", "LabelAuthors": "المؤلفون", "LabelAutoDownloadEpisodes": "تنزيل الحلقات تلقائيًا", + "LabelAutoFetchMetadata": "جلب البيانات الوصفية تلقائيًا", + "LabelAutoFetchMetadataHelp": "يجلب البيانات الوصفية للعنوان والمؤلف والسلسلة لتسهيل عملية الرفع. قد يلزم مطابقة بيانات وصفية إضافية بعد الرفع.", + "LabelAutoLaunch": "تشغيل تلقائي", + "LabelAutoLaunchDescription": "إعادة التوجيه إلى مزود المصادقة تلقائيًا عند الانتقال إلى صفحة تسجيل الدخول (مسار التجاوز اليدوي /login?autoLaunch=0)", + "LabelAutoRegister": "تسجيل تلقائي", + "LabelAutoRegisterDescription": "إنشاء مستخدمين جدد تلقائيًا بعد تسجيل الدخول", + "LabelBackToUser": "العودة إلى المستخدم", + "LabelBackupAudioFiles": "نسخ ملفات الصوت احتياطيًا", + "LabelBackupLocation": "موقع النسخ الاحتياطي", + "LabelBackupsEnableAutomaticBackups": "نسخ احتياطية تلقائية", + "LabelBackupsEnableAutomaticBackupsHelp": "النسخ الاحتياطية المحفوظة في / البيانات الوصفية / النسخ الاحتياطية", + "LabelBackupsMaxBackupSize": "الحد الأقصى لحجم النسخ الاحتياطي (بالجيجابايت) (0 لغير محدود)", + "LabelBackupsMaxBackupSizeHelp": "كإجراء وقائي ضد سوء التكوين، ستفشل عمليات النسخ الاحتياطي إذا تجاوزت الحجم المحدد.", + "LabelBackupsNumberToKeep": "عدد النسخ الاحتياطية التي يجب الاحتفاظ بها", + "LabelBackupsNumberToKeepHelp": "ستتم إزالة نسخة احتياطية واحدة فقط في كل مرة، لذا إذا كان لديك بالفعل عدد نسخ احتياطية أكبر من هذا، فيجب عليك إزالتها يدويًا.", + "LabelBitrate": "معدل البت", + "LabelBonus": "مكافأة", "LabelBooks": "الكتب", + "LabelButtonText": "نص الزر", + "LabelByAuthor": "بواسطة {0}", + "LabelChangePassword": "تغيير كلمة المرور", + "LabelChannels": "قنوات", + "LabelChapterCount": "{0} فصول", + "LabelChapterTitle": "عنوان الفصل", "LabelChapters": "الفصول", + "LabelChaptersFound": "تم العثور على فصول", + "LabelClickForMoreInfo": "انقر لمزيد من المعلومات", + "LabelClickToUseCurrentValue": "انقر لاستخدام القيمة الحالية", "LabelClosePlayer": "إغلاق المشغل", + "LabelCodec": "برنامج الترميز", "LabelCollapseSeries": "إخفاء المسلسلات", + "LabelCollapseSubSeries": "إخفاء المسلسلات الفرعية", + "LabelCollection": "مجموعة", "LabelComplete": "مكتمل", "LabelContinueListening": "استمرار الاستماع", "LabelContinueReading": "استمرار القراءة", From 7486a0659b1a865dca1c5a3d720ef46d7a8e56a9 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Sun, 18 May 2025 10:47:04 +0200 Subject: [PATCH 011/171] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1104 of 1104 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index b6365b6e..21293a4a 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -177,6 +177,7 @@ "HeaderPlaylist": "播放列表", "HeaderPlaylistItems": "播放列表项目", "HeaderPodcastsToAdd": "要添加的播客", + "HeaderPresets": "预设", "HeaderPreviewCover": "预览封面", "HeaderRSSFeedGeneral": "RSS 详细信息", "HeaderRSSFeedIsOpen": "RSS 源已打开", @@ -530,6 +531,7 @@ "LabelReleaseDate": "发布日期", "LabelRemoveAllMetadataAbs": "删除所有 metadata.abs 文件", "LabelRemoveAllMetadataJson": "删除所有 metadata.json 文件", + "LabelRemoveAudibleBranding": "删除章节中的 Audible 简介和结尾", "LabelRemoveCover": "移除封面", "LabelRemoveMetadataFile": "删除库项目文件夹中的元数据文件", "LabelRemoveMetadataFileHelp": "删除 {0} 文件夹中的所有 metadata.json 和 metadata.abs 文件.", @@ -706,6 +708,7 @@ "MessageAddToPlayerQueue": "添加到播放队列", "MessageAppriseDescription": "要使用此功能,你需要运行一个 Apprise API 实例或一个可以处理这些相同请求的 API.
Apprise API Url 应该是发送通知的完整 URL 路径, 例如: 如果你的 API 实例运行在 http://192.168.1.1:8337, 那么你可以输入 http://192.168.1.1:8337/notify.", "MessageAsinCheck": "确保你使用的 ASIN 来自正确的 Audible 地区, 而不是亚马逊.", + "MessageAuthenticationOIDCChangesRestart": "保存后重新启动服务器以应用 OIDC 更改.", "MessageBackupsDescription": "备份包括用户, 用户进度, 媒体库项目详细信息, 服务器设置和图像, 存储在 /metadata/items & /metadata/authors. 备份不包括存储在你的媒体库文件夹中的任何文件.", "MessageBackupsLocationEditNote": "注意: 更新备份位置不会移动或修改现有备份", "MessageBackupsLocationNoEditNote": "注意: 备份位置是通过环境变量设置的, 不能在此处更改.", @@ -971,6 +974,8 @@ "ToastCachePurgeFailed": "清除缓存失败", "ToastCachePurgeSuccess": "缓存清除成功", "ToastChaptersHaveErrors": "章节有错误", + "ToastChaptersInvalidShiftAmountLast": "偏移量无效. 最后一章的开始时间将超过这本有声读物的持续时间.", + "ToastChaptersInvalidShiftAmountStart": "偏移量无效. 第一章的长度将为零或负数, 并会被第二章覆盖. 请增加第二章的起始时长.", "ToastChaptersMustHaveTitles": "章节必须有标题", "ToastChaptersRemoved": "已删除章节", "ToastChaptersUpdated": "章节已更新", From 535ebc10f030e68481b5a8389498697b4899ee3d Mon Sep 17 00:00:00 2001 From: Usama Khalil Date: Sat, 17 May 2025 22:25:45 +0200 Subject: [PATCH 012/171] Translated using Weblate (Arabic) Currently translated at 98.5% (1088 of 1104 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/ --- client/strings/ar.json | 692 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 688 insertions(+), 4 deletions(-) diff --git a/client/strings/ar.json b/client/strings/ar.json index 717a88a0..5c9265b5 100644 --- a/client/strings/ar.json +++ b/client/strings/ar.json @@ -278,129 +278,813 @@ "LabelCollapseSeries": "إخفاء المسلسلات", "LabelCollapseSubSeries": "إخفاء المسلسلات الفرعية", "LabelCollection": "مجموعة", + "LabelCollections": "مجموعات", "LabelComplete": "مكتمل", + "LabelConfirmPassword": "تأكيد كلمة المرور", "LabelContinueListening": "استمرار الاستماع", "LabelContinueReading": "استمرار القراءة", "LabelContinueSeries": "استمرار المسلسلات", + "LabelCover": "الغلاف", + "LabelCoverImageURL": "رابط صورة الغلاف", + "LabelCoverProvider": "مزود الغلاف", + "LabelCreatedAt": "تاريخ الإنشاء", + "LabelCronExpression": "تعبير Cron", + "LabelCurrent": "الحالي", + "LabelCurrently": "حاليًا:", + "LabelCustomCronExpression": "تعبير Cron مخصص:", + "LabelDatetime": "التاريخ والوقت", + "LabelDays": "أيام", + "LabelDeleteFromFileSystemCheckbox": "حذف من نظام الملفات (إلغاء التحديد للإزالة من قاعدة البيانات فقط)", "LabelDescription": "الوصف", + "LabelDeselectAll": "إلغاء تحديد الكل", + "LabelDevice": "الجهاز", + "LabelDeviceInfo": "معلومات الجهاز", + "LabelDeviceIsAvailableTo": "الجهاز متاح لـ...", + "LabelDirectory": "مجلد / دليل", + "LabelDiscFromFilename": "القرص من اسم الملف", + "LabelDiscFromMetadata": "القرص من البيانات الوصفية", "LabelDiscover": "استكشف", "LabelDownload": "تنزيل", + "LabelDownloadNEpisodes": "تنزيل {0} حلقات", + "LabelDownloadable": "قابل للتنزيل", "LabelDuration": "المدة", + "LabelDurationComparisonExactMatch": "(تطابق تام)", + "LabelDurationComparisonLonger": "(أطول بـ {0})", + "LabelDurationComparisonShorter": "({0} أقصر)", + "LabelDurationFound": "المدة الموجودة:", "LabelEbook": "الكتاب الإلكتروني", "LabelEbooks": "الكتب الإلكترونية", + "LabelEdit": "تعديل", + "LabelEmail": "البريد الإلكتروني", + "LabelEmailSettingsFromAddress": "عنوان المرسل", + "LabelEmailSettingsRejectUnauthorized": "رفض الشهادات غير المصرح بها", + "LabelEmailSettingsRejectUnauthorizedHelp": "قد يؤدي تعطيل التحقق من شهادة SSL إلى تعريض اتصالك لمخاطر أمنية، مثل هجمات الوسيط. لا تقم بتعطيل هذا الخيار إلا إذا كنت تفهم الآثار المترتبة عليه وتثق في خادم البريد الذي تتصل به.", + "LabelEmailSettingsSecure": "آمن", + "LabelEmailSettingsSecureHelp": "إذا كانت القيمة true، فسيستخدم الاتصال TLS عند الاتصال بالخادم. وإذا كانت false، فسيتم استخدام TLS إذا كان الخادم يدعم امتداد STARTTLS. في معظم الحالات، اضبط هذه القيمة على true إذا كنت تتصل بالمنفذ 465. أما بالنسبة للمنفذ 587 أو 25، فاحتفظ بها على false. (من nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsTestAddress": "عنوان الاختبار", + "LabelEmbeddedCover": "غلاف مضمن", "LabelEnable": "تمكين", + "LabelEncodingBackupLocation": "سيتم تخزين نسخة احتياطية من ملفاتك الصوتية الأصلية في:", + "LabelEncodingChaptersNotEmbedded": "الفصول غير مضمنة في الكتب الصوتية متعددة المسارات.", + "LabelEncodingClearItemCache": "تأكد من مسح ذاكرة التخزين المؤقت للعناصر بشكل دوري.", + "LabelEncodingFinishedM4B": "سيتم وضع ملف M4B النهائي في مجلد الكتب الصوتية الخاص بك في:", + "LabelEncodingInfoEmbedded": "سيتم تضمين البيانات الوصفية في المسارات الصوتية داخل مجلد الكتب الصوتية الخاص بك.", + "LabelEncodingStartedNavigation": "بمجرد بدء المهمة، يمكنك الانتقال من هذه الصفحة.", + "LabelEncodingTimeWarning": "قد تستغرق عملية الترميز ما يصل إلى 30 دقيقة.", + "LabelEncodingWarningAdvancedSettings": "تحذير: لا تقم بتحديث هذه الإعدادات إلا إذا كنت على دراية بخيارات ترميز ffmpeg.", + "LabelEncodingWatcherDisabled": "إذا قمت بتعطيل المراقب، فستحتاج إلى إعادة فحص هذا الكتاب الصوتي بعد ذلك.", "LabelEnd": "انهاء", "LabelEndOfChapter": "نهاية الفصل", "LabelEpisode": "الحلقة", + "LabelEpisodeNotLinkedToRssFeed": "الحلقة غير مرتبطة بخلاصة RSS", + "LabelEpisodeNumber": "الحلقة #{0}", + "LabelEpisodeTitle": "عنوان الحلقة", + "LabelEpisodeType": "نوع الحلقة", + "LabelEpisodeUrlFromRssFeed": "رابط الحلقة من خلاصة RSS", + "LabelEpisodes": "حلقات", + "LabelEpisodic": "عرضي / حلقي", + "LabelExample": "مثال", + "LabelExpandSeries": "توسيع السلاسل", + "LabelExpandSubSeries": "توسيع السلاسل الفرعية", + "LabelExplicit": "صريح", + "LabelExplicitChecked": "صريح (محدد)", + "LabelExplicitUnchecked": "غير صريح (غير محدد)", + "LabelExportOPML": "تصدير OPML", "LabelFeedURL": "عنوان التغذية", + "LabelFetchingMetadata": "جلب البيانات الوصفية", "LabelFile": "الملف", "LabelFileBirthtime": "وقت انشاء الملف", + "LabelFileBornDate": "تاريخ الإنشاء {0}", "LabelFileModified": "تم تعديل الملف", + "LabelFileModifiedDate": "تم التعديل في {0}", "LabelFilename": "اسم الملف", + "LabelFilterByUser": "تصفية حسب المستخدم", + "LabelFindEpisodes": "البحث عن حلقات", "LabelFinished": "المنجزة", "LabelFolder": "المجلد", + "LabelFolders": "مجلدات", + "LabelFontBold": "عريض", "LabelFontBoldness": "تعريض الخط", + "LabelFontFamily": "عائلة الخط", + "LabelFontItalic": "مائل", "LabelFontScale": "نطاق الخط", + "LabelFontStrikethrough": "يتوسطه خط", + "LabelFormat": "تنسيق", + "LabelFull": "كامل", "LabelGenre": "التصنيف", "LabelGenres": "التصانيف", + "LabelHardDeleteFile": "حذف الملف نهائيًا", "LabelHasEbook": "يحتوي كتاب إلكتروني", "LabelHasSupplementaryEbook": "يحتوي كتاب إلكتروني تكميلي", + "LabelHideSubtitles": "إخفاء الترجمة", + "LabelHighestPriority": "الأولوية القصوى", "LabelHost": "المضيف", + "LabelHour": "ساعة", + "LabelHours": "ساعات", + "LabelIcon": "أيقونة", + "LabelImageURLFromTheWeb": "رابط الصورة من الويب", "LabelInProgress": "تحت التنفيذ", + "LabelIncludeInTracklist": "تضمين في قائمة المسارات", "LabelIncomplete": "غير مكتمل", + "LabelInterval": "فاصل زمني", + "LabelIntervalCustomDailyWeekly": "يومي/أسبوعي مخصص", + "LabelIntervalEvery12Hours": "كل 12 ساعة", + "LabelIntervalEvery15Minutes": "كل 15 دقيقة", + "LabelIntervalEvery2Hours": "كل ساعتين", + "LabelIntervalEvery30Minutes": "كل 30 دقيقة", + "LabelIntervalEvery6Hours": "كل 6 ساعات", + "LabelIntervalEveryDay": "كل يوم", + "LabelIntervalEveryHour": "كل ساعة", + "LabelIntervalEveryMinute": "كل دقيقة", + "LabelInvert": "عكس", + "LabelItem": "عنصر", + "LabelJumpBackwardAmount": "مقدار الرجوع للخلف", + "LabelJumpForwardAmount": "مقدار التقدم للأمام", "LabelLanguage": "اللغة", + "LabelLanguageDefaultServer": "لغة الخادم الافتراضية", + "LabelLanguages": "اللغات", + "LabelLastBookAdded": "آخر كتاب تمت إضافته", + "LabelLastBookUpdated": "آخر كتاب تم تحديثه", + "LabelLastSeen": "آخر ظهور", + "LabelLastTime": "آخر مرة", + "LabelLastUpdate": "آخر تحديث", "LabelLayout": "التنسيق", "LabelLayoutSinglePage": "صفحة واحدة", + "LabelLayoutSplitPage": "صفحتان متقابلتان", + "LabelLess": "أقل", + "LabelLibrariesAccessibleToUser": "المكتبات المتاحة للمستخدم", + "LabelLibrary": "مكتبة", + "LabelLibraryFilterSublistEmpty": "لا يوجد {0}", + "LabelLibraryItem": "عنصر المكتبة", + "LabelLibraryName": "اسم المكتبة", + "LabelLimit": "حد", "LabelLineSpacing": "تباعد الأسطر", "LabelListenAgain": "الاستماع مجدداً", + "LabelLogLevelDebug": "تصحيح الأخطاء", + "LabelLogLevelInfo": "معلومات", + "LabelLogLevelWarn": "تحذير", + "LabelLookForNewEpisodesAfterDate": "البحث عن حلقات جديدة بعد هذا التاريخ", + "LabelLowestPriority": "الأولوية الأدنى", + "LabelMatchExistingUsersBy": "مطابقة المستخدمين الحاليين بواسطة", + "LabelMatchExistingUsersByDescription": "يستخدم لربط المستخدمين الحاليين. بمجرد الاتصال، سيتم مطابقة المستخدمين بواسطة معرف فريد من مزود SSO الخاص بك", + "LabelMaxEpisodesToDownload": "الحد الأقصى لعدد الحلقات التي سيتم تنزيلها. استخدم 0 لغير محدود.", + "LabelMaxEpisodesToDownloadPerCheck": "الحد الأقصى لعدد الحلقات الجديدة التي سيتم تنزيلها في كل فحص", + "LabelMaxEpisodesToKeep": "الحد الأقصى لعدد الحلقات التي سيتم الاحتفاظ بها", + "LabelMaxEpisodesToKeepHelp": "القيمة 0 لا تضع حدًا أقصى. بعد تنزيل حلقة جديدة تلقائيًا، سيؤدي هذا إلى حذف أقدم حلقة إذا كان لديك أكثر من X حلقة. سيؤدي هذا إلى حذف حلقة واحدة فقط لكل تنزيل جديد.", + "LabelMediaPlayer": "مشغل الوسائط", "LabelMediaType": "نوع الوسائط", + "LabelMetaTag": "علامة بيانات وصفية", + "LabelMetaTags": "علامات البيانات الوصفية", + "LabelMetadataOrderOfPrecedenceDescription": "ستتجاوز مصادر البيانات الوصفية ذات الأولوية الأعلى مصادر البيانات الوصفية ذات الأولوية الأقل", + "LabelMetadataProvider": "مزود البيانات الوصفية", + "LabelMinute": "دقيقة", + "LabelMinutes": "دقائق", "LabelMissing": "مفقود", + "LabelMissingEbook": "لا يوجد كتاب إلكتروني", + "LabelMissingSupplementaryEbook": "لا يوجد كتاب إلكتروني تكميلي", + "LabelMobileRedirectURIs": "معرفات URI لإعادة التوجيه المسموح بها لتطبيقات الجوال", + "LabelMobileRedirectURIsDescription": "هذه قائمة بيضاء لمعرفات URI لإعادة التوجيه الصالحة لتطبيقات الجوال. المعرف الافتراضي هو audiobookshelf://oauth، والذي يمكنك إزالته أو استكماله بمعرفات URI إضافية لتكامل تطبيقات الطرف الثالث. استخدام علامة النجمة (*) كإدخال وحيد يسمح بأي معرف URI.", "LabelMore": "أكثر", "LabelMoreInfo": "معلومات أكثر", "LabelName": "الاسم", "LabelNarrator": "الراوي", "LabelNarrators": "الرواة", + "LabelNew": "جديد", + "LabelNewPassword": "كلمة سر جديدة", "LabelNewestAuthors": "أجدد المؤلفين", "LabelNewestEpisodes": "أجدد الحلقات", + "LabelNextBackupDate": "تاريخ النسخ الاحتياطي التالي", + "LabelNextScheduledRun": "التشغيل المجدول التالي", + "LabelNoCustomMetadataProviders": "لا يوجد مزودو بيانات وصفية مخصصون", + "LabelNoEpisodesSelected": "لم يتم تحديد أي حلقات", "LabelNotFinished": "لم يتم الانتهاء", "LabelNotStarted": "لم يتم البدء", + "LabelNotes": "ملاحظات", + "LabelNotificationAppriseURL": "رابط (روابط) Apprise", + "LabelNotificationAvailableVariables": "المتغيرات المتاحة", + "LabelNotificationBodyTemplate": "قالب النص", + "LabelNotificationEvent": "حدث الإشعار", + "LabelNotificationTitleTemplate": "قالب العنوان", + "LabelNotificationsMaxFailedAttempts": "الحد الأقصى لعدد المحاولات الفاشلة", + "LabelNotificationsMaxFailedAttemptsHelp": "يتم تعطيل الإشعارات بمجرد فشل إرسالها لهذا العدد من المرات", + "LabelNotificationsMaxQueueSize": "الحد الأقصى لحجم قائمة انتظار أحداث الإشعارات", + "LabelNotificationsMaxQueueSizeHelp": "تقتصر الأحداث على التشغيل مرة واحدة في الثانية. سيتم تجاهل الأحداث إذا كانت قائمة الانتظار في الحد الأقصى لحجمها. هذا يمنع إرسال الإشعارات بشكل متكرر.", + "LabelNumberOfBooks": "عدد الكتب", "LabelNumberOfEpisodes": "# من الحلقات", + "LabelOpenIDAdvancedPermsClaimDescription": "اسم مطالبة OpenID التي تحتوي على أذونات متقدمة لإجراءات المستخدم داخل التطبيق والتي ستطبق على الأدوار غير الإدارية (إذا تم تكوينها). إذا كانت المطالبة مفقودة من الاستجابة، فسيتم رفض الوصول إلى ABS. إذا كان هناك خيار واحد مفقودًا، فسيتم التعامل معه على أنه false. تأكد من أن مطالبة موفر الهوية تطابق البنية المتوقعة:", + "LabelOpenIDClaims": "اترك الخيارات التالية فارغة لتعطيل تعيين المجموعة والأذونات المتقدمة، وسيتم تعيين مجموعة \"مستخدم\" تلقائيًا بعد ذلك.", + "LabelOpenIDGroupClaimDescription": "اسم مطالبة OpenID التي تحتوي على قائمة بمجموعات المستخدم. يشار إليها عادةً باسم groups.إذا تم تكوينها، فسيقوم التطبيق تلقائيًا بتعيين الأدوار بناءً على عضويات مجموعة المستخدم، بشرط أن تسمى هذه المجموعات بشكل غير حساس لحالة الأحرف \"admin\" أو \"user\" أو \"guest\" في المطالبة. يجب أن تحتوي المطالبة على قائمة، وإذا كان المستخدم ينتمي إلى مجموعات متعددة، فسيقوم التطبيق بتعيين الدور المقابل لأعلى مستوى من الوصول. إذا لم تتطابق أي مجموعة، فسيتم رفض الوصول.", + "LabelOpenRSSFeed": "فتح تغذية RSS", + "LabelOverwrite": "استبدال", + "LabelPaginationPageXOfY": "صفحة {0} من {1}", "LabelPassword": "كلمة المرور", "LabelPath": "مسار", + "LabelPermanent": "دائم", + "LabelPermissionsAccessAllLibraries": "يمكنه الوصول إلى جميع المكتبات", + "LabelPermissionsAccessAllTags": "يمكنه الوصول إلى جميع العلامات", + "LabelPermissionsAccessExplicitContent": "يمكنه الوصول إلى المحتوى الصريح", + "LabelPermissionsCreateEreader": "يمكنه إنشاء قارئ إلكتروني", + "LabelPermissionsDelete": "يمكنه الحذف", + "LabelPermissionsDownload": "يمكنه التنزيل", + "LabelPermissionsUpdate": "يمكنه التحديث", + "LabelPermissionsUpload": "يمكنه الرفع", + "LabelPersonalYearReview": "ملخص عامك ({0})", + "LabelPhotoPathURL": "مسار/رابط الصورة", + "LabelPlayMethod": "طريقة التشغيل", + "LabelPlaybackRateIncrementDecrement": "مقدار زيادة/نقصان سرعة التشغيل", + "LabelPlayerChapterNumberMarker": "{0} من {1}", + "LabelPlaylists": "قوائم التشغيل", "LabelPodcast": "مدونة صوتية", - "LabelPodcasts": "مدونات صوتية", + "LabelPodcastSearchRegion": "منطقة البحث عن البودكاست", + "LabelPodcastType": "نوع البودكاست", + "LabelPodcasts": "بودكاست", + "LabelPort": "منفذ", + "LabelPrefixesToIgnore": "البادئات التي يجب تجاهلها (غير حساسة لحالة الأحرف)", "LabelPreventIndexing": "منع فهرسة تغذيتك بواسطة دليل آيتونز وقوقل بودكاست", + "LabelPrimaryEbook": "الكتاب الإلكتروني الأساسي", "LabelProgress": "تقدم", + "LabelProvider": "مزود", + "LabelProviderAuthorizationValue": "قيمة رأس التفويض", "LabelPubDate": "تاريخ النشر", "LabelPublishYear": "سنة النشر", "LabelPublishedDate": "منشور {0}", + "LabelPublishedDecade": "عقد النشر", + "LabelPublishedDecades": "عقود النشر", + "LabelPublisher": "الناشر", + "LabelPublishers": "الناشرون", "LabelRSSFeedCustomOwnerEmail": "البريد الالكتروني المخصص للمالك", "LabelRSSFeedCustomOwnerName": "الاسم المخصص للمالك", + "LabelRSSFeedOpen": "فتح تغذية RSS", "LabelRSSFeedPreventIndexing": "منع الفهرسة", - "LabelRSSFeedSlug": "رابط تغذية RSS", + "LabelRSSFeedSlug": "اسم تعريف تغذية RSS", + "LabelRSSFeedURL": "رابط تغذية RSS", "LabelRandomly": "عشوائياً", + "LabelReAddSeriesToContinueListening": "إعادة إضافة السلسلة إلى \"متابعة الاستماع\"", "LabelRead": "اقرأ", "LabelReadAgain": "اقرأ مرة أخرى", + "LabelReadEbookWithoutProgress": "قراءة الكتاب الإلكتروني دون حفظ التقدم", "LabelRecentSeries": "المسلسلات الحديثة", "LabelRecentlyAdded": "المضافة حديثاً", + "LabelRecommended": "موصى به", + "LabelRedo": "إعادة", + "LabelRegion": "المنطقة", + "LabelReleaseDate": "تاريخ الإصدار", + "LabelRemoveAllMetadataAbs": "إزالة جميع ملفات metadata.abs", + "LabelRemoveAllMetadataJson": "إزالة جميع ملفات metadata.json", + "LabelRemoveAudibleBranding": "إزالة مقدمة وخاتمة Audible من الفصول", + "LabelRemoveCover": "إزالة الغلاف", + "LabelRemoveMetadataFile": "إزالة ملفات البيانات الوصفية في مجلدات عناصر المكتبة", + "LabelRemoveMetadataFileHelp": "إزالة جميع ملفات metadata.json و metadata.abs في مجلدات {0} الخاصة بك.", + "LabelRowsPerPage": "عدد الصفوف في الصفحة", + "LabelSearchTerm": "مصطلح البحث", + "LabelSearchTitle": "بحث بالعنوان", + "LabelSearchTitleOrASIN": "بحث بالعنوان أو ASIN", "LabelSeason": "الموسم", + "LabelSeasonNumber": "الموسم #{0}", + "LabelSelectAll": "تحديد الكل", + "LabelSelectAllEpisodes": "تحديد جميع الحلقات", + "LabelSelectEpisodesShowing": "تحديد {0} حلقة معروضة", + "LabelSelectUsers": "تحديد المستخدمين", + "LabelSendEbookToDevice": "إرسال الكتاب الإلكتروني إلى...", + "LabelSequence": "تسلسل", + "LabelSerial": "مسلسل", "LabelSeries": "المسلسلات", + "LabelSeriesName": "اسم السلسلة", + "LabelSeriesProgress": "تقدم السلسلة", + "LabelServerLogLevel": "مستوى سجل الخادم", + "LabelServerYearReview": "ملخص عام الخادم ({0})", "LabelSetEbookAsPrimary": "تعيين كرئيسي", "LabelSetEbookAsSupplementary": "تعيين كتكميلي", - "LabelShowAll": "اظهار الكل", + "LabelSettingsAllowIframe": "السماح بالتضمين في إطار iframe", + "LabelSettingsAudiobooksOnly": "كتب صوتية فقط", + "LabelSettingsAudiobooksOnlyHelp": "سيؤدي تمكين هذا الإعداد إلى تجاهل ملفات الكتب الإلكترونية ما لم تكن داخل مجلد كتاب صوتي، وفي هذه الحالة سيتم تعيينها ككتب إلكترونية تكميلية", + "LabelSettingsBookshelfViewHelp": "تصميم يحاكي الواقع مع رفوف خشبية", + "LabelSettingsChromecastSupport": "دعم Chromecast", + "LabelSettingsDateFormat": "تنسيق التاريخ", + "LabelSettingsEnableWatcher": "فحص المكتبات تلقائيًا بحثًا عن تغييرات", + "LabelSettingsEnableWatcherForLibrary": "فحص المكتبة تلقائيًا بحثًا عن تغييرات", + "LabelSettingsEnableWatcherHelp": "يمكّن الإضافة/التحديث التلقائي للعناصر عند اكتشاف تغييرات في الملفات. *يتطلب إعادة تشغيل الخادم", + "LabelSettingsEpubsAllowScriptedContent": "السماح بالمحتوى النصي في ملفات epub", + "LabelSettingsEpubsAllowScriptedContentHelp": "السماح لملفات epub بتنفيذ النصوص البرمجية. يوصى بإبقاء هذا الإعداد معطلاً ما لم تثق في مصدر ملفات epub.", + "LabelSettingsExperimentalFeatures": "ميزات تجريبية", + "LabelSettingsExperimentalFeaturesHelp": "ميزات قيد التطوير يمكنها استخدام ملاحظاتك والمساعدة في اختبارها. انقر لفتح مناقشة على GitHub.", + "LabelSettingsFindCovers": "البحث عن الأغلفة", + "LabelSettingsFindCoversHelp": "إذا لم يكن لدى كتابك الصوتي غلاف مضمن أو صورة غلاف داخل المجلد، فسيحاول الماسح الضوئي العثور على غلاف.<br> ملاحظة: سيؤدي هذا إلى إطالة وقت الفحص", + "LabelSettingsHideSingleBookSeries": "إخفاء السلاسل ذات الكتاب الواحد", + "LabelSettingsHideSingleBookSeriesHelp": "سيتم إخفاء السلاسل التي تحتوي على كتاب واحد من صفحة السلاسل وأرفف الصفحة الرئيسية.", + "LabelSettingsHomePageBookshelfView": "استخدام عرض الرفوف في الصفحة الرئيسية", + "LabelSettingsLibraryBookshelfView": "استخدام عرض الرفوف في المكتبة", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "النسبة المئوية المكتملة أكبر من", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "الوقت المتبقي أقل من (ثواني)", + "LabelSettingsLibraryMarkAsFinishedWhen": "تعليم عنصر الوسائط على أنه منتهٍ عند", + "LabelSettingsOnlyShowLaterBooksInContinueSeries": "تخطي الكتب السابقة في \"متابعة السلسلة\"", + "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "يعرض رف \"متابعة السلسلة\" في الصفحة الرئيسية أول كتاب لم يبدأ في السلاسل التي تحتوي على كتاب واحد على الأقل منتهي ولا يوجد كتب قيد التقدم. سيؤدي تمكين هذا الإعداد إلى متابعة السلاسل من أبعد كتاب مكتمل بدلاً من أول كتاب لم يبدأ.", + "LabelSettingsParseSubtitles": "تحليل الترجمة", + "LabelSettingsParseSubtitlesHelp": "استخراج الترجمة من أسماء مجلدات الكتب الصوتية.<br>يجب فصل الترجمة بـ \" - \"<br>مثال: \"عنوان الكتاب - ترجمة هنا\" تحتوي على الترجمة \"ترجمة هنا\"", + "LabelSettingsPreferMatchedMetadata": "تفضيل البيانات الوصفية المطابقة", + "LabelSettingsPreferMatchedMetadataHelp": "ستتجاوز البيانات المطابقة تفاصيل العنصر عند استخدام المطابقة السريعة. بشكل افتراضي، ستملأ المطابقة السريعة التفاصيل المفقودة فقط.", + "LabelSettingsSkipMatchingBooksWithASIN": "تخطي مطابقة الكتب التي لديها ASIN بالفعل", + "LabelSettingsSkipMatchingBooksWithISBN": "تخطي مطابقة الكتب التي لديها ISBN بالفعل", + "LabelSettingsSortingIgnorePrefixes": "تجاهل البادئات عند الفرز", + "LabelSettingsSortingIgnorePrefixesHelp": "مثال: بالنسبة للبادئة \"the\"، سيتم فرز عنوان الكتاب \"The Book Title\" كـ \"Book Title, The\"", + "LabelSettingsSquareBookCovers": "استخدام أغلفة كتب مربعة", + "LabelSettingsSquareBookCoversHelp": "تفضيل استخدام الأغلفة المربعة على أغلفة الكتب القياسية بنسبة 1.6:1", + "LabelSettingsStoreCoversWithItem": "تخزين الأغلفة مع العنصر", + "LabelSettingsStoreCoversWithItemHelp": "بشكل افتراضي، يتم تخزين الأغلفة في /metadata/items، وسيؤدي تمكين هذا الإعداد إلى تخزين الأغلفة في مجلد عنصر المكتبة الخاص بك. سيتم الاحتفاظ بملف واحد فقط باسم \"cover\"", + "LabelSettingsStoreMetadataWithItem": "تخزين البيانات الوصفية مع العنصر", + "LabelSettingsStoreMetadataWithItemHelp": "بشكل افتراضي، يتم تخزين ملفات البيانات الوصفية في /metadata/items، وسيؤدي تمكين هذا الإعداد إلى تخزين ملفات البيانات الوصفية في مجلدات عناصر المكتبة الخاصة بك", + "LabelSettingsTimeFormat": "تنسيق الوقت", + "LabelShare": "مشاركة", + "LabelShareDownloadableHelp": "يسمح للمستخدمين الذين لديهم رابط المشاركة بتنزيل ملف مضغوط لعنصر المكتبة.", + "LabelShareOpen": "فتح المشاركة", + "LabelShareURL": "رابط المشاركة", + "LabelShowAll": "إظهار الكل", + "LabelShowSeconds": "إظهار الثواني", + "LabelShowSubtitles": "إظهار الترجمة", "LabelSize": "الحجم", "LabelSleepTimer": "مؤقت النوم", + "LabelSlug": "اسم تعريفي سهل القراءة", + "LabelSortAscending": "تصاعدي", + "LabelSortDescending": "تنازلي", + "LabelSortPubDate": "فرز حسب تاريخ النشر", "LabelStart": "ابدأ", + "LabelStartTime": "وقت البدء", + "LabelStarted": "بدأ", + "LabelStartedAt": "بدأ في", + "LabelStatsAudioTracks": "مسارات الصوت", + "LabelStatsAuthors": "المؤلفون", "LabelStatsBestDay": "أفضل يوم", "LabelStatsDailyAverage": "المتوسط اليومي", "LabelStatsDays": "أيام", "LabelStatsDaysListened": "أيام الاستماع", + "LabelStatsHours": "ساعات", "LabelStatsInARow": "على التوالي", "LabelStatsItemsFinished": "العناصر المنتهية", + "LabelStatsItemsInLibrary": "العناصر في المكتبة", "LabelStatsMinutes": "دقائق", "LabelStatsMinutesListening": "دقائق الاستماع", + "LabelStatsOverallDays": "إجمالي الأيام", + "LabelStatsOverallHours": "إجمالي الساعات", "LabelStatsWeekListening": "استماع هذا الأسبوع", + "LabelSubtitle": "عنوان فرعي / ترجمة", + "LabelSupportedFileTypes": "أنواع الملفات المدعومة", "LabelTag": "علامة", "LabelTags": "علامات", + "LabelTagsAccessibleToUser": "العلامات المتاحة للمستخدم", + "LabelTagsNotAccessibleToUser": "العلامات غير المتاحة للمستخدم", + "LabelTasks": "المهام قيد التشغيل", + "LabelTextEditorBulletedList": "قائمة نقطية", + "LabelTextEditorLink": "رابط", + "LabelTextEditorNumberedList": "قائمة مرقمة", + "LabelTextEditorUnlink": "إزالة الرابط", "LabelTheme": "النمط", "LabelThemeDark": "غامق", "LabelThemeLight": "فاتح", + "LabelTimeBase": "قاعدة الوقت", + "LabelTimeDurationXHours": "{0} ساعات", + "LabelTimeDurationXMinutes": "{0} دقائق", + "LabelTimeDurationXSeconds": "{0} ثواني", + "LabelTimeInMinutes": "الوقت بالدقائق", + "LabelTimeLeft": "باقي {0}", + "LabelTimeListened": "الوقت المستمع إليه", + "LabelTimeListenedToday": "الوقت المستمع إليه اليوم", "LabelTimeRemaining": "{0} متبقية", + "LabelTimeToShift": "الوقت المراد إزاحته بالثواني", "LabelTitle": "عنوان", + "LabelToolsEmbedMetadata": "تضمين البيانات الوصفية", + "LabelToolsEmbedMetadataDescription": "تضمين البيانات الوصفية في ملفات الصوت بما في ذلك صورة الغلاف والفصول.", + "LabelToolsM4bEncoder": "ترميز M4B", + "LabelToolsMakeM4b": "إنشاء ملف كتاب صوتي M4B", + "LabelToolsMakeM4bDescription": "إنشاء ملف كتاب صوتي ‎.M4B مع بيانات وصفية مضمنة وصورة غلاف وفصول.", + "LabelToolsSplitM4b": "تقسيم M4B إلى ملفات MP3", + "LabelToolsSplitM4bDescription": "إنشاء ملفات MP3 من ملف M4B مقسم حسب الفصول مع بيانات وصفية مضمنة وصورة غلاف وفصول.", + "LabelTotalDuration": "المدة الكلية", + "LabelTotalTimeListened": "إجمالي وقت الاستماع", + "LabelTrackFromFilename": "المسار من اسم الملف", + "LabelTrackFromMetadata": "المسار من البيانات الوصفية", "LabelTracks": "المسارات", + "LabelTracksMultiTrack": "متعدد المسارات", + "LabelTracksNone": "لا توجد مسارات", + "LabelTracksSingleTrack": "مسار واحد", + "LabelTrailer": "مقطع دعائي", "LabelType": "نوع", + "LabelUnabridged": "غير مختصر", + "LabelUndo": "تراجع", "LabelUnknown": "مجهول", + "LabelUnknownPublishDate": "تاريخ النشر مجهول", + "LabelUpdateCover": "تحديث الغلاف", + "LabelUpdateCoverHelp": "السماح باستبدال الأغلفة الموجودة للكتب المحددة عند العثور على تطابق", + "LabelUpdateDetails": "تحديث التفاصيل", + "LabelUpdateDetailsHelp": "السماح باستبدال التفاصيل الموجودة للكتب المحددة عند العثور على تطابق", + "LabelUpdatedAt": "تاريخ التحديث", + "LabelUploaderDragAndDrop": "اسحب وأفلت الملفات أو المجلدات", + "LabelUploaderDragAndDropFilesOnly": "اسحب وأفلت الملفات", + "LabelUploaderDropFiles": "إفلات الملفات", + "LabelUploaderItemFetchMetadataHelp": "جلب العنوان والمؤلف والسلسلة تلقائيًا", + "LabelUseAdvancedOptions": "استخدام الخيارات المتقدمة", + "LabelUseChapterTrack": "استخدام مسار الفصل", + "LabelUseFullTrack": "استخدام المسار الكامل", + "LabelUseZeroForUnlimited": "استخدم 0 لغير محدود", "LabelUser": "مستخدم", "LabelUsername": "اسم المستخدم", + "LabelValue": "القيمة", + "LabelVersion": "الإصدار", + "LabelViewBookmarks": "عرض الإشارات المرجعية", + "LabelViewChapters": "عرض الفصول", + "LabelViewPlayerSettings": "عرض إعدادات المشغل", + "LabelViewQueue": "عرض قائمة انتظار المشغل", + "LabelVolume": "مستوى الصوت", + "LabelWebRedirectURLsDescription": "قم بتخويل عناوين URL هذه في موفر OAuth الخاص بك للسماح بإعادة التوجيه إلى تطبيق الويب بعد تسجيل الدخول:", + "LabelWebRedirectURLsSubfolder": "مجلد فرعي لعناوين URL لإعادة التوجيه", + "LabelWeekdaysToRun": "أيام الأسبوع المراد التشغيل فيها", + "LabelXBooks": "{0} كتب", + "LabelXItems": "{0} عناصر", "LabelYearReviewHide": "إخفاء ملخص العام", "LabelYearReviewShow": "عرض ملخص العام", + "LabelYourAudiobookDuration": "مدة كتابك الصوتي", "LabelYourBookmarks": "علاماتك المرجعية", + "LabelYourPlaylists": "قوائم التشغيل الخاصة بك", "LabelYourProgress": "تقدمك", + "MessageAddToPlayerQueue": "إضافة إلى قائمة انتظار المشغل", + "MessageAppriseDescription": "لاستخدام هذه الميزة، ستحتاج إلى تشغيل مثيل Apprise API أو واجهة برمجة تطبيقات تتعامل مع نفس الطلبات.
يجب أن يكون عنوان URL الخاص بـ Apprise API هو مسار URL الكامل لإرسال الإشعار، على سبيل المثال، إذا كان مثيل واجهة برمجة التطبيقات الخاصة بك يعمل على http://192.168.1.1:8337، فستضع http://192.168.1.1:8337/notify.", + "MessageAsinCheck": "تأكد من أنك تستخدم ASIN من منطقة Audible الصحيحة، وليس Amazon.", + "MessageAuthenticationOIDCChangesRestart": "أعد تشغيل الخادم بعد الحفظ لتطبيق تغييرات OIDC.", + "MessageBackupsDescription": "تتضمن النسخ الاحتياطية المستخدمين وتقدم المستخدم وتفاصيل عنصر المكتبة وإعدادات الخادم والصور المخزنة في /metadata/items و /metadata/authors. لا تتضمن النسخ الاحتياطية أي ملفات مخزنة في مجلدات مكتبتك.", + "MessageBackupsLocationEditNote": "ملاحظة: لن يؤدي تحديث موقع النسخ الاحتياطي إلى نقل أو تعديل النسخ الاحتياطية الموجودة", + "MessageBackupsLocationNoEditNote": "ملاحظة: يتم تعيين موقع النسخ الاحتياطي من خلال متغير بيئة ولا يمكن تغييره هنا.", + "MessageBackupsLocationPathEmpty": "لا يمكن أن يكون مسار موقع النسخ الاحتياطي فارغًا", + "MessageBatchEditPopulateMapDetailsAllHelp": "املأ الحقول الممكّنة ببيانات من جميع العناصر. سيتم دمج الحقول ذات القيم المتعددة", + "MessageBatchEditPopulateMapDetailsItemHelp": "املأ حقول تفاصيل الخريطة الممكّنة ببيانات من هذا العنصر", + "MessageBatchQuickMatchDescription": "ستحاول المطابقة السريعة إضافة الأغلفة والبيانات الوصفية المفقودة للعناصر المحددة. قم بتمكين الخيارات أدناه للسماح للمطابقة السريعة بالكتابة فوق الأغلفة و/أو البيانات الوصفية الموجودة.", + "MessageBookshelfNoCollections": "لم تنشئ أي مجموعات حتى الآن", + "MessageBookshelfNoCollectionsHelp": "المجموعات عامة. يمكن لجميع المستخدمين الذين لديهم حق الوصول إلى المكتبة رؤيتها.", + "MessageBookshelfNoRSSFeeds": "لا توجد خلاصات RSS مفتوحة", + "MessageBookshelfNoResultsForFilter": "لا توجد نتائج للفلتر \"{0}: {1}\"", + "MessageBookshelfNoResultsForQuery": "لا توجد نتائج للاستعلام", + "MessageBookshelfNoSeries": "ليس لديك أي مسلسلات", + "MessageChapterEndIsAfter": "نهاية الفصل بعد نهاية كتابك الصوتي", + "MessageChapterErrorFirstNotZero": "يجب أن يبدأ الفصل الأول عند 0", + "MessageChapterErrorStartGteDuration": "يجب أن يكون وقت البدء غير الصالح أقل من مدة الكتاب الصوتي", + "MessageChapterErrorStartLtPrev": "يجب أن يكون وقت البدء غير الصالح أكبر من أو يساوي وقت بدء الفصل السابق", + "MessageChapterStartIsAfter": "بداية الفصل بعد نهاية كتابك الصوتي", + "MessageChaptersNotFound": "لم يتم العثور على فصول", + "MessageCheckingCron": "جارٍ فحص cron...", + "MessageConfirmCloseFeed": "هل أنت متأكد أنك تريد إغلاق هذه التغذية؟", + "MessageConfirmDeleteBackup": "هل أنت متأكد أنك تريد حذف النسخ الاحتياطي لـ {0}؟", + "MessageConfirmDeleteDevice": "هل أنت متأكد أنك تريد حذف جهاز القارئ الإلكتروني \"{0}\"؟", + "MessageConfirmDeleteFile": "سيؤدي هذا إلى حذف الملف من نظام الملفات الخاص بك. هل أنت متأكد؟", + "MessageConfirmDeleteLibrary": "هل أنت متأكد أنك تريد حذف المكتبة \"{0}\" نهائيًا؟", + "MessageConfirmDeleteLibraryItem": "سيؤدي هذا إلى حذف عنصر المكتبة من قاعدة البيانات ونظام الملفات الخاص بك. هل أنت متأكد؟", + "MessageConfirmDeleteLibraryItems": "سيؤدي هذا إلى حذف {0} عنصرًا من عناصر المكتبة من قاعدة البيانات ونظام الملفات الخاص بك. هل أنت متأكد؟", + "MessageConfirmDeleteMetadataProvider": "هل أنت متأكد أنك تريد حذف مزود البيانات الوصفية المخصص \"{0}\"؟", + "MessageConfirmDeleteNotification": "هل أنت متأكد أنك تريد حذف هذا الإشعار؟", + "MessageConfirmDeleteSession": "هل أنت متأكد أنك تريد حذف هذه الجلسة؟", + "MessageConfirmEmbedMetadataInAudioFiles": "هل أنت متأكد أنك تريد تضمين البيانات الوصفية في {0} ملفًا صوتيًا؟", + "MessageConfirmForceReScan": "هل أنت متأكد أنك تريد فرض إعادة الفحص؟", + "MessageConfirmMarkAllEpisodesFinished": "هل أنت متأكد أنك تريد تعليم جميع الحلقات على أنها منتهية؟", + "MessageConfirmMarkAllEpisodesNotFinished": "هل أنت متأكد أنك تريد تعليم جميع الحلقات على أنها غير منتهية؟", + "MessageConfirmMarkItemFinished": "هل أنت متأكد أنك تريد تعليم \"{0}\" على أنه منتهٍ؟", + "MessageConfirmMarkItemNotFinished": "هل أنت متأكد أنك تريد تعليم \"{0}\" على أنه غير منتهٍ؟", + "MessageConfirmMarkSeriesFinished": "هل أنت متأكد أنك تريد تعليم جميع الكتب في هذه السلسلة على أنها منتهية؟", + "MessageConfirmMarkSeriesNotFinished": "هل أنت متأكد أنك تريد تعليم جميع الكتب في هذه السلسلة على أنها غير منتهية؟", + "MessageConfirmNotificationTestTrigger": "هل تريد تشغيل هذا الإشعار ببيانات اختبار؟", + "MessageConfirmPurgeCache": "سيؤدي مسح ذاكرة التخزين المؤقت إلى حذف الدليل بأكمله في /metadata/cache.

هل أنت متأكد أنك تريد إزالة دليل ذاكرة التخزين المؤقت؟", + "MessageConfirmPurgeItemsCache": "سيؤدي مسح ذاكرة التخزين المؤقت للعناصر إلى حذف الدليل بأكمله في /metadata/cache/items
هل أنت متأكد؟", + "MessageConfirmQuickEmbed": "تحذير! لن يقوم التضمين السريع بنسخ ملفاتك الصوتية احتياطيًا. تأكد من أن لديك نسخة احتياطية من ملفاتك الصوتية.

هل ترغب في المتابعة؟", + "MessageConfirmQuickMatchEpisodes": "ستؤدي المطابقة السريعة للحلقات إلى الكتابة فوق التفاصيل إذا تم العثور على تطابق. سيتم تحديث الحلقات غير المتطابقة فقط. هل أنت متأكد؟", + "MessageConfirmReScanLibraryItems": "هل أنت متأكد أنك تريد إعادة فحص {0} عنصرًا؟", + "MessageConfirmRemoveAllChapters": "هل أنت متأكد أنك تريد إزالة جميع الفصول؟", + "MessageConfirmRemoveAuthor": "هل أنت متأكد أنك تريد إزالة المؤلف \"{0}\"؟", + "MessageConfirmRemoveCollection": "هل أنت متأكد أنك تريد إزالة المجموعة \"{0}\"؟", + "MessageConfirmRemoveEpisode": "هل أنت متأكد أنك تريد إزالة الحلقة \"{0}\"؟", + "MessageConfirmRemoveEpisodes": "هل أنت متأكد أنك تريد إزالة {0} حلقة؟", + "MessageConfirmRemoveListeningSessions": "هل أنت متأكد أنك تريد إزالة {0} جلسة استماع؟", + "MessageConfirmRemoveMetadataFiles": "هل أنت متأكد أنك تريد إزالة جميع ملفات البيانات الوصفية {0} الموجودة في مجلدات عناصر مكتبتك؟", + "MessageConfirmRemoveNarrator": "هل أنت متأكد أنك تريد إزالة الراوي \"{0}\"؟", + "MessageConfirmRemovePlaylist": "هل أنت متأكد أنك تريد إزالة قائمة التشغيل الخاصة بك \"{0}\"؟", + "MessageConfirmRenameGenre": "هل أنت متأكد أنك تريد إعادة تسمية النوع \"{0}\" إلى \"{1}\" لجميع العناصر؟", + "MessageConfirmRenameGenreMergeNote": "ملاحظة: هذا النوع موجود بالفعل لذا سيتم دمجهما.", + "MessageConfirmRenameGenreWarning": "تحذير! يوجد نوع مشابه بحالة أحرف مختلفة بالفعل \"{0}\".", + "MessageConfirmRenameTag": "هل أنت متأكد أنك تريد إعادة تسمية العلامة \"{0}\" إلى \"{1}\" لجميع العناصر؟", + "MessageConfirmRenameTagMergeNote": "ملاحظة: هذه العلامة موجودة بالفعل لذا سيتم دمجهما.", + "MessageConfirmRenameTagWarning": "تحذير! توجد علامة مشابهة بحالة أحرف مختلفة بالفعل \"{0}\".", + "MessageConfirmResetProgress": "هل أنت متأكد أنك تريد إعادة تعيين تقدمك؟", + "MessageConfirmSendEbookToDevice": "هل أنت متأكد أنك تريد إرسال الكتاب الإلكتروني \"{1}\" ({0}) إلى الجهاز \"{2}\"؟", + "MessageConfirmUnlinkOpenId": "هل أنت متأكد أنك تريد فصل هذا المستخدم عن OpenID؟", + "MessageDaysListenedInTheLastYear": "تم الاستماع لمدة {0} يومًا في العام الماضي", "MessageDownloadingEpisode": "جاري تنزيل الحلقة", + "MessageDragFilesIntoTrackOrder": "اسحب الملفات إلى ترتيب المسارات الصحيح", + "MessageEmbedFailed": "فشل التضمين!", + "MessageEmbedFinished": "تم الانتهاء من التضمين!", + "MessageEmbedQueue": "تمت إضافته إلى قائمة انتظار تضمين البيانات الوصفية ({0} في قائمة الانتظار)", "MessageEpisodesQueuedForDownload": "تمت إضافة {0} حلقة (حلقات) إلى قائمة انتظار التنزيل", + "MessageEreaderDevices": "لضمان تسليم الكتب الإلكترونية، قد تحتاج إلى إضافة عنوان البريد الإلكتروني أعلاه كمرسل صالح لكل جهاز مدرج أدناه.", "MessageFeedURLWillBe": "سيكون رابط التغذية هو {0}", "MessageFetching": "جاري الجلب...", + "MessageForceReScanDescription": "سيقوم بفحص جميع الملفات مرة أخرى كفحص جديد. سيتم فحص علامات ID3 لملفات الصوت وملفات OPF والملفات النصية كأنها جديدة.", + "MessageImportantNotice": "إشعار هام!", + "MessageInsertChapterBelow": "إدراج فصل أدناه", + "MessageInvalidAsin": "ASIN غير صالح", + "MessageItemsSelected": "تم تحديد {0} عنصرًا", + "MessageItemsUpdated": "تم تحديث {0} عنصرًا", + "MessageJoinUsOn": "انضم إلينا على", "MessageLoading": "جاري التحميل...", + "MessageLoadingFolders": "جاري تحميل المجلدات...", + "MessageLogsDescription": "يتم تخزين السجلات في /metadata/logs كملفات JSON. يتم تخزين سجلات الأعطال في /metadata/logs/crash_logs.txt.", + "MessageM4BFailed": "فشل M4B!", + "MessageM4BFinished": "تم الانتهاء من M4B!", + "MessageMapChapterTitles": "ربط عناوين الفصول بفصول كتابك الصوتي الحالي دون تعديل الطوابع الزمنية", + "MessageMarkAllEpisodesFinished": "تعليم جميع الحلقات على أنها منتهية", + "MessageMarkAllEpisodesNotFinished": "تعليم جميع الحلقات على أنها غير منتهية", "MessageMarkAsFinished": "وضع علامة \"تم الإنتهاء\"", + "MessageMarkAsNotFinished": "وضع علامة \"غير منته\"", + "MessageMatchBooksDescription": "سيحاول مطابقة الكتب في المكتبة مع كتاب من مزود البحث المحدد وملء التفاصيل الفارغة وصورة الغلاف. لا يستبدل التفاصيل الموجودة.", + "MessageNoAudioTracks": "لا توجد مسارات صوتية", + "MessageNoAuthors": "لا يوجد مؤلفون", + "MessageNoBackups": "لا توجد نسخ احتياطية", "MessageNoBookmarks": "لا توجد علامات مرجعية", "MessageNoChapters": "لا توجد فصول", "MessageNoCollections": "لا توجد مجموعات", + "MessageNoCoversFound": "لم يتم العثور على أغلفة", + "MessageNoDescription": "لا يوجد وصف", + "MessageNoDevices": "لا توجد أجهزة", + "MessageNoDownloadsInProgress": "لا توجد تنزيلات قيد التقدم حاليًا", + "MessageNoDownloadsQueued": "لا توجد تنزيلات في قائمة الانتظار", + "MessageNoEpisodeMatchesFound": "لم يتم العثور على أي تطابقات للحلقات", + "MessageNoEpisodes": "لا توجد حلقات", + "MessageNoFoldersAvailable": "لا توجد مجلدات متاحة", + "MessageNoGenres": "لا توجد تصانيف", + "MessageNoIssues": "لا توجد مشاكل", "MessageNoItems": "لا توجد عناصر", "MessageNoItemsFound": "لم يتم العثور على عناصر", "MessageNoListeningSessions": "لا توجد جلسات استماع", + "MessageNoLogs": "لا توجد سجلات", + "MessageNoMediaProgress": "لا يوجد تقدم للوسائط", + "MessageNoNotifications": "لا توجد إشعارات", + "MessageNoPodcastFeed": "بودكاست غير صالح: لا يوجد تغذية", "MessageNoPodcastsFound": "لم يتم العثور على أي بودكاست", + "MessageNoResults": "لا توجد نتائج", + "MessageNoSearchResultsFor": "لا توجد نتائج بحث عن \"{0}\"", + "MessageNoSeries": "لا توجد مسلسلات", + "MessageNoTags": "لا توجد علامات", + "MessageNoTasksRunning": "لا توجد مهام قيد التشغيل", "MessageNoUpdatesWereNecessary": "لا حاجة لأي تحديثات", "MessageNoUserPlaylists": "ليست لديك أي قوائم تشغيل", + "MessageNoUserPlaylistsHelp": "قوائم التشغيل خاصة. لا يمكن إلا للمستخدم الذي ينشئها رؤيتها.", + "MessageNotYetImplemented": "لم يتم تنفيذه بعد", + "MessageOpmlPreviewNote": "ملاحظة: هذه معاينة لملف OPML الذي تم تحليله. سيتم أخذ عنوان البودكاست الفعلي من خلاصة RSS.", + "MessageOr": "أو", + "MessagePauseChapter": "إيقاف تشغيل الفصل مؤقتًا", + "MessagePlayChapter": "الاستماع إلى بداية الفصل", + "MessagePlaylistCreateFromCollection": "إنشاء قائمة تشغيل من المجموعة", + "MessagePleaseWait": "الرجاء الانتظار...", + "MessagePodcastHasNoRSSFeedForMatching": "لا يحتوي البودكاست على عنوان URL لخلاصة RSS لاستخدامه في المطابقة", "MessagePodcastSearchField": "أدخل مصطلح البحث أو عنوان URL لخلاصة RSS", + "MessageQuickEmbedInProgress": "التضمين السريع قيد التقدم", + "MessageQuickEmbedQueue": "تمت إضافته إلى قائمة انتظار التضمين السريع ({0} في قائمة الانتظار)", + "MessageQuickMatchAllEpisodes": "مطابقة سريعة لجميع الحلقات", + "MessageQuickMatchDescription": "املأ تفاصيل العنصر الفارغة والغلاف بأول نتيجة مطابقة من '{0}'. لا يستبدل التفاصيل إلا إذا تم تمكين إعداد الخادم 'تفضيل البيانات الوصفية المطابقة'.", + "MessageRemoveChapter": "إزالة الفصل", + "MessageRemoveEpisodes": "إزالة {0} حلقة (حلقات)", + "MessageRemoveFromPlayerQueue": "إزالة من قائمة انتظار المشغل", + "MessageRemoveUserWarning": "هل أنت متأكد أنك تريد حذف المستخدم \"{0}\" نهائيًا؟", "MessageReportBugsAndContribute": "أبلغ عن الأخطاء، واطلب الميزات، وساهم في", + "MessageResetChaptersConfirm": "هل أنت متأكد أنك تريد إعادة تعيين الفصول والتراجع عن التغييرات التي أجريتها؟", + "MessageRestoreBackupConfirm": "هل أنت متأكد أنك تريد استعادة النسخ الاحتياطي الذي تم إنشاؤه في", + "MessageRestoreBackupWarning": "ستؤدي استعادة النسخ الاحتياطي إلى الكتابة فوق قاعدة البيانات بأكملها الموجودة في /config وصور الأغلفة في /metadata/items و /metadata/authors.

لا تعدل النسخ الاحتياطية أي ملفات في مجلدات مكتبتك. إذا قمت بتمكين إعدادات الخادم لتخزين صور الأغلفة والبيانات الوصفية في مجلدات مكتبتك، فلن يتم نسخها احتياطيًا أو الكتابة فوقها.

سيتم تحديث جميع العملاء الذين يستخدمون الخادم الخاص بك تلقائيًا.", + "MessageScheduleLibraryScanNote": "بالنسبة لمعظم المستخدمين، يوصى بترك هذه الميزة معطلة وإبقاء إعداد مراقب المجلدات ممكّنًا. سيكتشف مراقب المجلدات تلقائيًا التغييرات في مجلدات مكتبتك. لا يعمل مراقب المجلدات مع كل نظام ملفات (مثل NFS)، لذا يمكن استخدام عمليات فحص المكتبة المجدولة بدلاً من ذلك.", + "MessageScheduleRunEveryWeekdayAtTime": "تشغيل كل {0} في الساعة {1}", + "MessageSearchResultsFor": "نتائج البحث عن", + "MessageSelected": "تم تحديد {0}", + "MessageServerCouldNotBeReached": "تعذر الوصول إلى الخادم", + "MessageSetChaptersFromTracksDescription": "تعيين الفصول باستخدام كل ملف صوتي كفصل وعنوان الفصل كاسم الملف الصوتي", + "MessageShareExpirationWillBe": "سيكون تاريخ الانتهاء {0}", + "MessageShareExpiresIn": "ينتهي خلال {0}", + "MessageShareURLWillBe": "سيكون رابط المشاركة هو {0}", + "MessageStartPlaybackAtTime": "هل تريد بدء التشغيل لـ \"{0}\" في الساعة {1}؟", + "MessageTaskAudioFileNotWritable": "الملف الصوتي \"{0}\" غير قابل للكتابة", + "MessageTaskCanceledByUser": "تم إلغاء المهمة بواسطة المستخدم", + "MessageTaskDownloadingEpisodeDescription": "جاري تنزيل الحلقة \"{0}\"", + "MessageTaskEmbeddingMetadata": "جاري تضمين البيانات الوصفية", + "MessageTaskEmbeddingMetadataDescription": "جاري تضمين البيانات الوصفية في الكتاب الصوتي \"{0}\"", + "MessageTaskEncodingM4b": "جاري ترميز M4B", + "MessageTaskEncodingM4bDescription": "جاري ترميز الكتاب الصوتي \"{0}\" في ملف m4b واحد", + "MessageTaskFailed": "فشل", + "MessageTaskFailedToBackupAudioFile": "فشل في نسخ الملف الصوتي \"{0}\" احتياطيًا", + "MessageTaskFailedToCreateCacheDirectory": "فشل في إنشاء دليل ذاكرة التخزين المؤقت", + "MessageTaskFailedToEmbedMetadataInFile": "فشل في تضمين البيانات الوصفية في الملف \"{0}\"", + "MessageTaskFailedToMergeAudioFiles": "فشل في دمج الملفات الصوتية", + "MessageTaskFailedToMoveM4bFile": "فشل في نقل ملف m4b", + "MessageTaskFailedToWriteMetadataFile": "فشل في كتابة ملف البيانات الوصفية", + "MessageTaskMatchingBooksInLibrary": "جارٍ مطابقة الكتب في المكتبة \"{0}\"", + "MessageTaskNoFilesToScan": "لا توجد ملفات للفحص", + "MessageTaskOpmlImport": "استيراد OPML", + "MessageTaskOpmlImportDescription": "جارٍ إنشاء بودكاست من {0} خلاصة RSS", + "MessageTaskOpmlImportFeed": "استيراد تغذية OPML", + "MessageTaskOpmlImportFeedDescription": "جارٍ استيراد خلاصة RSS \"{0}\"", + "MessageTaskOpmlImportFeedFailed": "فشل في الحصول على تغذية البودكاست", + "MessageTaskOpmlImportFeedPodcastDescription": "جارٍ إنشاء بودكاست \"{0}\"", + "MessageTaskOpmlImportFeedPodcastExists": "البودكاست موجود بالفعل في المسار", + "MessageTaskOpmlImportFeedPodcastFailed": "فشل في إنشاء البودكاست", + "MessageTaskOpmlImportFinished": "تمت إضافة {0} بودكاست", + "MessageTaskOpmlParseFailed": "فشل في تحليل ملف OPML", + "MessageTaskOpmlParseFastFail": "ملف OPML غير صالح، لم يتم العثور على علامة أو لم يتم العثور على علامة ", + "MessageTaskOpmlParseNoneFound": "لم يتم العثور على أي خلاصات في ملف OPML", + "MessageTaskScanItemsAdded": "تمت إضافة {0}", + "MessageTaskScanItemsMissing": "{0} مفقود", + "MessageTaskScanItemsUpdated": "{0} تم تحديثه", + "MessageTaskScanNoChangesNeeded": "لا توجد تغييرات مطلوبة", + "MessageTaskScanningFileChanges": "جاري فحص تغييرات الملفات في \"{0}\"", + "MessageTaskScanningLibrary": "جاري فحص مكتبة \"{0}\"", + "MessageTaskTargetDirectoryNotWritable": "الدليل الهدف غير قابل للكتابة", + "MessageThinking": "جاري التفكير...", + "MessageUploaderItemFailed": "فشل الرفع", + "MessageUploaderItemSuccess": "تم الرفع بنجاح!", + "MessageUploading": "جاري الرفع...", + "MessageValidCronExpression": "تعبير Cron صالح", + "MessageWatcherIsDisabledGlobally": "المراقب معطل عالميًا في إعدادات الخادم", + "MessageXLibraryIsEmpty": "مكتبة {0} فارغة!", + "MessageYourAudiobookDurationIsLonger": "مدة كتابك الصوتي أطول من المدة التي تم العثور عليها", + "MessageYourAudiobookDurationIsShorter": "مدة كتابك الصوتي أقصر من المدة التي تم العثور عليها", + "NoteChangeRootPassword": "مستخدم الجذر هو المستخدم الوحيد الذي يمكن أن يكون لديه كلمة مرور فارغة", + "NoteChapterEditorTimes": "ملاحظة: يجب أن يظل وقت بدء الفصل الأول عند 0:00 ولا يمكن أن يتجاوز وقت بدء الفصل الأخير مدة هذا الكتاب الصوتي.", + "NoteFolderPicker": "ملاحظة: لن يتم عرض المجلدات التي تم تعيينها بالفعل", "NoteRSSFeedPodcastAppsHttps": "تحذير: تتطلب معظم تطبيقات البث الصوتي أن يكون عنوان URL لخلاصة RSS يستخدم HTTPS", "NoteRSSFeedPodcastAppsPubDate": "تحذير: حلقة واحدة أو أكثر من حلقاتك ليس لها تاريخ نشر. بعض تطبيقات البودكاست تتطلب هذا.", + "NoteUploaderFoldersWithMediaFiles": "سيتم التعامل مع المجلدات التي تحتوي على ملفات وسائط كعناصر مكتبة منفصلة.", + "NoteUploaderOnlyAudioFiles": "في حالة رفع ملفات صوتية فقط، سيتم التعامل مع كل ملف صوتي ككتاب صوتي منفصل.", + "NoteUploaderUnsupportedFiles": "يتم تجاهل الملفات غير المدعومة. عند اختيار مجلد أو إسقاطه، يتم تجاهل الملفات الأخرى التي ليست في مجلد عنصر.", + "NotificationOnBackupCompletedDescription": "يتم تشغيله عند اكتمال النسخ الاحتياطي", + "NotificationOnBackupFailedDescription": "يتم تشغيله عند فشل النسخ الاحتياطي", + "NotificationOnEpisodeDownloadedDescription": "يتم تشغيله عند تنزيل حلقة بودكاست تلقائيًا", + "NotificationOnTestDescription": "حدث لاختبار نظام الإشعارات", + "PlaceholderNewCollection": "اسم المجموعة الجديدة", + "PlaceholderNewFolderPath": "مسار المجلد الجديد", + "PlaceholderNewPlaylist": "اسم قائمة التشغيل الجديدة", + "PlaceholderSearch": "بحث..", + "PlaceholderSearchEpisode": "بحث عن حلقة..", + "StatsAuthorsAdded": "تمت إضافة مؤلفين", + "StatsBooksAdded": "تمت إضافة كتب", + "StatsBooksAdditional": "تتضمن بعض الإضافات…", + "StatsBooksFinished": "كتب تم الانتهاء منها", + "StatsBooksFinishedThisYear": "بعض الكتب التي تم الانتهاء منها هذا العام…", + "StatsBooksListenedTo": "كتب تم الاستماع إليها", + "StatsCollectionGrewTo": "نما مجموعتك من الكتب لتصبح…", + "StatsSessions": "جلسات", + "StatsSpentListening": "تم قضاء وقت في الاستماع", + "StatsTopAuthor": "أفضل مؤلف", + "StatsTopAuthors": "أفضل المؤلفين", + "StatsTopGenre": "أفضل تصنيف", + "StatsTopGenres": "أفضل التصنيفات", + "StatsTopMonth": "أفضل شهر", + "StatsTopNarrator": "أفضل راوي", + "StatsTopNarrators": "أفضل الرواة", + "StatsTotalDuration": "بإجمالي مدة…", + "StatsYearInReview": "ملخص العام", + "ToastAccountUpdateSuccess": "تم تحديث الحساب", + "ToastAppriseUrlRequired": "يجب إدخال عنوان URL لـ Apprise", + "ToastAsinRequired": "ASIN مطلوب", + "ToastAuthorImageRemoveSuccess": "تمت إزالة صورة المؤلف", + "ToastAuthorNotFound": "لم يتم العثور على المؤلف \"{0}\"", + "ToastAuthorRemoveSuccess": "تمت إزالة المؤلف", + "ToastAuthorSearchNotFound": "لم يتم العثور على المؤلف", + "ToastAuthorUpdateMerged": "تم دمج المؤلف", + "ToastAuthorUpdateSuccess": "تم تحديث المؤلف", + "ToastAuthorUpdateSuccessNoImageFound": "تم تحديث المؤلف (لم يتم العثور على صورة)", + "ToastBackupAppliedSuccess": "تم تطبيق النسخ الاحتياطي", + "ToastBackupCreateFailed": "فشل إنشاء النسخ الاحتياطي", + "ToastBackupCreateSuccess": "تم إنشاء النسخ الاحتياطي", + "ToastBackupDeleteFailed": "فشل حذف النسخ الاحتياطي", + "ToastBackupDeleteSuccess": "تم حذف النسخ الاحتياطي", + "ToastBackupInvalidMaxKeep": "عدد غير صالح للنسخ الاحتياطية التي يجب الاحتفاظ بها", + "ToastBackupInvalidMaxSize": "حجم أقصى غير صالح للنسخ الاحتياطي", + "ToastBackupRestoreFailed": "فشل استعادة النسخ الاحتياطي", + "ToastBackupUploadFailed": "فشل رفع النسخ الاحتياطي", + "ToastBackupUploadSuccess": "تم رفع النسخ الاحتياطي", + "ToastBatchApplyDetailsToItemsSuccess": "تم تطبيق التفاصيل على العناصر", + "ToastBatchDeleteFailed": "فشل الحذف المجمّع", + "ToastBatchDeleteSuccess": "نجاح الحذف المجمّع", + "ToastBatchQuickMatchFailed": "فشلت المطابقة السريعة المجمّعة!", + "ToastBatchQuickMatchStarted": "بدأت المطابقة السريعة المجمّعة لـ {0} كتابًا!", + "ToastBatchUpdateFailed": "فشل التحديث المجمّع", + "ToastBatchUpdateSuccess": "نجاح التحديث المجمّع", "ToastBookmarkCreateFailed": "فشل في إنشاء الإشارة المرجعية", + "ToastBookmarkCreateSuccess": "تمت إضافة الإشارة المرجعية", + "ToastBookmarkRemoveSuccess": "تمت إزالة الإشارة المرجعية", + "ToastCachePurgeFailed": "فشل مسح ذاكرة التخزين المؤقت", + "ToastCachePurgeSuccess": "تم مسح ذاكرة التخزين المؤقت بنجاح", + "ToastChaptersHaveErrors": "الفصول تحتوي على أخطاء", + "ToastChaptersInvalidShiftAmountLast": "مقدار إزاحة غير صالح. سيمتد وقت بدء الفصل الأخير إلى ما بعد مدة هذا الكتاب الصوتي.", + "ToastChaptersInvalidShiftAmountStart": "مقدار إزاحة غير صالح. سيكون للفصل الأول طول صفري أو سالب وسيتم الكتابة فوقه بواسطة الفصل الثاني. قم بزيادة مدة بدء الفصل الثاني.", + "ToastChaptersMustHaveTitles": "يجب أن تحتوي الفصول على عناوين", + "ToastChaptersRemoved": "تمت إزالة الفصول", + "ToastChaptersUpdated": "تم تحديث الفصول", + "ToastCollectionItemsAddFailed": "فشل إضافة عنصر (عناصر) إلى المجموعة", + "ToastCollectionRemoveSuccess": "تمت إزالة المجموعة", + "ToastCollectionUpdateSuccess": "تم تحديث المجموعة", + "ToastCoverUpdateFailed": "فشل تحديث الغلاف", + "ToastDateTimeInvalidOrIncomplete": "التاريخ والوقت غير صالحين أو غير مكتملين", + "ToastDeleteFileFailed": "فشل حذف الملف", + "ToastDeleteFileSuccess": "تم حذف الملف", + "ToastDeviceAddFailed": "فشل إضافة الجهاز", + "ToastDeviceNameAlreadyExists": "جهاز قارئ إلكتروني بهذا الاسم موجود بالفعل", + "ToastDeviceTestEmailFailed": "فشل إرسال البريد الإلكتروني التجريبي", + "ToastDeviceTestEmailSuccess": "تم إرسال البريد الإلكتروني التجريبي", + "ToastEmailSettingsUpdateSuccess": "تم تحديث إعدادات البريد الإلكتروني", + "ToastEncodeCancelFailed": "فشل إلغاء الترميز", + "ToastEncodeCancelSucces": "تم إلغاء الترميز", + "ToastEpisodeDownloadQueueClearFailed": "فشل مسح قائمة انتظار تنزيل الحلقات", + "ToastEpisodeDownloadQueueClearSuccess": "تم مسح قائمة انتظار تنزيل الحلقات", + "ToastEpisodeUpdateSuccess": "تم تحديث {0} حلقة", + "ToastErrorCannotShare": "لا يمكن المشاركة محليًا على هذا الجهاز", + "ToastFailedToLoadData": "فشل تحميل البيانات", + "ToastFailedToMatch": "فشل التطابق", + "ToastFailedToShare": "فشل المشاركة", + "ToastFailedToUpdate": "فشل التحديث", + "ToastInvalidImageUrl": "رابط صورة غير صالح", + "ToastInvalidMaxEpisodesToDownload": "الحد الأقصى غير صالح لعدد الحلقات المراد تنزيلها", + "ToastInvalidUrl": "رابط غير صالح", + "ToastItemCoverUpdateSuccess": "تم تحديث غلاف العنصر", + "ToastItemDeletedFailed": "فشل حذف العنصر", + "ToastItemDeletedSuccess": "تم حذف العنصر", + "ToastItemDetailsUpdateSuccess": "تم تحديث تفاصيل العنصر", "ToastItemMarkedAsFinishedFailed": "فشل في وضع علامة على الانتهاء", + "ToastItemMarkedAsFinishedSuccess": "تم وضع علامة \"تم الانتهاء\" على العنصر", "ToastItemMarkedAsNotFinishedFailed": "فشل في وضع علامة \"غير مكتمل\"", + "ToastItemMarkedAsNotFinishedSuccess": "تم وضع علامة \"غير مكتمل\" على العنصر", + "ToastItemUpdateSuccess": "تم تحديث العنصر", + "ToastLibraryCreateFailed": "فشل إنشاء المكتبة", + "ToastLibraryCreateSuccess": "تم إنشاء المكتبة \"{0}\"", + "ToastLibraryDeleteFailed": "فشل حذف المكتبة", + "ToastLibraryDeleteSuccess": "تم حذف المكتبة", + "ToastLibraryScanFailedToStart": "فشل بدء الفحص", + "ToastLibraryScanStarted": "بدأ فحص المكتبة", + "ToastLibraryUpdateSuccess": "تم تحديث المكتبة \"{0}\"", + "ToastMatchAllAuthorsFailed": "فشل مطابقة جميع المؤلفين", + "ToastMetadataFilesRemovedError": "حدث خطأ أثناء إزالة ملفات البيانات الوصفية. {0}", + "ToastMetadataFilesRemovedNoneFound": "لا توجد بيانات وصفية. تم العثور على {0} ملف في المكتبة", + "ToastMetadataFilesRemovedNoneRemoved": "لا توجد بيانات وصفية. تمت إزالة {0} ملفًا", + "ToastMetadataFilesRemovedSuccess": "{0} بيانات وصفية. تم إزالة {1} ملف", + "ToastMustHaveAtLeastOnePath": "يجب أن يكون هناك مسار واحد على الأقل", + "ToastNameEmailRequired": "الاسم والبريد الإلكتروني مطلوبان", + "ToastNameRequired": "الاسم مطلوب", + "ToastNewEpisodesFound": "تم العثور على {0} حلقة جديدة", + "ToastNewUserCreatedFailed": "فشل إنشاء الحساب: \"{0}\"", + "ToastNewUserCreatedSuccess": "تم إنشاء حساب جديد", + "ToastNewUserLibraryError": "يجب تحديد مكتبة واحدة على الأقل", + "ToastNewUserPasswordError": "يجب أن يكون لديك كلمة مرور، يمكن لمستخدم الجذر فقط أن يكون لديه كلمة مرور فارغة", + "ToastNewUserTagError": "يجب تحديد علامة واحدة على الأقل", + "ToastNewUserUsernameError": "أدخل اسم مستخدم", + "ToastNoNewEpisodesFound": "لم يتم العثور على حلقات جديدة", + "ToastNoRSSFeed": "لا يحتوي البودكاست على خلاصة RSS", + "ToastNoUpdatesNecessary": "لا توجد تحديثات ضرورية", + "ToastNotificationCreateFailed": "فشل إنشاء الإشعار", + "ToastNotificationDeleteFailed": "فشل حذف الإشعار", + "ToastNotificationFailedMaximum": "يجب أن يكون الحد الأقصى للمحاولات الفاشلة >= 0", + "ToastNotificationQueueMaximum": "يجب أن يكون الحد الأقصى لقائمة انتظار الإشعارات >= 0", + "ToastNotificationSettingsUpdateSuccess": "تم تحديث إعدادات الإشعارات", + "ToastNotificationTestTriggerFailed": "فشل تشغيل إشعار الاختبار", + "ToastNotificationTestTriggerSuccess": "تم تشغيل إشعار الاختبار", + "ToastNotificationUpdateSuccess": "تم تحديث الإشعار", "ToastPlaylistCreateFailed": "فشل إنشاء قائمة التشغيل", + "ToastPlaylistCreateSuccess": "تم إنشاء قائمة التشغيل", + "ToastPlaylistRemoveSuccess": "تمت إزالة قائمة التشغيل", + "ToastPlaylistUpdateSuccess": "تم تحديث قائمة التشغيل", "ToastPodcastCreateFailed": "فشل في إنشاء البودكاست", "ToastPodcastCreateSuccess": "تم إنشاء البودكاست بنجاح", + "ToastPodcastGetFeedFailed": "فشل في الحصول على تغذية البودكاست", + "ToastPodcastNoEpisodesInFeed": "لم يتم العثور على حلقات في خلاصة RSS", + "ToastPodcastNoRssFeed": "لا يحتوي البودكاست على خلاصة RSS", + "ToastProgressIsNotBeingSynced": "لا تتم مزامنة التقدم، أعد تشغيل التشغيل", + "ToastProviderCreatedFailed": "فشل إضافة المزود", + "ToastProviderCreatedSuccess": "تمت إضافة مزود جديد", + "ToastProviderNameAndUrlRequired": "الاسم والرابط مطلوبان", + "ToastProviderRemoveSuccess": "تمت إزالة المزود", "ToastRSSFeedCloseFailed": "فشل في إغلاق موجز RSS", - "ToastRSSFeedCloseSuccess": "تم إغلاق موجز RSS" + "ToastRSSFeedCloseSuccess": "تم إغلاق موجز RSS", + "ToastRemoveFailed": "فشل الإزالة", + "ToastRemoveItemFromCollectionFailed": "فشل إزالة العنصر من المجموعة", + "ToastRemoveItemFromCollectionSuccess": "تمت إزالة العنصر من المجموعة", + "ToastRemoveItemsWithIssuesFailed": "فشل إزالة عناصر المكتبة التي بها مشاكل", + "ToastRemoveItemsWithIssuesSuccess": "تمت إزالة عناصر المكتبة التي بها مشاكل", + "ToastRenameFailed": "فشل إعادة التسمية", + "ToastRescanFailed": "فشل إعادة الفحص لـ {0}", + "ToastRescanRemoved": "اكتملت إعادة الفحص، وتمت إزالة العنصر", + "ToastRescanUpToDate": "اكتملت إعادة الفحص، العنصر كان محدثًا", + "ToastRescanUpdated": "اكتملت إعادة الفحص، وتم تحديث العنصر", + "ToastScanFailed": "فشل فحص عنصر المكتبة", + "ToastSelectAtLeastOneUser": "حدد مستخدمًا واحدًا على الأقل", + "ToastSendEbookToDeviceFailed": "فشل إرسال الكتاب الإلكتروني إلى الجهاز", + "ToastSendEbookToDeviceSuccess": "تم إرسال الكتاب الإلكتروني إلى الجهاز \"{0}\"", + "ToastSeriesSubmitFailedSameName": "لا يمكن إضافة سلسلتين بنفس الاسم", + "ToastSeriesUpdateFailed": "فشل تحديث السلسلة", + "ToastSeriesUpdateSuccess": "نجاح تحديث السلسلة", + "ToastServerSettingsUpdateSuccess": "تم تحديث إعدادات الخادم", + "ToastSessionCloseFailed": "فشل إغلاق الجلسة", + "ToastSessionDeleteFailed": "فشل حذف الجلسة", + "ToastSessionDeleteSuccess": "تم حذف الجلسة", + "ToastSleepTimerDone": "انتهى مؤقت النوم... ششششش", + "ToastSlugMustChange": "يحتوي الاسم التعريفي على أحرف غير صالحة", + "ToastSlugRequired": "الاسم التعريفي مطلوب", + "ToastSocketConnected": "تم الاتصال بالمقبس" } From 19dc096d226f3be0973f307af47b6a82ab4597a1 Mon Sep 17 00:00:00 2001 From: Antoniy Chonkov Date: Mon, 19 May 2025 06:36:20 +0200 Subject: [PATCH 013/171] Translated using Weblate (Bulgarian) Currently translated at 75.5% (834 of 1104 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bg/ --- client/strings/bg.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/bg.json b/client/strings/bg.json index 7d94876c..481a3bb8 100644 --- a/client/strings/bg.json +++ b/client/strings/bg.json @@ -177,6 +177,7 @@ "HeaderPlaylist": "Плейлист", "HeaderPlaylistItems": "Елементи от плейлист", "HeaderPodcastsToAdd": "Подкасти за Добавяне", + "HeaderPresets": "Настройки по подразбиране", "HeaderPreviewCover": "Преглед на Корица", "HeaderRSSFeedGeneral": "RSS подробности", "HeaderRSSFeedIsOpen": "RSS емисията е отворена", @@ -219,6 +220,7 @@ "LabelAccountTypeAdmin": "Администратор", "LabelAccountTypeGuest": "Гост", "LabelAccountTypeUser": "Потребител", + "LabelActivities": "Дейности", "LabelActivity": "Дейност", "LabelAddToCollection": "Добави в Колекция", "LabelAddToCollectionBatch": "Добави {0} Книги в Колекция", From 35c2a5c1a3bf16c8c822aa5fed421e57cc0eaa07 Mon Sep 17 00:00:00 2001 From: Usama Khalil Date: Sun, 18 May 2025 11:59:36 +0200 Subject: [PATCH 014/171] Translated using Weblate (Arabic) Currently translated at 100.0% (1104 of 1104 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/ --- client/strings/ar.json | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/client/strings/ar.json b/client/strings/ar.json index 5c9265b5..59fcd4cf 100644 --- a/client/strings/ar.json +++ b/client/strings/ar.json @@ -98,7 +98,7 @@ "ButtonSetChaptersFromTracks": "تعيين الفصول من الملفات", "ButtonShare": "نشر", "ButtonShiftTimes": "أوقات العمل", - "ButtonShow": "عرض", + "ButtonShow": "أعرض", "ButtonStartM4BEncode": "ابدأ ترميز M4B", "ButtonStartMetadataEmbed": "ابدأ تضمين البيانات الوصفية", "ButtonStats": "الإحصائيات", @@ -168,7 +168,7 @@ "HeaderNotifications": "إشعارات", "HeaderOpenIDConnectAuthentication": "مصادقة OpenID Connect", "HeaderOpenListeningSessions": "جلسات الاستماع المفتوحة", - "HeaderOpenRSSFeed": "فتح تغذية RSS", + "HeaderOpenRSSFeed": "عرض تغذية RSS", "HeaderOtherFiles": "ملفات أخرى", "HeaderPasswordAuthentication": "مصادقة كلمة المرور", "HeaderPermissions": "الصلاحيات", @@ -474,7 +474,7 @@ "LabelOpenIDAdvancedPermsClaimDescription": "اسم مطالبة OpenID التي تحتوي على أذونات متقدمة لإجراءات المستخدم داخل التطبيق والتي ستطبق على الأدوار غير الإدارية (إذا تم تكوينها). إذا كانت المطالبة مفقودة من الاستجابة، فسيتم رفض الوصول إلى ABS. إذا كان هناك خيار واحد مفقودًا، فسيتم التعامل معه على أنه false. تأكد من أن مطالبة موفر الهوية تطابق البنية المتوقعة:", "LabelOpenIDClaims": "اترك الخيارات التالية فارغة لتعطيل تعيين المجموعة والأذونات المتقدمة، وسيتم تعيين مجموعة \"مستخدم\" تلقائيًا بعد ذلك.", "LabelOpenIDGroupClaimDescription": "اسم مطالبة OpenID التي تحتوي على قائمة بمجموعات المستخدم. يشار إليها عادةً باسم groups.إذا تم تكوينها، فسيقوم التطبيق تلقائيًا بتعيين الأدوار بناءً على عضويات مجموعة المستخدم، بشرط أن تسمى هذه المجموعات بشكل غير حساس لحالة الأحرف \"admin\" أو \"user\" أو \"guest\" في المطالبة. يجب أن تحتوي المطالبة على قائمة، وإذا كان المستخدم ينتمي إلى مجموعات متعددة، فسيقوم التطبيق بتعيين الدور المقابل لأعلى مستوى من الوصول. إذا لم تتطابق أي مجموعة، فسيتم رفض الوصول.", - "LabelOpenRSSFeed": "فتح تغذية RSS", + "LabelOpenRSSFeed": "تغذية RSS مفتوحة", "LabelOverwrite": "استبدال", "LabelPaginationPageXOfY": "صفحة {0} من {1}", "LabelPassword": "كلمة المرور", @@ -1086,5 +1086,21 @@ "ToastSleepTimerDone": "انتهى مؤقت النوم... ششششش", "ToastSlugMustChange": "يحتوي الاسم التعريفي على أحرف غير صالحة", "ToastSlugRequired": "الاسم التعريفي مطلوب", - "ToastSocketConnected": "تم الاتصال بالمقبس" + "ToastSocketConnected": "تم الاتصال بالمقبس", + "ToastSocketDisconnected": "تم قطع الاتصال بالمقبس", + "ToastSocketFailedToConnect": "فشل الاتصال بالمقبس", + "ToastSortingPrefixesEmptyError": "يجب أن يكون هناك بادئة فرز واحدة على الأقل", + "ToastSortingPrefixesUpdateSuccess": "تم تحديث بادئات الفرز ({0} عنصرًا)", + "ToastTitleRequired": "العنوان مطلوب", + "ToastUnknownError": "خطأ غير معروف", + "ToastUnlinkOpenIdFailed": "فشل فصل المستخدم عن OpenID", + "ToastUnlinkOpenIdSuccess": "تم فصل المستخدم عن OpenID", + "ToastUploaderFilepathExistsError": "مسار الملف \"{0}\" موجود بالفعل على الخادم", + "ToastUploaderItemExistsInSubdirectoryError": "يستخدم العنصر \"{0}\" دليلًا فرعيًا لمسار الرفع.", + "ToastUserDeleteFailed": "فشل حذف المستخدم", + "ToastUserDeleteSuccess": "تم حذف المستخدم", + "ToastUserPasswordChangeSuccess": "تم تغيير كلمة المرور بنجاح", + "ToastUserPasswordMismatch": "كلمات المرور غير متطابقة", + "ToastUserPasswordMustChange": "يجب ألا تطابق كلمة المرور الجديدة كلمة المرور القديمة", + "ToastUserRootRequireName": "يجب إدخال اسم مستخدم الجذر" } From d8e2ff8b0e2f612ee10451fbae84d2db18e41d08 Mon Sep 17 00:00:00 2001 From: peter cerny Date: Mon, 19 May 2025 08:42:11 +0200 Subject: [PATCH 015/171] Translated using Weblate (Slovak) Currently translated at 99.5% (1099 of 1104 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sk/ --- client/strings/sk.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/sk.json b/client/strings/sk.json index 714ad88a..f5a3aa6b 100644 --- a/client/strings/sk.json +++ b/client/strings/sk.json @@ -786,7 +786,7 @@ "MessageItemsSelected": "{0} vybraných položiek", "MessageItemsUpdated": "{0} aktualizovaných položiek", "MessageJoinUsOn": "Pridajte sa k nám", - "MessageLoading": "Načítanie...", + "MessageLoading": "Načítavam...", "MessageLoadingFolders": "Načítanie priečinkov...", "MessageLogsDescription": "Záznamy logovania sú uložené v /metadata/logs vo forme JSON súborov. Záznamy kritických chýb sú uložené v /metadata/logs/crash_logs.txt.", "MessageM4BFailed": "M4B zlyhalo!", @@ -826,7 +826,7 @@ "MessageNoSeries": "Žiadne série", "MessageNoTags": "Žiadne štítky", "MessageNoTasksRunning": "Žiadne prebiehajúce úlohy", - "MessageNoUpdatesWereNecessary": "Žiadne nutné aktualizácie", + "MessageNoUpdatesWereNecessary": "Neboli potrebné žiadne aktualizácie", "MessageNoUserPlaylists": "Nemáte žiadny playlist", "MessageNoUserPlaylistsHelp": "Playlisty sú súkromné. Každý playlist môže vidieť iba používateľ, ktorý ho vytvoril.", "MessageNotYetImplemented": "Ešte neimplementované", From 13d94628688a5dc1b99b32ae5bc80a2f1aacb659 Mon Sep 17 00:00:00 2001 From: Antoniy Chonkov Date: Mon, 19 May 2025 19:58:37 +0200 Subject: [PATCH 016/171] Translated using Weblate (Bulgarian) Currently translated at 81.7% (903 of 1104 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bg/ --- client/strings/bg.json | 71 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/client/strings/bg.json b/client/strings/bg.json index 481a3bb8..9fc3f7af 100644 --- a/client/strings/bg.json +++ b/client/strings/bg.json @@ -255,7 +255,7 @@ "LabelBackupLocation": "Местоположение на Архив", "LabelBackupsEnableAutomaticBackups": "Включи автоматично архивиране", "LabelBackupsEnableAutomaticBackupsHelp": "Архиви запазени в /metadata/backups", - "LabelBackupsMaxBackupSize": "Максимален размер на архива (в GB)", + "LabelBackupsMaxBackupSize": "Максимален размер на архива (в GB) (0 за неограничен)", "LabelBackupsMaxBackupSizeHelp": "За защита срещу грешки в конфигурацията, архивите ще се провалят ако надхвърлят конфигурирания размер.", "LabelBackupsNumberToKeep": "Брой архиви за запазване", "LabelBackupsNumberToKeepHelp": "Само 1 архив ще бъде премахнат на веднъж, така че ако вече имате повече архиви от това трябва да ги премахнете ръчно.", @@ -285,6 +285,7 @@ "LabelContinueSeries": "Продължи серии", "LabelCover": "Корица", "LabelCoverImageURL": "URL на Корица", + "LabelCoverProvider": "Източник за обложки", "LabelCreatedAt": "Създадено на", "LabelCronExpression": "Cron израз", "LabelCurrent": "Текущо", @@ -327,11 +328,20 @@ "LabelEncodingClearItemCache": "Уверете се, че периодично изчиствате кеша на елементите.", "LabelEncodingFinishedM4B": "Завършеният M4B файл ще бъде поставен в папката на вашите аудиокниги на:", "LabelEncodingInfoEmbedded": "Метаданните ще бъдат вградени в аудио траковете в папката на вашите аудиокниги.", + "LabelEncodingStartedNavigation": "Когато задачата е стартирана, можете да смените тази страница.", + "LabelEncodingTimeWarning": "Кодирането може да отнеме до 30 минути.", + "LabelEncodingWarningAdvancedSettings": "Внимание: Не променяйте тези настройки, ако не сте запознати с ffmpeg настройките за кодиране.", + "LabelEncodingWatcherDisabled": "Ако сте изключили наблюдението на папки, ще е нужно да сканирате повторно аудио книгата.", "LabelEnd": "Край", "LabelEndOfChapter": "Край на глава", "LabelEpisode": "Епизод", + "LabelEpisodeNotLinkedToRssFeed": "Епизодът не е свързан с RSS канал", + "LabelEpisodeNumber": "Епизод #{0}", "LabelEpisodeTitle": "Заглавие на Епизод", "LabelEpisodeType": "Тип на Епизод", + "LabelEpisodeUrlFromRssFeed": "URL адрес на епизод от RSS канал", + "LabelEpisodes": "Епизоди", + "LabelEpisodic": "Епизодичен", "LabelExample": "Пример", "LabelExpandSeries": "Покажи сериите", "LabelExpandSubSeries": "Покажи съб сериите", @@ -343,7 +353,9 @@ "LabelFetchingMetadata": "Взимане на Метаданни", "LabelFile": "Файл", "LabelFileBirthtime": "Дата на създаване на файла", + "LabelFileBornDate": "Роден {0}", "LabelFileModified": "Дата на модификация на файла", + "LabelFileModifiedDate": "Променен {0}", "LabelFilename": "Име на файла", "LabelFilterByUser": "Филтриране по Потребител", "LabelFindEpisodes": "Намери Епизоди", @@ -357,14 +369,17 @@ "LabelFontScale": "Мащаб на шрифта", "LabelFontStrikethrough": "Зачертан", "LabelFormat": "Формат", + "LabelFull": "Пълен", "LabelGenre": "Жанр", "LabelGenres": "Жанрове", "LabelHardDeleteFile": "Пълно Изтриване на Файл", "LabelHasEbook": "Има е-книга", "LabelHasSupplementaryEbook": "Има допълнителна е-книга", + "LabelHideSubtitles": "Скрий субтитри", "LabelHighestPriority": "Най-висок Приоритет", "LabelHost": "Хост", "LabelHour": "Час", + "LabelHours": "Часа", "LabelIcon": "Икона", "LabelImageURLFromTheWeb": "URL на Изображение от Интернет", "LabelInProgress": "В процес на изпълнение", @@ -379,8 +394,11 @@ "LabelIntervalEvery6Hours": "Всеки 6 часа", "LabelIntervalEveryDay": "Всеки ден", "LabelIntervalEveryHour": "Всеки час", + "LabelIntervalEveryMinute": "Всяка минута", "LabelInvert": "Обърни", "LabelItem": "Елемент", + "LabelJumpBackwardAmount": "Количество за прескачане назад", + "LabelJumpForwardAmount": "Количество за прескачане напред", "LabelLanguage": "Език", "LabelLanguageDefaultServer": "Език по подразбиране на сървъра", "LabelLanguages": "Езици", @@ -395,6 +413,7 @@ "LabelLess": "По-малко", "LabelLibrariesAccessibleToUser": "Библиотеки Достъпни за Потребителя", "LabelLibrary": "Библиотека", + "LabelLibraryFilterSublistEmpty": "Не {0}", "LabelLibraryItem": "Елемент на Библиотека", "LabelLibraryName": "Име на Библиотека", "LabelLimit": "Лимит", @@ -407,6 +426,10 @@ "LabelLowestPriority": "Най-нисък Приоритет", "LabelMatchExistingUsersBy": "Съпостави съществуващи потребители по", "LabelMatchExistingUsersByDescription": "Използва се за свързване на съществуващи потребители. След свързване потребителите ще бъдат съпоставени по уникален идентификатор от вашия доставчик на SSO", + "LabelMaxEpisodesToDownload": "Максимален брой епизоди за сваляне. Използвай 0 за неограничен.", + "LabelMaxEpisodesToDownloadPerCheck": "Максимален брой нови епизоди за сваляне за проверка", + "LabelMaxEpisodesToKeep": "Максимален брой епизоди за запазване", + "LabelMaxEpisodesToKeepHelp": "Стойност 0 указва без максимален лимит. След като нов епизод е автоматично свален, най-старият епизод ще бъде изтрит, ако имате повече от X епизода. Само по един епизод ще бъде изтриван за всеки нов свален такъв.", "LabelMediaPlayer": "Медия Плейър", "LabelMediaType": "Тип медия", "LabelMetaTag": "Мета Таг", @@ -414,6 +437,7 @@ "LabelMetadataOrderOfPrecedenceDescription": "По-високите източници на метаданни ще заменят по-ниските", "LabelMetadataProvider": "Доставчик на Метаданни", "LabelMinute": "Минута", + "LabelMinutes": "Минути", "LabelMissing": "Липсващо", "LabelMissingEbook": "Няма електронна книга", "LabelMissingSupplementaryEbook": "Няма допълнителна електронна книга", @@ -451,11 +475,14 @@ "LabelOpenIDGroupClaimDescription": "Име на OpenID твърдението, което съдържа списък с групите на потребителя. Обикновено се нарича groups. Ако е конфигурирано, приложението автоматично ще присвоява роли въз основа на членството на потребителя в групи, при условие че тези групи са наименувани без чувствителност към регистъра като 'admin', 'user' или 'guest' в твърдението. Твърдението трябва да съдържа списък и ако потребителят принадлежи към множество групи, приложението ще присвои ролята, съответстваща на най-високото ниво на достъп. Ако няма съвпадение с група, достъпът ще бъде отказан.", "LabelOpenRSSFeed": "Отвори RSS Feed", "LabelOverwrite": "Презапиши", + "LabelPaginationPageXOfY": "Страница {0} от {1}", "LabelPassword": "Парола", "LabelPath": "Път", + "LabelPermanent": "Постоянен", "LabelPermissionsAccessAllLibraries": "Може да достъпи до всички библиотеки", "LabelPermissionsAccessAllTags": "Може да достъпи всички тагове", "LabelPermissionsAccessExplicitContent": "Може да достъпи експлицитно съдържание", + "LabelPermissionsCreateEreader": "Може да създава електронен четец", "LabelPermissionsDelete": "Може да трие", "LabelPermissionsDownload": "Може да сваля", "LabelPermissionsUpdate": "Може да обновява", @@ -463,6 +490,8 @@ "LabelPersonalYearReview": "Преглед на годината Ви ({0})", "LabelPhotoPathURL": "Път/URL на Снимка", "LabelPlayMethod": "Метод на Пускане", + "LabelPlaybackRateIncrementDecrement": "Размер на увеличаване/намаляне при скоростта на възпроизвеждане", + "LabelPlayerChapterNumberMarker": "{0} от {1}", "LabelPlaylists": "Плейлисти", "LabelPodcast": "Подкаст", "LabelPodcastSearchRegion": "Регион за Търсене на Подкасти", @@ -474,9 +503,12 @@ "LabelPrimaryEbook": "Основна Електронна Книга", "LabelProgress": "Прогрес", "LabelProvider": "Доставчик", + "LabelProviderAuthorizationValue": "Стойност на Authorization Header", "LabelPubDate": "Дата на публикуване", "LabelPublishYear": "Година на публикуване", "LabelPublishedDate": "Публикувани {0}", + "LabelPublishedDecade": "Десетилетие на публикуване", + "LabelPublishedDecades": "Десетилетия на публикуване", "LabelPublisher": "Издател", "LabelPublishers": "Издателство", "LabelRSSFeedCustomOwnerEmail": "Персонализиран имейл на собственика", @@ -486,6 +518,7 @@ "LabelRSSFeedSlug": "идентификатор на RSS емисия", "LabelRSSFeedURL": "URL на RSS емисия", "LabelRandomly": "Случайно", + "LabelReAddSeriesToContinueListening": "Добави отново в \"Продължете да слушате\"", "LabelRead": "Прочети", "LabelReadAgain": "Прочети отново", "LabelReadEbookWithoutProgress": "Прочети електронната книга без записване прогрес", @@ -495,29 +528,40 @@ "LabelRedo": "Повтори", "LabelRegion": "Регион", "LabelReleaseDate": "Дата на Издаване", + "LabelRemoveAllMetadataAbs": "Премахни всички metadata.abs файлове", + "LabelRemoveAllMetadataJson": "Премахни всички metadata.json файлове", + "LabelRemoveAudibleBranding": "Премахни въведението и заключението на Audible от главите", "LabelRemoveCover": "Премахни Корица", + "LabelRemoveMetadataFile": "Премахни файловете с метаданни от папката на библиотеката", + "LabelRemoveMetadataFileHelp": "Премахни всички metadata.json и metadata.abs файлове от вашата {0} папка.", "LabelRowsPerPage": "Редове на Страница", "LabelSearchTerm": "Търси Термин", "LabelSearchTitle": "Търси Заглавие", "LabelSearchTitleOrASIN": "Търси Заглавие или ASIN", "LabelSeason": "Сезон", + "LabelSeasonNumber": "Сезон #{0}", "LabelSelectAll": "Избери всичко", "LabelSelectAllEpisodes": "Избери всички епизоди", "LabelSelectEpisodesShowing": "Избери {0} епизоди показани", "LabelSelectUsers": "Избери Потребители", "LabelSendEbookToDevice": "Изпрати електронна книга до ...", "LabelSequence": "Последователност", + "LabelSerial": "Сериал", "LabelSeries": "От сериите", "LabelSeriesName": "Име на Серия", "LabelSeriesProgress": "Прогрес на Серия", + "LabelServerLogLevel": "Ниво на сървърен журнал", "LabelServerYearReview": "Преглед на годината на сървъра ({0})", "LabelSetEbookAsPrimary": "Направи главен", "LabelSetEbookAsSupplementary": "Направи второстепенен", + "LabelSettingsAllowIframe": "Разреши вграждане в iframe", "LabelSettingsAudiobooksOnly": "Само аудиокниги", "LabelSettingsAudiobooksOnlyHelp": "Активирането на тази настройка ще игнорира файловете на електронни книги, освен ако не са в папка с аудиокниги, в което случай ще бъдат зададени като допълнителни електронни книги", "LabelSettingsBookshelfViewHelp": "Скеуморфен дизайн с дървени рафтове", "LabelSettingsChromecastSupport": "Chromecast поддръжка", "LabelSettingsDateFormat": "Формат на Дата", + "LabelSettingsEnableWatcher": "Автоматично сканиране на библиотеките за промени", + "LabelSettingsEnableWatcherForLibrary": "Автоматично сканиране на библиотеката за промени", "LabelSettingsEnableWatcherHelp": "Включва автоматичното добавяне/обновяване на елементи, когато се открият промени във файловете. *Изисква рестарт на сървъра", "LabelSettingsEpubsAllowScriptedContent": "Позволи скриптово съдържание в epub-и", "LabelSettingsEpubsAllowScriptedContentHelp": "Позволи epub файловете да изпълняват скриптове. Препоръчително е да бъде изключено освен ако не се доверявате на източника на epub файловете.", @@ -529,10 +573,13 @@ "LabelSettingsHideSingleBookSeriesHelp": "Сериите с една книга ще бъдат скрити от страницата на серията и рафтовете на началната страница.", "LabelSettingsHomePageBookshelfView": "Начална страница изглед на рафт", "LabelSettingsLibraryBookshelfView": "Библиотека изглед на рафт", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Процент завършеност е по-голям от", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Оставащо време е по-малко от (секунди)", + "LabelSettingsLibraryMarkAsFinishedWhen": "Отбелязване на мултимедиен елемент като завършен когато", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Пропусни предишни книги в Продължи Поредица", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Рафтът на началната страница 'Продължи поредицата' показва първата книга, която не е започната в поредици, в които има поне една завършена книга и няма книги в процес на четене. Активирането на тази настройка ще продължи поредицата от най-далечната завършена книга вместо от първата незапочната книга.", "LabelSettingsParseSubtitles": "Извлечи подзаглавия", - "LabelSettingsParseSubtitlesHelp": "Извлича подзаглавия от имената на папките на аудиокнигите.
Подзаглавията трябва да бъдат разделени с \" - \"
например \"Заглавие на Книга - Тук е Подзаглавито\" има подзаглавие \"Тук е Подзаглавито\"", + "LabelSettingsParseSubtitlesHelp": "Извлича подзаглавия от имената на папките на аудио книгите.
Подзаглавията трябва да бъдат разделени с \" - \"
например \"Заглавие на Книга - Тук е подзаглавието\" има подзаглавие \"Тук е подзаглавието\"", "LabelSettingsPreferMatchedMetadata": "Предпочети съвпадащи метаданни", "LabelSettingsPreferMatchedMetadataHelp": "Съвпадащите данни ще заменят детайлите на елемента при използване на Бързо Съпоставяне. По подразбиране Бързото Съпоставяне ще попълни само липсващите детайли.", "LabelSettingsSkipMatchingBooksWithASIN": "Пропусни съвпадащи книги, които вече имат ASIN", @@ -546,11 +593,19 @@ "LabelSettingsStoreMetadataWithItem": "Запази метаданните с елемента", "LabelSettingsStoreMetadataWithItemHelp": "По подразбиране метаданните се съхраняват в /metadata/items, като активирате тази настройка метаданните ще се съхраняват в папката на елемента на вашата библиотека", "LabelSettingsTimeFormat": "Формат на Време", + "LabelShare": "Сподели", + "LabelShareDownloadableHelp": "Разреши на потребителите през връзка за споделяне да свалят zip файл с мултимедийния елемент.", + "LabelShareOpen": "Общодостъпно", + "LabelShareURL": "URL за споделяне", "LabelShowAll": "Покажи всички", "LabelShowSeconds": "Покажи секунди", + "LabelShowSubtitles": "Показвай подзаглавия", "LabelSize": "Размер", "LabelSleepTimer": "Таймер за изключване", "LabelSlug": "Слъг", + "LabelSortAscending": "Възходящ", + "LabelSortDescending": "Низходящ", + "LabelSortPubDate": "Подреди по дата на публикуване", "LabelStart": "Старт", "LabelStartTime": "Начално Време", "LabelStarted": "Стартирано", @@ -585,6 +640,11 @@ "LabelThemeDark": "Тъмна", "LabelThemeLight": "Светла", "LabelTimeBase": "Времева Основа", + "LabelTimeDurationXHours": "{0} часа", + "LabelTimeDurationXMinutes": "{0} минути", + "LabelTimeDurationXSeconds": "{0} секунди", + "LabelTimeInMinutes": "Време в минути", + "LabelTimeLeft": "остава {0}", "LabelTimeListened": "Време Слушано", "LabelTimeListenedToday": "Време Слушано Днес", "LabelTimeRemaining": "{0} оставащи", @@ -592,6 +652,7 @@ "LabelTitle": "Заглавие", "LabelToolsEmbedMetadata": "Вграждане на Метаданни", "LabelToolsEmbedMetadataDescription": "Вграждане на метаданни в аудио файлове, включително корица и глави.", + "LabelToolsM4bEncoder": "M4B кодировчик", "LabelToolsMakeM4b": "Направи M4B Аудиокнига Файл", "LabelToolsMakeM4bDescription": "Генериране на .M4B аудиокнига файл с вградени метаданни, корица и глави.", "LabelToolsSplitM4b": "Раздели M4B на MP3-ки", @@ -604,26 +665,32 @@ "LabelTracksMultiTrack": "Многоканален", "LabelTracksNone": "Няма канали", "LabelTracksSingleTrack": "Единичен канал", + "LabelTrailer": "Трейлър", "LabelType": "Тип", "LabelUnabridged": "Несъкратен", "LabelUndo": "Отмени", "LabelUnknown": "Неизвестен", + "LabelUnknownPublishDate": "Неизвестна дата на публикуване", "LabelUpdateCover": "Обнови Корица", "LabelUpdateCoverHelp": "Позволи презаписване на съществуващите корици за избраните книги, когато се намери съвпадение", "LabelUpdateDetails": "Обнови Детайли", "LabelUpdateDetailsHelp": "Позволи презаписване на съществуващите детайли за избраните книги, когато се намери съвпадение", "LabelUpdatedAt": "Обновено на", "LabelUploaderDragAndDrop": "Плъзни и Пусни Файлове или Папки", + "LabelUploaderDragAndDropFilesOnly": "Извлачване на файлове", "LabelUploaderDropFiles": "Пусни Файлове", "LabelUploaderItemFetchMetadataHelp": "Автоматично вземи заглавие, автор и серия", + "LabelUseAdvancedOptions": "Използвай разширени опции", "LabelUseChapterTrack": "Използвай канал за глава", "LabelUseFullTrack": "Използвай пълен канал", + "LabelUseZeroForUnlimited": "Използвай 0 за неограничен", "LabelUser": "Потребител", "LabelUsername": "Потребителско име", "LabelValue": "Стойност", "LabelVersion": "Версия", "LabelViewBookmarks": "Виж Отметки", "LabelViewChapters": "Виж Глави", + "LabelViewPlayerSettings": "Виж настройки на плеъра", "LabelViewQueue": "Виж Опашка", "LabelVolume": "Сила на Звука", "LabelWeekdaysToRun": "Делници за изпълнение", From 0cd698cc8d95a5d0b503a0d5e78eef5a761244c3 Mon Sep 17 00:00:00 2001 From: peter cerny Date: Mon, 19 May 2025 13:13:58 +0200 Subject: [PATCH 017/171] Translated using Weblate (Slovak) Currently translated at 99.9% (1103 of 1104 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sk/ --- client/strings/sk.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/strings/sk.json b/client/strings/sk.json index f5a3aa6b..c1b3bfdc 100644 --- a/client/strings/sk.json +++ b/client/strings/sk.json @@ -531,6 +531,7 @@ "LabelReleaseDate": "Dátum vydania", "LabelRemoveAllMetadataAbs": "Odstrániť všetky súbory metadata.abs", "LabelRemoveAllMetadataJson": "Odstrániť všetky súbory metadata.json", + "LabelRemoveAudibleBranding": "Odstrániť z kapitol Audible intro a outro", "LabelRemoveCover": "Odstrániť prebal", "LabelRemoveMetadataFile": "Odstrániť súbory metadát z priečinkov položiek v knižnici", "LabelRemoveMetadataFileHelp": "Odstrániť všetky súbory metadata.json a metadata.abs vo Vašich {0} priečinkoch.", @@ -707,6 +708,7 @@ "MessageAddToPlayerQueue": "Pridať do zoznamu prehrávania", "MessageAppriseDescription": "Aby ste mohli používať túto funkciumusíte mať k dispozícii inštanciu Apprise API alebo inú, ktorá dokáže spracovávať rovnaké požiadavky/requesty.
Apprise URL musí byť úplná URL určená na zasielanie notifikácií, tj. ak napr. vaša APi beží na http://192.168.1.1:8337, vložte do daného poľa http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Uistite sa, že používate ASIN zo správneho regiónu Audible, nie Amazonu.", + "MessageAuthenticationOIDCChangesRestart": "Reštartujte svoj server po uložení, aby mohli byť použité zmeny OIDC.", "MessageBackupsDescription": "Zálohy pokrývajú používateľov, ich aktuálne stavy počúvania, detaily položiek knižnice, nastavenia servera a obrázky uložené v /metadata/items a /metadata/authors. Zálohy neobsahujú súbory v priečinkoch vašich knižníc.", "MessageBackupsLocationEditNote": "Poznámka: Zmena umiestnenia záloh nepresunie ani nezmení existujúce zálohy", "MessageBackupsLocationNoEditNote": "Poznámka: Umietnenie záloh je nastavené prostredníctvom premennej prostredia a nie je ho možné zmeniť z tohto miesta.", @@ -907,8 +909,8 @@ "NoteChangeRootPassword": "Root používateľ je jediný používateľ, ktorý môže mať prázdne heslo", "NoteChapterEditorTimes": "Poznámka: Prvá kapitola musí vždy začínať v 0:00 a začiatok poslednej kapitoly nemôže prekročiť trvanie tejto audioknihy.", "NoteFolderPicker": "Poznámka: Priečinky, ktoré už boli priradené, sa ďalej nezobrazujú", - "NoteRSSFeedPodcastAppsHttps": "Varovanie: Väčšina podcastových aplikácií vyžaduje, aby URL RSS zdroja vyžívala HTTPS", - "NoteRSSFeedPodcastAppsPubDate": "Varovanie: 1 alebo viacero vašich epizód neobsahuje infomáciu o dátume vydania. Niektoré podcastové aplikácie ju vyžadujú.", + "NoteRSSFeedPodcastAppsHttps": "Varovanie: Väčšina podcastových aplikácií požaduje URL RSS zdroja s HTTPS", + "NoteRSSFeedPodcastAppsPubDate": "Varovanie: 1 alebo viac vašich epizód neobsahuje infomáciu o dátum vydania. Niektoré podcastové ju vyžadujú.", "NoteUploaderFoldersWithMediaFiles": "Priečinky obsahujúce súbory médií budú považované za samostatné položky knižnice.", "NoteUploaderOnlyAudioFiles": "Ak budú nahraté iba zvukové súbory, každý zvukový súbor bude považovaný za samostatnú audioknihu.", "NoteUploaderUnsupportedFiles": "Nepodporované súbory budú ignorované. Pri výbere alebo prenesení priečinka, budú všetky súbory, ktoré nie sú v priečinku niektorej z položiek, ignorované.", @@ -972,6 +974,8 @@ "ToastCachePurgeFailed": "Vyčistenie vyrovnávacej pamäte zlyhalo", "ToastCachePurgeSuccess": "Vyrovnávacia pamäť vyčistená", "ToastChaptersHaveErrors": "Kapitoly obsahujú chyby", + "ToastChaptersInvalidShiftAmountLast": "Neplatná hodnota veľkosti posunutia. Začiatok poslednej kapitoly by ležal za koncom audioknihy.", + "ToastChaptersInvalidShiftAmountStart": "Nesprávna hodnota posunutia. Prvá kapitola by mala nulovú alebo zápornú dĺžku a bola by nahradená nasledujúcou kapitolou. Navýšte čas začiatku druhej kapitoly.", "ToastChaptersMustHaveTitles": "Kapitoly musia mať názvy", "ToastChaptersRemoved": "Kapitoly boli odstránené", "ToastChaptersUpdated": "Kapitoly boli aktualizované", From 846a8c38812df7468a936df029ac031547bfcfe3 Mon Sep 17 00:00:00 2001 From: biuklija Date: Tue, 20 May 2025 07:50:09 +0200 Subject: [PATCH 018/171] Translated using Weblate (Croatian) Currently translated at 100.0% (1105 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hr/ --- client/strings/hr.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/hr.json b/client/strings/hr.json index 7e4e82c4..c4715ff3 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -856,6 +856,7 @@ "MessageScheduleRunEveryWeekdayAtTime": "Pokreni svaki {0} u {1}", "MessageSearchResultsFor": "Rezultati pretrage za", "MessageSelected": "{0} odabrano", + "MessageSeriesSequenceCannotContainSpaces": "Slijed serijala ne može sadržavati praznine", "MessageServerCouldNotBeReached": "Nije moguće pristupiti poslužitelju", "MessageSetChaptersFromTracksDescription": "Postavi poglavlja koristeći se zvučnom datotekom kao poglavljem i nazivom datoteke kao naslovom poglavlja", "MessageShareExpirationWillBe": "Vrijeme isteka će biti {0}", From 6a51cb07e8516ee80c35e209a34749936f4b36d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=93=D0=BE=D1=80?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D1=96=D1=87?= Date: Tue, 20 May 2025 07:21:07 +0200 Subject: [PATCH 019/171] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1105 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/uk.json b/client/strings/uk.json index 5b652049..51e5f69e 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -856,6 +856,7 @@ "MessageScheduleRunEveryWeekdayAtTime": "Запуск кожні {0} о {1}", "MessageSearchResultsFor": "Результати пошуку для", "MessageSelected": "Вибрано: {0}", + "MessageSeriesSequenceCannotContainSpaces": "Послідовність серій не може містити пробілів", "MessageServerCouldNotBeReached": "Не вдалося підключитися до сервера", "MessageSetChaptersFromTracksDescription": "Створити глави з аудіодоріжок, встановивши назви файлів за заголовки", "MessageShareExpirationWillBe": "Термін сплине за {0}", From 8979e19e92e072327e9850b584ab7014d6257eb2 Mon Sep 17 00:00:00 2001 From: Usama Khalil Date: Tue, 20 May 2025 20:08:41 +0200 Subject: [PATCH 020/171] Translated using Weblate (Arabic) Currently translated at 100.0% (1105 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/ --- client/strings/ar.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/ar.json b/client/strings/ar.json index 59fcd4cf..fce38bc6 100644 --- a/client/strings/ar.json +++ b/client/strings/ar.json @@ -856,6 +856,7 @@ "MessageScheduleRunEveryWeekdayAtTime": "تشغيل كل {0} في الساعة {1}", "MessageSearchResultsFor": "نتائج البحث عن", "MessageSelected": "تم تحديد {0}", + "MessageSeriesSequenceCannotContainSpaces": "السلسلة المتعاقبة لا يمكن أن تحتوي على مسافات", "MessageServerCouldNotBeReached": "تعذر الوصول إلى الخادم", "MessageSetChaptersFromTracksDescription": "تعيين الفصول باستخدام كل ملف صوتي كفصل وعنوان الفصل كاسم الملف الصوتي", "MessageShareExpirationWillBe": "سيكون تاريخ الانتهاء {0}", From c950ac7d69a3f28de424b37bb47b57927c26eb3d Mon Sep 17 00:00:00 2001 From: Adolfo Jayme Barrientos Date: Wed, 21 May 2025 18:54:51 +0200 Subject: [PATCH 021/171] Translated using Weblate (Spanish) Currently translated at 99.9% (1104 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/client/strings/es.json b/client/strings/es.json index 80652f39..0da47082 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -855,6 +855,7 @@ "MessageScheduleRunEveryWeekdayAtTime": "Ejecutar cada {0} a las {1}", "MessageSearchResultsFor": "Resultados de la búsqueda de", "MessageSelected": "{0} seleccionado(s)", + "MessageSeriesSequenceCannotContainSpaces": "La secuencia de la serie no puede contener espacios", "MessageServerCouldNotBeReached": "No se pudo establecer la conexión con el servidor", "MessageSetChaptersFromTracksDescription": "Establecer capítulos usando cada archivo de audio como un capítulo y el título del capítulo como el nombre del archivo de audio", "MessageShareExpirationWillBe": "La caducidad será {0}", @@ -958,11 +959,11 @@ "ToastBackupInvalidMaxKeep": "Número no válido de copias de seguridad a conservar", "ToastBackupInvalidMaxSize": "Tamaño máximo de copia de seguridad no válido", "ToastBackupRestoreFailed": "Error al restaurar el respaldo", - "ToastBackupUploadFailed": "Error al subir el respaldo", + "ToastBackupUploadFailed": "Error al cargar la copia de respaldo", "ToastBackupUploadSuccess": "Respaldo cargado", "ToastBatchApplyDetailsToItemsSuccess": "Detalles aplicados a los elementos", "ToastBatchDeleteFailed": "Falló la eliminación por lotes", - "ToastBatchDeleteSuccess": "Borrado por lotes correcto", + "ToastBatchDeleteSuccess": "Se eliminó por lotes correctamente", "ToastBatchQuickMatchFailed": "¡Error en la sincronización rápida por lotes!", "ToastBatchQuickMatchStarted": "¡Se inició el lote de búsqueda rápida de {0} libros!", "ToastBatchUpdateFailed": "Falló la actualización por lotes", @@ -974,6 +975,7 @@ "ToastCachePurgeSuccess": "Se purgó la antememoria correctamente", "ToastChaptersHaveErrors": "Los capítulos tienen errores", "ToastChaptersInvalidShiftAmountLast": "Cantidad de desplazamiento no válida. La hora de inicio del último capítulo se extendería más allá de la duración de este audiolibro.", + "ToastChaptersInvalidShiftAmountStart": "Cantidad de desplazamiento no válida. El primer capítulo tendría una duración cero o negativa y lo sobrescribiría el segundo capítulo. Aumente la duración inicial del segundo capítulo.", "ToastChaptersMustHaveTitles": "Los capítulos deben tener título", "ToastChaptersRemoved": "Capítulos eliminados", "ToastChaptersUpdated": "Capítulos actualizados", From 0fc6afec260ed75ec97734824b1698dea0c2cb9b Mon Sep 17 00:00:00 2001 From: SunSpring Date: Wed, 21 May 2025 07:43:46 +0200 Subject: [PATCH 022/171] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1105 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 21293a4a..8715ac6e 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -856,6 +856,7 @@ "MessageScheduleRunEveryWeekdayAtTime": "每隔 {0} 在 {1} 运行一次", "MessageSearchResultsFor": "搜索结果", "MessageSelected": "{0} 已选择", + "MessageSeriesSequenceCannotContainSpaces": "系列序列不能包含空格", "MessageServerCouldNotBeReached": "无法访问服务器", "MessageSetChaptersFromTracksDescription": "把每个音频文件设置为章节并将章节标题设置为音频文件名", "MessageShareExpirationWillBe": "到期日期为 {0}", From 6e67b1d9dd55b9c27d6f00e5ba8a98cfb0e0909b Mon Sep 17 00:00:00 2001 From: Adolfo Jayme Barrientos Date: Wed, 21 May 2025 18:46:37 +0200 Subject: [PATCH 023/171] Translated using Weblate (Catalan) Currently translated at 96.0% (1061 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ca/ --- client/strings/ca.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/client/strings/ca.json b/client/strings/ca.json index 2aaf0377..ee104674 100644 --- a/client/strings/ca.json +++ b/client/strings/ca.json @@ -177,6 +177,7 @@ "HeaderPlaylist": "Llista de Reproducció", "HeaderPlaylistItems": "Elements de la Llista de Reproducció", "HeaderPodcastsToAdd": "Pòdcasts a afegir", + "HeaderPresets": "Valors predefinits", "HeaderPreviewCover": "Previsualització de la Portada", "HeaderRSSFeedGeneral": "Detalls RSS", "HeaderRSSFeedIsOpen": "La Font RSS està oberta", @@ -802,16 +803,18 @@ "MessageQuickEmbedQueue": "En cua per a inserció ràpida ({0} en cua)", "MessageQuickMatchAllEpisodes": "Combina ràpidament tots els episodis", "MessageQuickMatchDescription": "Omple detalls d'elements buits i portades amb els primers resultats de '{0}'. No sobreescriu els detalls tret que l'opció \"Preferir metadades trobades\" del servidor estigui habilitada.", - "MessageRemoveChapter": "Eliminar capítols", - "MessageRemoveEpisodes": "Eliminar {0} episodi(s)", - "MessageRemoveFromPlayerQueue": "Eliminar de la cua del reproductor", + "MessageRemoveChapter": "Elimina el capítol", + "MessageRemoveEpisodes": "Elimina {0} episodi(s)", + "MessageRemoveFromPlayerQueue": "Elimina de la cua del reproductor", "MessageRemoveUserWarning": "Segur que voleu suprimir permanentment l'usuari «{0}»?", "MessageReportBugsAndContribute": "Informa d'errors, sol·licita funcions i contribueix a", "MessageResetChaptersConfirm": "Segur que voleu desfer els canvis i revertir els capítols al seu estat original?", "MessageRestoreBackupConfirm": "Segur que voleu restaurar la còpia de seguretat creada a", "MessageRestoreBackupWarning": "Restaurar sobreescriurà tota la base de dades situada a /config i les imatges de portades a /metadata/items i /metadata/authors.

La còpia de seguretat no modifica cap fitxer a les carpetes de la teva biblioteca. Si has activat l'opció del servidor per guardar portades i metadades a les carpetes de la biblioteca, aquests fitxers no es guarden ni sobreescriuen.

Tots els clients que utilitzin el teu servidor s'actualitzaran automàticament.", + "MessageScheduleRunEveryWeekdayAtTime": "Executa cada {0} a les {1}", "MessageSearchResultsFor": "Resultats de la cerca de", "MessageSelected": "{0} seleccionat(s)", + "MessageSeriesSequenceCannotContainSpaces": "La seqüència de la sèrie no pot contenir espais", "MessageServerCouldNotBeReached": "No es va poder establir la connexió amb el servidor", "MessageSetChaptersFromTracksDescription": "Establir capítols utilitzant cada fitxer d'àudio com un capítol i el títol del capítol com el nom del fitxer d'àudio", "MessageShareExpirationWillBe": "La caducitat serà {0}", @@ -917,6 +920,7 @@ "ToastBackupRestoreFailed": "Error en restaurar la còpia de seguretat", "ToastBackupUploadFailed": "Error en carregar la còpia de seguretat", "ToastBackupUploadSuccess": "Còpia de seguretat carregada", + "ToastBatchApplyDetailsToItemsSuccess": "S'han aplicat els detalls als elements", "ToastBatchDeleteFailed": "Error en l'eliminació per lots", "ToastBatchDeleteSuccess": "Eliminació per lots correcte", "ToastBatchQuickMatchFailed": "Error en la sincronització ràpida per lots!", @@ -930,6 +934,7 @@ "ToastCachePurgeSuccess": "Memòria cau purgada amb èxit", "ToastChaptersHaveErrors": "Els capítols tenen errors", "ToastChaptersInvalidShiftAmountLast": "La quantitat de desplaçament no és vàlida. L'hora d'inici de l'últim capítol s'estendria més enllà de la durada d'aquest audiollibre.", + "ToastChaptersInvalidShiftAmountStart": "La quantitat de desplaçament no és vàlida. El primer capítol tindria una durada zero o negativa i el sobreescriuria el segon capítol. Augmenteu la durada inicial del segon capítol.", "ToastChaptersMustHaveTitles": "Els capítols han de tenir un títol", "ToastChaptersRemoved": "Capítols eliminats", "ToastChaptersUpdated": "Capítols actualitzats", @@ -937,6 +942,7 @@ "ToastCollectionRemoveSuccess": "Col·lecció eliminada", "ToastCollectionUpdateSuccess": "Col·lecció actualitzada", "ToastCoverUpdateFailed": "Error en actualitzar la portada", + "ToastDateTimeInvalidOrIncomplete": "La data i hora no és vàlida o està incompleta", "ToastDeleteFileFailed": "No s'ha pogut suprimir el fitxer", "ToastDeleteFileSuccess": "Fitxer suprimit", "ToastDeviceAddFailed": "Error en afegir el dispositiu", From 4fd97510b82f9a16f5be2529d0cdbf952c448a14 Mon Sep 17 00:00:00 2001 From: peter cerny Date: Thu, 22 May 2025 10:48:48 +0200 Subject: [PATCH 024/171] Translated using Weblate (Slovak) Currently translated at 99.9% (1104 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sk/ --- client/strings/sk.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/sk.json b/client/strings/sk.json index c1b3bfdc..39b3c0ae 100644 --- a/client/strings/sk.json +++ b/client/strings/sk.json @@ -856,6 +856,7 @@ "MessageScheduleRunEveryWeekdayAtTime": "Spustiť každú {0} o {1}", "MessageSearchResultsFor": "Výsledky vyhľadávania pre", "MessageSelected": "{0} vybrané", + "MessageSeriesSequenceCannotContainSpaces": "Poradie série nemôže obsahovať medzery", "MessageServerCouldNotBeReached": "Nepodarilo sa pripojiť na server", "MessageSetChaptersFromTracksDescription": "Nastaviť jednotlivé zvukové súbory ako kapitoly a názvy zvukových súborov ako názvy týchto kapitol", "MessageShareExpirationWillBe": "Expiruje {0}", From 7275b1063b03b281949382a4305a71a74648ac98 Mon Sep 17 00:00:00 2001 From: Jan Schoenfeld Date: Fri, 23 May 2025 22:39:27 +0200 Subject: [PATCH 025/171] Translated using Weblate (German) Currently translated at 100.0% (1105 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/de.json b/client/strings/de.json index 03b850c4..e15a22f7 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -856,6 +856,7 @@ "MessageScheduleRunEveryWeekdayAtTime": "Immer {0} um {1} ausführen", "MessageSearchResultsFor": "Suchergebnisse für", "MessageSelected": "{0} ausgewählt", + "MessageSeriesSequenceCannotContainSpaces": "Serie Abfolge kann keine Leerzeichen enthalten", "MessageServerCouldNotBeReached": "Server kann nicht erreicht werden", "MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird", "MessageShareExpirationWillBe": "Läuft am {0} ab", @@ -975,6 +976,7 @@ "ToastCachePurgeSuccess": "Cache geleert", "ToastChaptersHaveErrors": "Kapitel sind fehlerhaft", "ToastChaptersInvalidShiftAmountLast": "Die Verschiebung ist nicht möglich, da die Startzeit des letzten Kapitels über die Gesamtdauer dieses Hörbuchs hinausgehen würde.", + "ToastChaptersInvalidShiftAmountStart": "Ungültige Höhe der Verschiebung. Das erste Kapitel hätte eine Länge von Null oder eine negative Länge und würde vom zweiten Kapitel überschrieben werden. Erhöhen Sie die Startdauer des zweiten Kapitels.", "ToastChaptersMustHaveTitles": "Kapitel benötigen eindeutige Namen", "ToastChaptersRemoved": "Kapitel entfernt", "ToastChaptersUpdated": "Kapitel aktualisiert", From 243baaf77504d890398bce9d0b9019571cc3aeca Mon Sep 17 00:00:00 2001 From: thehijacker Date: Sat, 24 May 2025 19:20:17 +0200 Subject: [PATCH 026/171] Translated using Weblate (Slovenian) Currently translated at 100.0% (1105 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/sl.json b/client/strings/sl.json index 2794e080..2f028403 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -856,6 +856,7 @@ "MessageScheduleRunEveryWeekdayAtTime": "Zaženi vsakih {0} ob {1}", "MessageSearchResultsFor": "Rezultati iskanja za", "MessageSelected": "{0} izbrano", + "MessageSeriesSequenceCannotContainSpaces": "Zaporedje serij ne sme vsebovati presledkov", "MessageServerCouldNotBeReached": "Strežnika ni bilo mogoče doseči", "MessageSetChaptersFromTracksDescription": "Nastavi poglavja z uporabo vsake zvočne datoteke kot poglavja in naslova poglavja kot imena zvočne datoteke", "MessageShareExpirationWillBe": "Potečeno bo {0}", From ff425212e70e3865ae36e4f83303e5355c1142b9 Mon Sep 17 00:00:00 2001 From: ABS translator Date: Sun, 25 May 2025 09:11:49 +0200 Subject: [PATCH 027/171] Translated using Weblate (Arabic) Currently translated at 100.0% (1105 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/ --- client/strings/ar.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/client/strings/ar.json b/client/strings/ar.json index fce38bc6..a1383047 100644 --- a/client/strings/ar.json +++ b/client/strings/ar.json @@ -59,7 +59,7 @@ "ButtonPause": "إيقاف مؤقت", "ButtonPlay": "تشغيل", "ButtonPlayAll": "تشغيل الكل", - "ButtonPlaying": "مشغل الآن", + "ButtonPlaying": "جاري التشغيل", "ButtonPlaylists": "قوائم التشغيل", "ButtonPrevious": "سابِق", "ButtonPreviousChapter": "الفصل السابق", @@ -829,7 +829,7 @@ "MessageNoTags": "لا توجد علامات", "MessageNoTasksRunning": "لا توجد مهام قيد التشغيل", "MessageNoUpdatesWereNecessary": "لا حاجة لأي تحديثات", - "MessageNoUserPlaylists": "ليست لديك أي قوائم تشغيل", + "MessageNoUserPlaylists": "ليس لديك أي قوائم تشغيل", "MessageNoUserPlaylistsHelp": "قوائم التشغيل خاصة. لا يمكن إلا للمستخدم الذي ينشئها رؤيتها.", "MessageNotYetImplemented": "لم يتم تنفيذه بعد", "MessageOpmlPreviewNote": "ملاحظة: هذه معاينة لملف OPML الذي تم تحليله. سيتم أخذ عنوان البودكاست الفعلي من خلاصة RSS.", @@ -839,7 +839,7 @@ "MessagePlaylistCreateFromCollection": "إنشاء قائمة تشغيل من المجموعة", "MessagePleaseWait": "الرجاء الانتظار...", "MessagePodcastHasNoRSSFeedForMatching": "لا يحتوي البودكاست على عنوان URL لخلاصة RSS لاستخدامه في المطابقة", - "MessagePodcastSearchField": "أدخل مصطلح البحث أو عنوان URL لخلاصة RSS", + "MessagePodcastSearchField": "أدخل مصطلح البحث أو عنوان URL الخاص بتغذية RSS", "MessageQuickEmbedInProgress": "التضمين السريع قيد التقدم", "MessageQuickEmbedQueue": "تمت إضافته إلى قائمة انتظار التضمين السريع ({0} في قائمة الانتظار)", "MessageQuickMatchAllEpisodes": "مطابقة سريعة لجميع الحلقات", @@ -910,7 +910,7 @@ "NoteChangeRootPassword": "مستخدم الجذر هو المستخدم الوحيد الذي يمكن أن يكون لديه كلمة مرور فارغة", "NoteChapterEditorTimes": "ملاحظة: يجب أن يظل وقت بدء الفصل الأول عند 0:00 ولا يمكن أن يتجاوز وقت بدء الفصل الأخير مدة هذا الكتاب الصوتي.", "NoteFolderPicker": "ملاحظة: لن يتم عرض المجلدات التي تم تعيينها بالفعل", - "NoteRSSFeedPodcastAppsHttps": "تحذير: تتطلب معظم تطبيقات البث الصوتي أن يكون عنوان URL لخلاصة RSS يستخدم HTTPS", + "NoteRSSFeedPodcastAppsHttps": "تحذير: تتطلب معظم تطبيقات البث الصوتي أن يكون عنوان URL الخاص بتغذية RSS يستخدم HTTPS", "NoteRSSFeedPodcastAppsPubDate": "تحذير: حلقة واحدة أو أكثر من حلقاتك ليس لها تاريخ نشر. بعض تطبيقات البودكاست تتطلب هذا.", "NoteUploaderFoldersWithMediaFiles": "سيتم التعامل مع المجلدات التي تحتوي على ملفات وسائط كعناصر مكتبة منفصلة.", "NoteUploaderOnlyAudioFiles": "في حالة رفع ملفات صوتية فقط، سيتم التعامل مع كل ملف صوتي ككتاب صوتي منفصل.", @@ -1009,9 +1009,9 @@ "ToastItemDeletedFailed": "فشل حذف العنصر", "ToastItemDeletedSuccess": "تم حذف العنصر", "ToastItemDetailsUpdateSuccess": "تم تحديث تفاصيل العنصر", - "ToastItemMarkedAsFinishedFailed": "فشل في وضع علامة على الانتهاء", + "ToastItemMarkedAsFinishedFailed": "فشل وضع علامة \"مكتمل\"", "ToastItemMarkedAsFinishedSuccess": "تم وضع علامة \"تم الانتهاء\" على العنصر", - "ToastItemMarkedAsNotFinishedFailed": "فشل في وضع علامة \"غير مكتمل\"", + "ToastItemMarkedAsNotFinishedFailed": "فشل وضع علامة \"غير مكتمل\"", "ToastItemMarkedAsNotFinishedSuccess": "تم وضع علامة \"غير مكتمل\" على العنصر", "ToastItemUpdateSuccess": "تم تحديث العنصر", "ToastLibraryCreateFailed": "فشل إنشاء المكتبة", @@ -1051,7 +1051,7 @@ "ToastPlaylistCreateSuccess": "تم إنشاء قائمة التشغيل", "ToastPlaylistRemoveSuccess": "تمت إزالة قائمة التشغيل", "ToastPlaylistUpdateSuccess": "تم تحديث قائمة التشغيل", - "ToastPodcastCreateFailed": "فشل في إنشاء البودكاست", + "ToastPodcastCreateFailed": "فشل إنشاء البودكاست", "ToastPodcastCreateSuccess": "تم إنشاء البودكاست بنجاح", "ToastPodcastGetFeedFailed": "فشل في الحصول على تغذية البودكاست", "ToastPodcastNoEpisodesInFeed": "لم يتم العثور على حلقات في خلاصة RSS", @@ -1061,8 +1061,8 @@ "ToastProviderCreatedSuccess": "تمت إضافة مزود جديد", "ToastProviderNameAndUrlRequired": "الاسم والرابط مطلوبان", "ToastProviderRemoveSuccess": "تمت إزالة المزود", - "ToastRSSFeedCloseFailed": "فشل في إغلاق موجز RSS", - "ToastRSSFeedCloseSuccess": "تم إغلاق موجز RSS", + "ToastRSSFeedCloseFailed": "فشل إغلاق مغذّي RSS", + "ToastRSSFeedCloseSuccess": "تم إغلاق مغذّي RSS", "ToastRemoveFailed": "فشل الإزالة", "ToastRemoveItemFromCollectionFailed": "فشل إزالة العنصر من المجموعة", "ToastRemoveItemFromCollectionSuccess": "تمت إزالة العنصر من المجموعة", From 2589121908ece894a66d1d8ddaa3af686c389de8 Mon Sep 17 00:00:00 2001 From: Biepa Date: Mon, 26 May 2025 17:40:00 +0200 Subject: [PATCH 028/171] Translated using Weblate (German) Currently translated at 100.0% (1105 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/de.json b/client/strings/de.json index e15a22f7..55b33d8a 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -88,7 +88,7 @@ "ButtonSave": "Speichern", "ButtonSaveAndClose": "Speichern & Schließen", "ButtonSaveTracklist": "Speichere die Titelliste", - "ButtonScan": "Partial-Scan (nur geänderte/neue Medien)", + "ButtonScan": "Scannen", "ButtonScanLibrary": "Bibliothek scannen", "ButtonScrollLeft": "Nach Links scrollen", "ButtonScrollRight": "Nach Rechts scrollen", From d71f091e3ec5c364db9140afbf423ae7654db6b4 Mon Sep 17 00:00:00 2001 From: Adolfo Jayme Barrientos Date: Mon, 26 May 2025 07:30:13 +0200 Subject: [PATCH 029/171] Translated using Weblate (Spanish) Currently translated at 100.0% (1105 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/es/ --- client/strings/es.json | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/client/strings/es.json b/client/strings/es.json index 0da47082..4dac8272 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -499,7 +499,7 @@ "LabelPodcastType": "Tipo de pódcast", "LabelPodcasts": "Pódcast", "LabelPort": "Puerto", - "LabelPrefixesToIgnore": "Prefijos para Ignorar (no distingue entre mayúsculas y minúsculas.)", + "LabelPrefixesToIgnore": "Prefijos para ignorar (no distingue entre mayúsculas y minúsculas)", "LabelPreventIndexing": "Evite que los directorios de pódcast de iTunes y Google indicen su suministro", "LabelPrimaryEbook": "Libro electrónico principal", "LabelProgress": "Progreso", @@ -515,7 +515,7 @@ "LabelRSSFeedCustomOwnerEmail": "Correo electrónico de dueño personalizado", "LabelRSSFeedCustomOwnerName": "Nombre de dueño personalizado", "LabelRSSFeedOpen": "Suministro RSS abierto", - "LabelRSSFeedPreventIndexing": "Prevenir indexado", + "LabelRSSFeedPreventIndexing": "Evitar indización", "LabelRSSFeedSlug": "«Slug» de suministro RSS", "LabelRSSFeedURL": "URL de suministro RSS", "LabelRandomly": "Aleatorio", @@ -531,6 +531,7 @@ "LabelReleaseDate": "Fecha de estreno", "LabelRemoveAllMetadataAbs": "Eliminar todos los archivos metadata.abs", "LabelRemoveAllMetadataJson": "Eliminar todos los archivos metadata.json", + "LabelRemoveAudibleBranding": "Quitar introducción y cierre de Audible de los capítulos", "LabelRemoveCover": "Quitar cubierta", "LabelRemoveMetadataFile": "Eliminar archivos de metadatos en carpetas de elementos de biblioteca", "LabelRemoveMetadataFileHelp": "Elimine todos los archivos metadata.json y metadata.abs de sus carpetas {0}.", @@ -539,7 +540,7 @@ "LabelSearchTitle": "Buscar título", "LabelSearchTitleOrASIN": "Buscar título o ASIN", "LabelSeason": "Temporada", - "LabelSeasonNumber": "Sesión #{0}", + "LabelSeasonNumber": "{0}.ª temporada", "LabelSelectAll": "Seleccionar todo", "LabelSelectAllEpisodes": "Seleccionar todos los episodios", "LabelSelectEpisodesShowing": "Seleccionar los {0} episodios visibles", @@ -749,7 +750,7 @@ "MessageConfirmNotificationTestTrigger": "¿Activar esta notificación con datos de prueba?", "MessageConfirmPurgeCache": "Purgar la antememoria eliminará el directorio completo ubicado en /metadata/cache.

¿Confirma que quiere eliminar el directorio de antememoria?", "MessageConfirmPurgeItemsCache": "Purgar la antememoria de elementos eliminará el directorio completo ubicado en /metadata/cache/items.
¿Lo confirma?", - "MessageConfirmQuickEmbed": "¡Advertencia! La integración rápida no realiza copias de seguridad a ninguno de tus archivos de audio. Asegúrate de haber realizado una copia de los mismos previamente.

¿Deseas continuar?", + "MessageConfirmQuickEmbed": "Atención: la incrustación rápida no realiza copias de respaldo a ninguno de sus archivos de audio. Cerciórese de haber realizado una copia de los mismos previamente.

¿Quiere continuar?", "MessageConfirmQuickMatchEpisodes": "El reconocimiento rápido de extensiones sobrescribirá los detalles si se encuentra una coincidencia. Se actualizarán las extensiones no reconocidas. ¿Quiere continuar?", "MessageConfirmReScanLibraryItems": "¿Confirma que quiere volver a analizar {0} elementos?", "MessageConfirmRemoveAllChapters": "¿Confirma que quiere quitar todos los capítulos?", @@ -842,7 +843,7 @@ "MessageQuickEmbedInProgress": "Integración rápida en proceso", "MessageQuickEmbedQueue": "En cola para inserción rápida ({0} en cola)", "MessageQuickMatchAllEpisodes": "Combina rápidamente todos los episodios", - "MessageQuickMatchDescription": "Rellenar detalles de elementos vacíos y portada con los primeros resultados de '{0}'. No sobrescribe los detalles a menos que la opción \"Preferir Metadatos Encontrados\" del servidor esté habilitada.", + "MessageQuickMatchDescription": "Rellena los detalles y la cubierta de los elementos vacíos con el primer resultado coincidente de «{0}». No sobrescribe los detalles a menos que se active la opción del servidor «Preferir metadatos coincidentes».", "MessageRemoveChapter": "Quitar capítulo", "MessageRemoveEpisodes": "Quitar {0} episodio(s)", "MessageRemoveFromPlayerQueue": "Quitar de la cola de reproducción", @@ -956,8 +957,8 @@ "ToastBackupCreateSuccess": "Respaldo creado", "ToastBackupDeleteFailed": "Error al eliminar respaldo", "ToastBackupDeleteSuccess": "Respaldo eliminado", - "ToastBackupInvalidMaxKeep": "Número no válido de copias de seguridad a conservar", - "ToastBackupInvalidMaxSize": "Tamaño máximo de copia de seguridad no válido", + "ToastBackupInvalidMaxKeep": "Número no válido de copias de respaldo para conservar", + "ToastBackupInvalidMaxSize": "Tamaño máximo de copia de respaldo no válido", "ToastBackupRestoreFailed": "Error al restaurar el respaldo", "ToastBackupUploadFailed": "Error al cargar la copia de respaldo", "ToastBackupUploadSuccess": "Respaldo cargado", From 9eaa0c26cd4d439faa7c7f6d5d8c14fb2ffb3056 Mon Sep 17 00:00:00 2001 From: Grzegorz Orlowski Date: Mon, 26 May 2025 21:59:55 +0200 Subject: [PATCH 030/171] Translated using Weblate (Polish) Currently translated at 73.3% (810 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pl/ --- client/strings/pl.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/strings/pl.json b/client/strings/pl.json index 026d7c49..e1822755 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -177,6 +177,7 @@ "HeaderPlaylist": "Playlista", "HeaderPlaylistItems": "Pozycje listy odtwarzania", "HeaderPodcastsToAdd": "Podcasty do dodania", + "HeaderPresets": "Ustawienia wstępne", "HeaderPreviewCover": "Podgląd okładki", "HeaderRSSFeedGeneral": "Szczegóły RSS", "HeaderRSSFeedIsOpen": "Kanał RSS jest otwarty", @@ -219,6 +220,7 @@ "LabelAccountTypeAdmin": "Administrator", "LabelAccountTypeGuest": "Gość", "LabelAccountTypeUser": "Użytkownik", + "LabelActivities": "Aktywności", "LabelActivity": "Aktywność", "LabelAddToCollection": "Dodaj do kolekcji", "LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji", @@ -228,6 +230,7 @@ "LabelAddedDate": "Dodano {0}", "LabelAdminUsersOnly": "Tylko użytkownicy administracyjni", "LabelAll": "Wszystkie", + "LabelAllEpisodesDownloaded": "Wszystkie odcinki pobrane", "LabelAllUsers": "Wszyscy użytkownicy", "LabelAllUsersExcludingGuests": "Wszyscy użytkownicy z wyłączeniem gości", "LabelAllUsersIncludingGuests": "Wszyscy użytkownicy, łącznie z gośćmi", @@ -245,6 +248,7 @@ "LabelAutoFetchMetadata": "Automatycznie pobierz metadane", "LabelAutoFetchMetadataHelp": "Pobiera metadane dotyczące tytułu, autora i serii, aby usprawnić przesyłanie. Po przesłaniu może być konieczne dopasowanie dodatkowych metadanych.", "LabelAutoLaunch": "Uruchom automatycznie", + "LabelAutoLaunchDescription": "Automatyczne przekierowanie do dostawcy uwierzytelniania podczas przechodzenia na stronę logowania (ręczna zamiana ścieżki /login?autoLaunch=0)", "LabelAutoRegister": "Automatyczna rejestracja", "LabelAutoRegisterDescription": "Automatycznie utwórz nowych użytkowników po zalogowaniu", "LabelBackToUser": "Powrót", @@ -282,6 +286,7 @@ "LabelContinueSeries": "Kontynuuj serię", "LabelCover": "Okładka", "LabelCoverImageURL": "URL okładki", + "LabelCoverProvider": "Dostawca okładki", "LabelCreatedAt": "Utworzone", "LabelCronExpression": "Wyrażenie CRON", "LabelCurrent": "Aktualny", From 949c8ce230ce3a81555d8ede0460e37a9441679d Mon Sep 17 00:00:00 2001 From: Adolfo Jayme Barrientos Date: Mon, 26 May 2025 04:03:17 +0200 Subject: [PATCH 031/171] Translated using Weblate (Catalan) Currently translated at 96.2% (1064 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ca/ --- client/strings/ca.json | 65 ++++++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/client/strings/ca.json b/client/strings/ca.json index ee104674..d882385d 100644 --- a/client/strings/ca.json +++ b/client/strings/ca.json @@ -440,7 +440,7 @@ "LabelMinute": "Minut", "LabelMinutes": "Minuts", "LabelMissing": "Absent", - "LabelMissingEbook": "No té ebook", + "LabelMissingEbook": "No té llibre electrònic", "LabelMissingSupplementaryEbook": "No té ebook complementari", "LabelMobileRedirectURIs": "URI de redirecció mòbil permeses", "LabelMobileRedirectURIsDescription": "Aquesta és una llista blanca d'URI de redirecció vàlides per a aplicacions mòbils. El predeterminat és audiobookshelf, que pots eliminar o complementar amb URI addicionals per a la integració d'aplicacions de tercers. Usant un asterisc ( *) com a única entrada que permet qualsevol URI.", @@ -498,25 +498,25 @@ "LabelPodcastType": "Tipus de pòdcast", "LabelPodcasts": "Pòdcasts", "LabelPort": "Port", - "LabelPrefixesToIgnore": "Prefixos per Ignorar (no distingeix entre majúscules i minúscules.)", + "LabelPrefixesToIgnore": "Prefixos a ignorar (no distingeix entre majúscules i minúscules)", "LabelPreventIndexing": "Evita que el vostre canal l'indexin els directoris de pòdcasts de l'iTunes i Google", - "LabelPrimaryEbook": "Ebook Principal", + "LabelPrimaryEbook": "Llibre electrònic principal", "LabelProgress": "Progrés", "LabelProvider": "Proveïdor", "LabelProviderAuthorizationValue": "Valor de l'encapçalament d'autorització", - "LabelPubDate": "Data de Publicació", - "LabelPublishYear": "Any de Publicació", + "LabelPubDate": "Data de publicació", + "LabelPublishYear": "Any de publicació", "LabelPublishedDate": "Publicat {0}", - "LabelPublishedDecade": "Dècada de Publicació", + "LabelPublishedDecade": "Dècada de publicació", "LabelPublishedDecades": "Dècades Publicades", "LabelPublisher": "Editor", "LabelPublishers": "Editors", "LabelRSSFeedCustomOwnerEmail": "Correu Electrònic Personalitzat del Propietari", "LabelRSSFeedCustomOwnerName": "Nom Personalitzat del Propietari", "LabelRSSFeedOpen": "Font RSS Oberta", - "LabelRSSFeedPreventIndexing": "Evitar l'indexació", - "LabelRSSFeedSlug": "Font RSS Slug", - "LabelRSSFeedURL": "URL de la Font RSS", + "LabelRSSFeedPreventIndexing": "Evita la indexació", + "LabelRSSFeedSlug": "URL semàntic del canal RSS", + "LabelRSSFeedURL": "URL del canal RSS", "LabelRandomly": "A l'atzar", "LabelReAddSeriesToContinueListening": "Reafegir la sèrie per continuar escoltant-la", "LabelRead": "Llegit", @@ -525,39 +525,40 @@ "LabelRecentSeries": "Sèries recents", "LabelRecentlyAdded": "Addicions recents", "LabelRecommended": "Recomanats", - "LabelRedo": "Refer", + "LabelRedo": "Refés", "LabelRegion": "Regió", - "LabelReleaseDate": "Data d'Estrena", - "LabelRemoveAllMetadataAbs": "Eliminar tots els fitxers metadata.abs", - "LabelRemoveAllMetadataJson": "Eliminar tots els fitxers metadata.json", - "LabelRemoveCover": "Eliminar Coberta", + "LabelReleaseDate": "Data d'estrena", + "LabelRemoveAllMetadataAbs": "Elimina tots els fitxers metadata.abs", + "LabelRemoveAllMetadataJson": "Elimina tots els fitxers metadata.json", + "LabelRemoveAudibleBranding": "Elimina la introducció i el tancament de l'Audible dels capítols", + "LabelRemoveCover": "Elimina la coberta", "LabelRemoveMetadataFile": "Eliminar fitxers de metadades en carpetes d'elements de biblioteca", "LabelRemoveMetadataFileHelp": "Elimina tots els fitxers metadata.json i metadata.abs de les vostres carpetes {0}.", - "LabelRowsPerPage": "Files per Pàgina", - "LabelSearchTerm": "Cercar Terme", - "LabelSearchTitle": "Cercar Títol", - "LabelSearchTitleOrASIN": "Cercar Títol o ASIN", + "LabelRowsPerPage": "Files per pàgina", + "LabelSearchTerm": "Cerca terme", + "LabelSearchTitle": "Cerca títol", + "LabelSearchTitleOrASIN": "Cerca títol o ASIN", "LabelSeason": "Temporada", - "LabelSeasonNumber": "Temporada #{0}", - "LabelSelectAll": "Seleccionar tot", - "LabelSelectAllEpisodes": "Seleccionar tots els episodis", + "LabelSeasonNumber": "{0}a temporada", + "LabelSelectAll": "Selecciona-ho tot", + "LabelSelectAllEpisodes": "Selecciona tots els episodis", "LabelSelectEpisodesShowing": "Seleccionar els {0} episodis visibles", "LabelSelectUsers": "Seleccionar usuaris", "LabelSendEbookToDevice": "Enviar Ebook a...", "LabelSequence": "Seqüència", "LabelSerial": "En sèrie", - "LabelSeries": "Sèries", - "LabelSeriesName": "Nom de la Sèrie", - "LabelSeriesProgress": "Progrés de la Sèrie", + "LabelSeries": "Sèrie", + "LabelSeriesName": "Nom de la sèrie", + "LabelSeriesProgress": "Progrés de la sèrie", "LabelServerLogLevel": "Nivell de registre del servidor", "LabelServerYearReview": "Resum de l'any del servidor ({0})", "LabelSetEbookAsPrimary": "Establir com a principal", "LabelSetEbookAsSupplementary": "Establir com a suplementari", - "LabelSettingsAudiobooksOnly": "Només Audiollibres", - "LabelSettingsAudiobooksOnlyHelp": "Activant aquesta opció s'ignoraran els fitxers d'ebook, excepte si estan dins d'una carpeta d'audiollibre, en aquest cas es marcaran com ebooks suplementaris", + "LabelSettingsAudiobooksOnly": "Només audiollibres", + "LabelSettingsAudiobooksOnlyHelp": "En activar aquesta opció s'ignoraran els fitxers de llibre electrònic, excepte si estan dins d'una carpeta d'audiollibre; en aquest cas es marcaran com a llibres suplementaris", "LabelSettingsBookshelfViewHelp": "Disseny esqueomorf amb prestatgeries de fusta", "LabelSettingsChromecastSupport": "Compatibilitat amb Chromecast", - "LabelSettingsDateFormat": "Format de Data", + "LabelSettingsDateFormat": "Format de data", "LabelSettingsEnableWatcherHelp": "Permet afegir/actualitzar elements automàticament quan es detectin canvis en els fitxers. *Requereix reiniciar el servidor", "LabelSettingsEpubsAllowScriptedContent": "Permetre scripts en epubs", "LabelSettingsEpubsAllowScriptedContentHelp": "Permetre que els fitxers epub executin scripts. Es recomana mantenir aquesta opció desactivada tret que confiïs en l'origen dels fitxers epub.", @@ -577,6 +578,8 @@ "LabelSize": "Mida", "LabelSleepTimer": "Temporitzador de repòs", "LabelSlug": "Slug", + "LabelSortAscending": "Ascendent", + "LabelSortDescending": "Descendent", "LabelStart": "Inicia", "LabelStartTime": "Hora d'inici", "LabelStarted": "Iniciat", @@ -802,7 +805,7 @@ "MessageQuickEmbedInProgress": "Integració ràpida en procés", "MessageQuickEmbedQueue": "En cua per a inserció ràpida ({0} en cua)", "MessageQuickMatchAllEpisodes": "Combina ràpidament tots els episodis", - "MessageQuickMatchDescription": "Omple detalls d'elements buits i portades amb els primers resultats de '{0}'. No sobreescriu els detalls tret que l'opció \"Preferir metadades trobades\" del servidor estigui habilitada.", + "MessageQuickMatchDescription": "Emplena els detalls i la coberta dels elements buits amb el resultat de la primera coincidència de «{0}». No sobreescriu els detalls tret que s'activi el paràmetre del servidor «Prefereix metadades coincidents».", "MessageRemoveChapter": "Elimina el capítol", "MessageRemoveEpisodes": "Elimina {0} episodi(s)", "MessageRemoveFromPlayerQueue": "Elimina de la cua del reproductor", @@ -820,7 +823,7 @@ "MessageShareExpirationWillBe": "La caducitat serà {0}", "MessageShareExpiresIn": "Caduca en {0}", "MessageShareURLWillBe": "La URL per compartir serà {0}", - "MessageStartPlaybackAtTime": "Començar la reproducció per a \"{0}\" a {1}?", + "MessageStartPlaybackAtTime": "Voleu començar la reproducció per a «{0}» a {1}?", "MessageTaskAudioFileNotWritable": "El fitxer d'àudio «{0}» no es pot escriure", "MessageTaskCanceledByUser": "Tasca cancel·lada per l'usuari", "MessageTaskDownloadingEpisodeDescription": "S'està baixant l'episodi «{0}»", @@ -991,7 +994,7 @@ "ToastNewUserCreatedFailed": "No s'ha pogut crear el compte: «{0}»", "ToastNewUserCreatedSuccess": "Nou compte creat", "ToastNewUserLibraryError": "S'ha de seleccionar almenys una biblioteca", - "ToastNewUserPasswordError": "Necessites una contrasenya, només el root pot estar sense contrasenya", + "ToastNewUserPasswordError": "Cal una contrasenya; només l'usuari primari pot estar sense contrasenya", "ToastNewUserTagError": "S'ha de seleccionar almenys una etiqueta", "ToastNewUserUsernameError": "Introduïu un nom d'usuari", "ToastNoNewEpisodesFound": "No s'han trobat nous episodis", @@ -1034,7 +1037,7 @@ "ToastScanFailed": "No s'ha pogut escanejar l'element de la biblioteca", "ToastSelectAtLeastOneUser": "Selecciona almenys un usuari", "ToastSendEbookToDeviceFailed": "Error en enviar l'ebook al dispositiu", - "ToastSendEbookToDeviceSuccess": "Ebook enviat al dispositiu \"{0}\"", + "ToastSendEbookToDeviceSuccess": "El llibre electrònic s'ha enviat al dispositiu «{0}»", "ToastSeriesSubmitFailedSameName": "No és possible afegir dues sèries amb el mateix nom", "ToastSeriesUpdateFailed": "Error en actualitzar la sèrie", "ToastSeriesUpdateSuccess": "Sèrie actualitzada", From abaa7b5ad0debb238b1afba3f7a8c8c37b76c82f Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 28 May 2025 17:09:39 -0500 Subject: [PATCH 032/171] Add arabic language option --- client/plugins/i18n.js | 1 + 1 file changed, 1 insertion(+) diff --git a/client/plugins/i18n.js b/client/plugins/i18n.js index 1769e6eb..86109175 100644 --- a/client/plugins/i18n.js +++ b/client/plugins/i18n.js @@ -5,6 +5,7 @@ import { supplant } from './utils' const defaultCode = 'en-us' const languageCodeMap = { + ar: { label: 'عربي', dateFnsLocale: 'ar' }, bg: { label: 'Български', dateFnsLocale: 'bg' }, bn: { label: 'বাংলা', dateFnsLocale: 'bn' }, ca: { label: 'Català', dateFnsLocale: 'ca' }, From 0c8e334b1a77c13bc97ea9e9775f0fd132cce212 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 29 May 2025 17:27:29 -0500 Subject: [PATCH 033/171] Update rich text editor to prevent pasting in images from the browser --- client/components/ui/VueTrix.vue | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/components/ui/VueTrix.vue b/client/components/ui/VueTrix.vue index 6a24f1d3..2687d934 100644 --- a/client/components/ui/VueTrix.vue +++ b/client/components/ui/VueTrix.vue @@ -31,7 +31,7 @@ - + @@ -316,6 +316,12 @@ export default { if (this.$refs.trix && this.$refs.trix.blur) { this.$refs.trix.blur() } + }, + handleAttachmentAdd(event) { + // Prevent pasting in images from the browser + if (!event.attachment.file) { + event.attachment.remove() + } } }, mounted() { From 4968864498fd525e90cb9ccf714a6fe975a0cab4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 30 May 2025 17:33:15 -0500 Subject: [PATCH 034/171] Fix safari specific issue with line clamp on description #4348 --- client/pages/item/_id/index.vue | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 38152cb1..6042f92a 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -819,6 +819,17 @@ export default { -webkit-line-clamp: 4; max-height: calc(6 * 1lh); } + +/* Safari-specific fix for the description clamping */ +@supports (-webkit-touch-callout: none) { + #item-description { + position: relative; + display: block; + overflow: hidden; + max-height: calc(6 * 1lh); + } +} + #item-description.show-full { -webkit-line-clamp: unset; max-height: 999rem; From 9052ceedd3659aca1e6b227e62fe0687c1d94336 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 31 May 2025 17:01:58 -0500 Subject: [PATCH 035/171] Sanitize media item & episode description on update --- server/controllers/PodcastController.js | 10 ++++++++++ server/models/Book.js | 11 ++++++++++- server/models/Podcast.js | 10 ++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index 6395e05b..1ebe1d11 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -9,6 +9,7 @@ const fs = require('../libs/fsExtra') const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils') const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils') const { validateUrl } = require('../utils/index') +const htmlSanitizer = require('../utils/htmlSanitizer') const Scanner = require('../scanner/Scanner') const CoverManager = require('../managers/CoverManager') @@ -404,6 +405,15 @@ class PodcastController { const supportedStringKeys = ['title', 'subtitle', 'description', 'pubDate', 'episode', 'season', 'episodeType'] for (const key in req.body) { if (supportedStringKeys.includes(key) && typeof req.body[key] === 'string') { + // Sanitize description HTML + if (key === 'description' && req.body[key]) { + const sanitizedDescription = htmlSanitizer.sanitize(req.body[key]) + if (sanitizedDescription !== req.body[key]) { + Logger.debug(`[PodcastController] Sanitized description from "${req.body[key]}" to "${sanitizedDescription}"`) + req.body[key] = sanitizedDescription + } + } + updatePayload[key] = req.body[key] } else if (key === 'chapters' && Array.isArray(req.body[key]) && req.body[key].every((ch) => typeof ch === 'object' && ch.title && ch.start)) { updatePayload[key] = req.body[key] diff --git a/server/models/Book.js b/server/models/Book.js index 0dd0b785..96371f3a 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -377,8 +377,17 @@ class Book extends Model { if (typeof payload.metadata[key] == 'number') { payload.metadata[key] = String(payload.metadata[key]) } - + if ((typeof payload.metadata[key] === 'string' || payload.metadata[key] === null) && this[key] !== payload.metadata[key]) { + // Sanitize description HTML + if (key === 'description' && payload.metadata[key]) { + const sanitizedDescription = htmlSanitizer.sanitize(payload.metadata[key]) + if (sanitizedDescription !== payload.metadata[key]) { + Logger.debug(`[Book] "${this.title}" Sanitized description from "${payload.metadata[key]}" to "${sanitizedDescription}"`) + payload.metadata[key] = sanitizedDescription + } + } + this[key] = payload.metadata[key] || null if (key === 'title') { diff --git a/server/models/Podcast.js b/server/models/Podcast.js index fa27821d..d99a66df 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -2,6 +2,7 @@ const { DataTypes, Model } = require('sequelize') const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils') const Logger = require('../Logger') const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') +const htmlSanitizer = require('../utils/htmlSanitizer') /** * @typedef PodcastExpandedProperties @@ -215,6 +216,15 @@ class Podcast extends Model { newKey = 'itunesPageURL' } if ((typeof payload.metadata[key] === 'string' || payload.metadata[key] === null) && payload.metadata[key] !== this[newKey]) { + // Sanitize description HTML + if (key === 'description' && payload.metadata[key]) { + const sanitizedDescription = htmlSanitizer.sanitize(payload.metadata[key]) + if (sanitizedDescription !== payload.metadata[key]) { + Logger.debug(`[Podcast] "${this.title}" Sanitized description from "${payload.metadata[key]}" to "${sanitizedDescription}"`) + payload.metadata[key] = sanitizedDescription + } + } + this[newKey] = payload.metadata[key] || null if (key === 'title') { From 96401c377c5a3a0db0eef2b40a2d6b801944f137 Mon Sep 17 00:00:00 2001 From: Tommaso Bellandi Date: Thu, 29 May 2025 20:36:39 +0200 Subject: [PATCH 036/171] Translated using Weblate (Italian) Currently translated at 100.0% (1105 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/it/ --- client/strings/it.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/it.json b/client/strings/it.json index ae2facd1..41c06175 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -708,6 +708,7 @@ "MessageAddToPlayerQueue": "Aggiungi alla coda di riproduzione", "MessageAppriseDescription": "Per utilizzare questa funzione è necessario disporre di un'istanza di Apprise API in esecuzione o un'API che gestirà quelle stesse richieste.
L'API Url dovrebbe essere il percorso URL completo per inviare la notifica, ad esempio se la tua istanza API è servita cosi .http://192.168.1.1:8337 Allora dovrai mettere http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Assicurati di utilizzare l'ASIN della regione Audible corretta, non di Amazon.", + "MessageAuthenticationOIDCChangesRestart": "Riavvia il tuo server dopo aver salvato per applicare le modifiche OIDC.", "MessageBackupsDescription": "I backup includono utenti, progressi degli utenti, dettagli sugli elementi della libreria, impostazioni del server e immagini archiviate in /metadata/items & /metadata/authors. I backup non includono i file archiviati nelle cartelle della libreria.", "MessageBackupsLocationEditNote": "Nota: l'aggiornamento della posizione di backup non sposterà o modificherà i backup esistenti", "MessageBackupsLocationNoEditNote": "Nota: la posizione del backup viene impostata tramite una variabile di ambiente e non può essere modificata qui.", @@ -855,6 +856,7 @@ "MessageScheduleRunEveryWeekdayAtTime": "Esegui ogni {0} alle {1}", "MessageSearchResultsFor": "cerca risultati per", "MessageSelected": "{0} selezionati", + "MessageSeriesSequenceCannotContainSpaces": "La sequenza della serie non può contenere spazi", "MessageServerCouldNotBeReached": "Impossibile raggiungere il server", "MessageSetChaptersFromTracksDescription": "Impostare i capitoli utilizzando ciascun file audio come capitolo e il titolo del capitolo come nome del file audio", "MessageShareExpirationWillBe": "Scadrà tra {0}", From 28c2e62e61d50ea1586f9a780b1ef662599662a8 Mon Sep 17 00:00:00 2001 From: Simple16 Date: Thu, 29 May 2025 18:52:44 +0200 Subject: [PATCH 037/171] Translated using Weblate (Russian) Currently translated at 100.0% (1105 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ru/ --- client/strings/ru.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/client/strings/ru.json b/client/strings/ru.json index 1af0c642..07a786a1 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -212,9 +212,9 @@ "HeaderUsers": "Пользователи", "HeaderYearReview": "Итоги {0} года", "HeaderYourStats": "Ваша статистика", - "LabelAbridged": "Сокращенное издание", + "LabelAbridged": "Сокращенная форма", "LabelAbridgedChecked": "Сокращено (отмечено)", - "LabelAbridgedUnchecked": "Без сокращений (не отмечено)", + "LabelAbridgedUnchecked": "Полное издание (не отмечено)", "LabelAccessibleBy": "Доступ", "LabelAccountType": "Тип учетной записи", "LabelAccountTypeAdmin": "Администратор", @@ -346,8 +346,8 @@ "LabelExample": "Пример", "LabelExpandSeries": "Развернуть серию", "LabelExpandSubSeries": "Развернуть подсерию", - "LabelExplicit": "Явный", - "LabelExplicitChecked": "Явный (отмечено)", + "LabelExplicit": "18+", + "LabelExplicitChecked": "18+ (отмечено)", "LabelExplicitUnchecked": "Не явно (не отмечено)", "LabelExportOPML": "Экспорт OPML", "LabelFeedURL": "URL канала", @@ -856,6 +856,7 @@ "MessageScheduleRunEveryWeekdayAtTime": "Запуск каждые {0} по {1}", "MessageSearchResultsFor": "Результаты поиска для", "MessageSelected": "{0} выбрано", + "MessageSeriesSequenceCannotContainSpaces": "Последовательность серии должна быть без пропусков", "MessageServerCouldNotBeReached": "Не удалось связаться с сервером", "MessageSetChaptersFromTracksDescription": "Установка глав с использованием каждого аудиофайла в качестве главы и заголовка главы в качестве имени аудиофайла", "MessageShareExpirationWillBe": "Срок действия истекает {0}", From db66b9eaebadfd3ae36c1d72518a6cedb69c6e2a Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Thu, 29 May 2025 05:21:43 +0200 Subject: [PATCH 038/171] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1105 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 8715ac6e..0f9f4a99 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -21,7 +21,7 @@ "ButtonChooseFiles": "选择文件", "ButtonClearFilter": "清除过滤器", "ButtonCloseFeed": "关闭源", - "ButtonCloseSession": "关闭开放会话", + "ButtonCloseSession": "关闭活动会话", "ButtonCollections": "收藏", "ButtonConfigureScanner": "配置扫描", "ButtonCreate": "创建", From 1ad2e71fd5fd85dd56dbb77dd8d797d397b5200a Mon Sep 17 00:00:00 2001 From: kuci-JK Date: Sun, 1 Jun 2025 00:01:56 +0200 Subject: [PATCH 039/171] Translated using Weblate (Czech) Currently translated at 98.9% (1093 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/ --- client/strings/cs.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/strings/cs.json b/client/strings/cs.json index 5d44e98a..b17075b0 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -705,6 +705,7 @@ "LabelYourProgress": "Váš pokrok", "MessageAddToPlayerQueue": "Přidat do fronty přehrávače", "MessageAppriseDescription": "Abyste mohli používat tuto funkci, musíte mít spuštěnou instanci Apprise API nebo API, které bude zpracovávat stejné požadavky.
Adresa URL API Apprise by měla být úplná URL cesta pro odeslání oznámení, např. pokud je vaše instance API obsluhována na adrese http://192.168.1.1:8337 pak byste měli zadat http://192.168.1.1:8337/notify.", + "MessageAsinCheck": "Ujistěte se, že používáte ASIN ze správného regionu Audible a ne z Amazonu.", "MessageBackupsDescription": "Zálohy zahrnují uživatele, průběh uživatele, podrobnosti o položkách knihovny, nastavení serveru a obrázky uložené v /metadata/items a /metadata/authors. Zálohy ne zahrnují všechny soubory uložené ve složkách knihovny.", "MessageBackupsLocationEditNote": "Poznámka: Změna umístění záloh nepřesune ani nezmění existující zálohy", "MessageBackupsLocationNoEditNote": "Poznámka: Umístění záloh je nastavené z proměnných prostředí a nelze zde změnit.", @@ -723,6 +724,7 @@ "MessageChapterErrorStartGteDuration": "Neplatný čas začátku, musí být kratší než doba trvání audioknihy", "MessageChapterErrorStartLtPrev": "Neplatný čas začátku, musí být větší nebo roven času začátku předchozí kapitoly", "MessageChapterStartIsAfter": "Začátek kapitoly přesahuje konec audioknihy", + "MessageChaptersNotFound": "Kapitoly nenalezeny", "MessageCheckingCron": "Kontrola cronu...", "MessageConfirmCloseFeed": "Opravdu chcete zavřít tento kanál?", "MessageConfirmDeleteBackup": "Opravdu chcete smazat zálohu pro {0}?", @@ -779,6 +781,7 @@ "MessageForceReScanDescription": "znovu prohledá všechny soubory jako při novém skenování. ID3 tagy zvukových souborů OPF soubory a textové soubory budou skenovány jako nové.", "MessageImportantNotice": "Důležité upozornění!", "MessageInsertChapterBelow": "Vložit kapitolu níže", + "MessageInvalidAsin": "Neplatný ASIN", "MessageItemsSelected": "{0} vybraných položek", "MessageItemsUpdated": "{0} položky byly aktualizovány", "MessageJoinUsOn": "Přidejte se k nám", From 81572adab6b81b5564e0d39fa4286e568adf7b1d Mon Sep 17 00:00:00 2001 From: DR Date: Sat, 31 May 2025 16:26:04 +0200 Subject: [PATCH 040/171] Translated using Weblate (Hebrew) Currently translated at 76.4% (845 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/he/ --- client/strings/he.json | 166 +++++++++++++++++++++++++++++------------ 1 file changed, 118 insertions(+), 48 deletions(-) diff --git a/client/strings/he.json b/client/strings/he.json index 19da58e7..8b5bcdd0 100644 --- a/client/strings/he.json +++ b/client/strings/he.json @@ -10,6 +10,8 @@ "ButtonApplyChapters": "החל פרקים", "ButtonAuthors": "סופרים", "ButtonBack": "חזור", + "ButtonBatchEditPopulateFromExisting": "מלא משדות קיימים", + "ButtonBatchEditPopulateMapDetails": "מלא פרטי מפה", "ButtonBrowseForFolder": "עיין בתיקייה", "ButtonCancel": "ביטול", "ButtonCancelEncode": "בטל קידוד", @@ -29,7 +31,9 @@ "ButtonEdit": "ערוך", "ButtonEditChapters": "ערוך פרקים", "ButtonEditPodcast": "ערוך פודקאסט", - "ButtonEnable": "הפעל", + "ButtonEnable": "אפשר", + "ButtonFireAndFail": "שלח בכישלון", + "ButtonFireOnTest": "שלח באירוע בדיקה", "ButtonForceReScan": "סרוק מחדש בכוח", "ButtonFullPath": "נתיב מלא", "ButtonHide": "הסתר", @@ -37,7 +41,7 @@ "ButtonIssues": "תקלות", "ButtonJumpBackward": "דלג אחורה", "ButtonJumpForward": "דלג קדימה", - "ButtonLatest": "חדש ביותר", + "ButtonLatest": "אחרון", "ButtonLibrary": "ספרייה", "ButtonLogout": "התנתק", "ButtonLookup": "חפש", @@ -70,7 +74,7 @@ "ButtonReScan": "סרוק מחדש", "ButtonRead": "קרא", "ButtonReadLess": "קרא פחות", - "ButtonReadMore": "קרא יותר", + "ButtonReadMore": "קרא עוד", "ButtonRefresh": "רענן", "ButtonRemove": "הסר", "ButtonRemoveAll": "הסר הכל", @@ -86,7 +90,9 @@ "ButtonSaveTracklist": "שמור רשימת רצועות", "ButtonScan": "סרוק", "ButtonScanLibrary": "סרוק ספרייה", - "ButtonSearch": "חפש", + "ButtonScrollLeft": "גלול שמאלה", + "ButtonScrollRight": "גלול ימינה", + "ButtonSearch": "חיפוש", "ButtonSelectFolderPath": "בחר נתיב לתיקייה", "ButtonSeries": "סדרה", "ButtonSetChaptersFromTracks": "קבע פרקים לפי הרצועות", @@ -96,7 +102,7 @@ "ButtonStartM4BEncode": "התחל קידוד M4B", "ButtonStartMetadataEmbed": "התחל הטמעת מטא-נתונים", "ButtonStats": "סטטיסטיקות", - "ButtonSubmit": "שלח", + "ButtonSubmit": "שליחה", "ButtonTest": "בדיקה", "ButtonUnlinkOpenId": "נתק OpenID", "ButtonUpload": "העלה", @@ -122,26 +128,26 @@ "HeaderChapters": "פרקים", "HeaderChooseAFolder": "בחר תיקייה", "HeaderCollection": "אוסף", - "HeaderCollectionItems": "פריטי אוסף", + "HeaderCollectionItems": "פרטי אוסף", "HeaderCover": "כריכה", "HeaderCurrentDownloads": "הורדות נוכחיות", "HeaderCustomMessageOnLogin": "הודעה מותאמת אישית בהתחברות", "HeaderCustomMetadataProviders": "ספקי מטא-נתונים מותאמים אישית", "HeaderDetails": "פרטים", "HeaderDownloadQueue": "תור הורדה", - "HeaderEbookFiles": "קבצי ספר אלקטרוני", + "HeaderEbookFiles": "קבצי Ebook", "HeaderEmail": "אימייל", "HeaderEmailSettings": "הגדרות אימייל", "HeaderEpisodes": "פרקים", "HeaderEreaderDevices": "התקני קריאה דיגיטליים", - "HeaderEreaderSettings": "הגדרות התקני קריאה דיגיטליים", + "HeaderEreaderSettings": "הגדרות קורא אלקטרוני", "HeaderFiles": "קבצים", "HeaderFindChapters": "מצא פרקים", "HeaderIgnoredFiles": "קבצים שנתעלמו", "HeaderItemFiles": "קבצי פריט", "HeaderItemMetadataUtils": "כלי מטא-נתונים", "HeaderLastListeningSession": "הפעלת האזנה אחרונה", - "HeaderLatestEpisodes": "הפרקים העדכניים ביותר", + "HeaderLatestEpisodes": "פרקים אחרונים", "HeaderLibraries": "ספריות", "HeaderLibraryFiles": "קבצי ספרייה", "HeaderLibraryStats": "סטטיסטיקות ספרייה", @@ -171,8 +177,9 @@ "HeaderPlaylist": "רשימת השמעה", "HeaderPlaylistItems": "פריטי רשימת השמעה", "HeaderPodcastsToAdd": "פודקאסטים להוספה", + "HeaderPresets": "קביעות מוגדרות מראש", "HeaderPreviewCover": "תצוגה מקדימה של כריכה", - "HeaderRSSFeedGeneral": "פרטי ערוץ RSS", + "HeaderRSSFeedGeneral": "פרטי RSS", "HeaderRSSFeedIsOpen": "ערוץ RSS פתוח", "HeaderRSSFeeds": "ערוצי RSS", "HeaderRemoveEpisode": "הסר פרק", @@ -188,14 +195,15 @@ "HeaderSettingsExperimental": "תכונות ניסיוניות", "HeaderSettingsGeneral": "כללי", "HeaderSettingsScanner": "סורק", + "HeaderSettingsWebClient": "מערך", "HeaderSleepTimer": "טיימר שינה", "HeaderStatsLargestItems": "הפריטים הגדולים ביותר", "HeaderStatsLongestItems": "הפריטים הארוכים ביותר (בשעות)", - "HeaderStatsMinutesListeningChart": "דקות האזנה (בימים האחרונים)", - "HeaderStatsRecentSessions": "הפעלות אחרונות", + "HeaderStatsMinutesListeningChart": "דקות האזנה (7 ימים אחרונים)", + "HeaderStatsRecentSessions": "האזנות אחרונות", "HeaderStatsTop10Authors": "10 היוצרים המובילים", "HeaderStatsTop5Genres": "הז'אנרים המובילים 5", - "HeaderTableOfContents": "תוכן העניינים", + "HeaderTableOfContents": "תוכן עניינים", "HeaderTools": "כלים", "HeaderUpdateAccount": "עדכן חשבון", "HeaderUpdateAuthor": "עדכן יוצר", @@ -212,15 +220,17 @@ "LabelAccountTypeAdmin": "מנהל", "LabelAccountTypeGuest": "אורח", "LabelAccountTypeUser": "משתמש", + "LabelActivities": "פעילויות", "LabelActivity": "פעילות", "LabelAddToCollection": "הוסף לאוסף", "LabelAddToCollectionBatch": "הוסף {0} ספרים לאוסף", "LabelAddToPlaylist": "הוסף לרשימת השמעה", "LabelAddToPlaylistBatch": "הוסף {0} פריטים לרשימת השמעה", - "LabelAddedAt": "נוסף בתאריך", + "LabelAddedAt": "נוסף ב-", "LabelAddedDate": "נוסף ב-{0}", "LabelAdminUsersOnly": "רק מנהלים", "LabelAll": "הכל", + "LabelAllEpisodesDownloaded": "כל הפרקים הורדו", "LabelAllUsers": "כל המשתמשים", "LabelAllUsersExcludingGuests": "כל המשתמשים, ללא אורחים", "LabelAllUsersIncludingGuests": "כל המשתמשים כולל אורחים", @@ -230,10 +240,10 @@ "LabelAudioBitrate": "קצב סיביות (לדוגמא 128k)", "LabelAudioChannels": "ערוצי קול (1 או 2)", "LabelAudioCodec": "קידוד קול", - "LabelAuthor": "יוצר", - "LabelAuthorFirstLast": "יוצר (שם פרטי שם משפחה)", - "LabelAuthorLastFirst": "יוצר (שם משפחה, שם פרטי)", - "LabelAuthors": "יוצרים", + "LabelAuthor": "סופר", + "LabelAuthorFirstLast": "סופר (שם, משפחה)", + "LabelAuthorLastFirst": "סופר (משפחה, שם)", + "LabelAuthors": "סופרים", "LabelAutoDownloadEpisodes": "הורד פרקים באופן אוטומטי", "LabelAutoFetchMetadata": "חפש והורד מטא-נתונים באופן אוטומטי", "LabelAutoFetchMetadataHelp": "מחפש ומוריד מטא-נתונים לשדות כותרת, יוצר וסדרה כדי לשפר את תהליך ההעלאה. ייתכן שיהיה צורך להתאים מטא-נתונים נוסף לאחר ההעלאה.", @@ -242,36 +252,48 @@ "LabelAutoRegister": "הרשמה אוטומטית", "LabelAutoRegisterDescription": "יצירת משתמשים חדשים אוטומטית לאחר התחברות", "LabelBackToUser": "חזרה למשתמש", + "LabelBackupAudioFiles": "גיבוי קבצי שמע", "LabelBackupLocation": "מיקום גיבוי", - "LabelBackupsEnableAutomaticBackups": "הפעל גיבויים אוטומטיים", + "LabelBackupsEnableAutomaticBackups": "גיבויים אוטומטיים", "LabelBackupsEnableAutomaticBackupsHelp": "גיבויים שמורים ב /metadata/backups", - "LabelBackupsMaxBackupSize": "גודל הגיבוי המרבי (בג'יגה-בייט)", + "LabelBackupsMaxBackupSize": "גודל הגיבוי המרבי (בג'יגה-בייט) (0 - ללא הגבלה)", "LabelBackupsMaxBackupSizeHelp": "כהגנה על עצמך מפני תצורה שגויה, הגיבויים ייכשלו אם הם יעברו את הגודל שהוגדר.", "LabelBackupsNumberToKeep": "מספר הגיבויים לשמירה", "LabelBackupsNumberToKeepHelp": "רק גיבוי אחד יוסר בכל פעם, לכן אם יש לך כבר יותר מגיבוי אחד יש להסיר אותם באופן ידני.", "LabelBitrate": "קצב סיביות", + "LabelBonus": "בונוס", "LabelBooks": "ספרים", "LabelButtonText": "טקסט לחצן", + "LabelByAuthor": "על ידי {0}", "LabelChangePassword": "שינוי סיסמה", "LabelChannels": "ערוצים", + "LabelChapterCount": "{0} פרקים", "LabelChapterTitle": "כותרת הפרק", "LabelChapters": "פרקים", "LabelChaptersFound": "פרקים שנמצאו", "LabelClickForMoreInfo": "לחץ למידע נוסף", + "LabelClickToUseCurrentValue": "לחץ לשימוש בערך הנוכחי", "LabelClosePlayer": "סגור נגן", - "LabelCollapseSeries": "צמצום סדרה", + "LabelCodec": "Coded", + "LabelCollapseSeries": "הסתר סדרה", + "LabelCollapseSubSeries": "הסתר תת סדרה", "LabelCollection": "אוסף", "LabelCollections": "אוספים", - "LabelComplete": "מלא", + "LabelComplete": "הושלם", "LabelConfirmPassword": "אישור סיסמה", "LabelContinueListening": "המשך האזנה", "LabelContinueReading": "המשך קריאה", "LabelContinueSeries": "המשך סדרה", "LabelCover": "כריכה", "LabelCoverImageURL": "כתובת התמונה ברשת", + "LabelCoverProvider": "ספק כריכה", "LabelCreatedAt": "נוצר בתאריך", + "LabelCronExpression": "ביטוי cron", "LabelCurrent": "נוכחי", "LabelCurrently": "כעת:", + "LabelCustomCronExpression": "ביטוי cron מותאם אישית:", + "LabelDatetime": "Datetime", + "LabelDays": "ימים", "LabelDeleteFromFileSystemCheckbox": "מחיקה מהמערכת הקבצים (הסר סימון למחיקה רק ממסד הנתונים)", "LabelDescription": "תיאור", "LabelDeselectAll": "הסר בחירת כל הפריטים", @@ -282,51 +304,83 @@ "LabelDiscFromFilename": "דיסק משם הקובץ", "LabelDiscFromMetadata": "דיסק מהמטא-נתונים", "LabelDiscover": "גלה", - "LabelDownload": "הורד", + "LabelDownload": "הורדה", "LabelDownloadNEpisodes": "הורד {0} פרקים", + "LabelDownloadable": "ניתן להורדה", "LabelDuration": "משך", + "LabelDurationComparisonExactMatch": "(התאמה מדוייקת)", + "LabelDurationComparisonLonger": "({0} ארוך יותר)", + "LabelDurationComparisonShorter": "({0} קצר יותר)", "LabelDurationFound": "משך נמצא:", "LabelEbook": "ספר אלקטרוני", "LabelEbooks": "ספרים אלקטרוניים", "LabelEdit": "עריכה", "LabelEmail": "דואר אלקטרוני", "LabelEmailSettingsFromAddress": "מאת", + "LabelEmailSettingsRejectUnauthorized": "דחה תעודות לא מאושרות", + "LabelEmailSettingsRejectUnauthorizedHelp": "השבתת אימות תעודת SSL עלולה לחשוף את החיבור שלך לסיכוני אבטחה, כגון התקפות \"אדם באמצע\". השבת אפשרות זו רק אם אתה מבין את ההשלכות ובוטח בשרת הדואר שאליו אתה מתחבר.", "LabelEmailSettingsSecure": "מאובטח", "LabelEmailSettingsSecureHelp": "אם מופעל, החיבור ישתמש ב-TLS בעת ההתחברות לשרת. אם לא, אז TLS יהיה בשימוש אם השרת תומך בהרחבת STARTTLS. ברוב המקרים מומלץ להפעיל את הגדרה זו אם אתה מתחבר לפורט 465. לפורט 587 או 25, השאר כבוי. (from nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "כתובת לבדיקה", "LabelEmbeddedCover": "כריכה מוטמעת", - "LabelEnable": "הפעל", - "LabelEnd": "סיום", + "LabelEnable": "אפשר", + "LabelEncodingBackupLocation": "גיבוי של קבצי אודיו מקוריים יישמר ב:", + "LabelEncodingChaptersNotEmbedded": "פרקים אינם מוטבעים בספרי אודיו מרובי רצועות.", + "LabelEncodingClearItemCache": "הקפד לנקות מטמון פריטים מעת לעת.", + "LabelEncodingFinishedM4B": "קובץ M4B סופי יישמר בתיקייה ה-audiobook ב:", + "LabelEncodingInfoEmbedded": "מטה דאטה יוטמע ברצועות השמע בתוך תיקיית ה-audiobook.", + "LabelEncodingStartedNavigation": "לאחר שהמשימה תתחיל אפשר לנווט לדף אחר.", + "LabelEncodingTimeWarning": "קידוד יכול להימשך עד 30 דקות.", + "LabelEncodingWarningAdvancedSettings": "אזהרה: אל תעדכן את ההגדרות האלה אלא אם כן אתה מכיר את אפשרויות קידוד ffmpeg.", + "LabelEncodingWatcherDisabled": "אם ה-watcher כבוי, יש לסרוק את הספר מחדש לאחר מכן.", + "LabelEnd": "סוף", + "LabelEndOfChapter": "סוף הפרק", "LabelEpisode": "פרק", + "LabelEpisodeNotLinkedToRssFeed": "פרק לא מקושר לערוץ RSS", + "LabelEpisodeNumber": "פרק #{0}", "LabelEpisodeTitle": "כותרת הפרק", "LabelEpisodeType": "סוג הפרק", + "LabelEpisodeUrlFromRssFeed": "קישור פרק מערוץ RSS", + "LabelEpisodes": "פרקים", + "LabelEpisodic": "ארעי", "LabelExample": "דוגמה", + "LabelExpandSeries": "הרחב סדרה", + "LabelExpandSubSeries": "הרחב תת סדרה", "LabelExplicit": "בוטה", + "LabelExplicitChecked": "בוטה (מסומן)", + "LabelExplicitUnchecked": "לא בוטה (לא מסומן)", + "LabelExportOPML": "ייצוא OPML", "LabelFeedURL": "כתובת ערוץ", "LabelFetchingMetadata": "מושך מטא-נתונים", "LabelFile": "קובץ", "LabelFileBirthtime": "זמן יצירת הקובץ", - "LabelFileModified": "הקובץ שונה", - "LabelFilename": "שם הקובץ", + "LabelFileBornDate": "נוצר {0}", + "LabelFileModified": "קובץ נערך", + "LabelFileModifiedDate": "שונה {0}", + "LabelFilename": "שם קובץ", "LabelFilterByUser": "סינון לפי משתמש", "LabelFindEpisodes": "מצא פרקים", "LabelFinished": "הושלם", "LabelFolder": "תיקייה", "LabelFolders": "תיקיות", "LabelFontBold": "מודגש", + "LabelFontBoldness": "עובי פונט", "LabelFontFamily": "משפחת הפונטים", "LabelFontItalic": "נטוי", - "LabelFontScale": "קנה מידה של הפונט", + "LabelFontScale": "גודל פונט", "LabelFontStrikethrough": "קו חוצה", "LabelFormat": "תבנית", - "LabelGenre": "ז'אנר", - "LabelGenres": "ז'אנרים", + "LabelFull": "מלא", + "LabelGenre": "סגנון", + "LabelGenres": "סגנונות", "LabelHardDeleteFile": "מחיקה חזקה של הקובץ", - "LabelHasEbook": "ספר אלקטרוני קיים", - "LabelHasSupplementaryEbook": "קיים ספר אלקטרוני נלווה", + "LabelHasEbook": "קיים ספר אלקטרוני", + "LabelHasSupplementaryEbook": "קיים ספר אלקטרוני משלים", + "LabelHideSubtitles": "הסתר תת כותרות", "LabelHighestPriority": "העדיפות הגבוהה ביותר", "LabelHost": "מארח", "LabelHour": "שעה", + "LabelHours": "שעות", "LabelIcon": "סמל", "LabelImageURLFromTheWeb": "כתובת התמונה מהרשת", "LabelInProgress": "בתהליך", @@ -341,25 +395,30 @@ "LabelIntervalEvery6Hours": "כל 6 שעות", "LabelIntervalEveryDay": "כל יום", "LabelIntervalEveryHour": "כל שעה", + "LabelIntervalEveryMinute": "כל דקה", "LabelInvert": "הפוך", "LabelItem": "פריט", + "LabelJumpBackwardAmount": "כמות הרצה לאחור", + "LabelJumpForwardAmount": "כמות הרצה קדימה", "LabelLanguage": "שפה", "LabelLanguageDefaultServer": "שפת ברירת המחדל של השרת", + "LabelLanguages": "שפות", "LabelLastBookAdded": "הספר האחרון שנוסף", "LabelLastBookUpdated": "הספר האחרון שעודכן", "LabelLastSeen": "נראה לאחרונה", "LabelLastTime": "הזמן האחרון", "LabelLastUpdate": "עדכון אחרון", - "LabelLayout": "פריסה", - "LabelLayoutSinglePage": "דף בודד", + "LabelLayout": "Layout", + "LabelLayoutSinglePage": "עמוד יחיד", "LabelLayoutSplitPage": "פיצול הדף", "LabelLess": "פחות", "LabelLibrariesAccessibleToUser": "ספריות נגישות למשתמש", "LabelLibrary": "ספרייה", + "LabelLibraryFilterSublistEmpty": "לא {0}", "LabelLibraryItem": "פריט ספרייה", "LabelLibraryName": "שם הספרייה", "LabelLimit": "מגבלה", - "LabelLineSpacing": "ריווח שורות", + "LabelLineSpacing": "מרווח שורה", "LabelListenAgain": "האזן שוב", "LabelLogLevelDebug": "דיבוג", "LabelLogLevelInfo": "מידע", @@ -368,6 +427,10 @@ "LabelLowestPriority": "העדיפות הנמוכה ביותר", "LabelMatchExistingUsersBy": "התאם משתמשים קיימים לפי", "LabelMatchExistingUsersByDescription": "משמש לחיבור משתמשים קיימים. לאחר החיבור, המשתמשים יותאמו לפי זיהוי ייחודי מספק ה-SSO שלך", + "LabelMaxEpisodesToDownload": "מספר פרקים מקסימלי להורדה. 0 - ללא הגבלה.", + "LabelMaxEpisodesToDownloadPerCheck": "מספר פרקים חדשים מקסימלי להורדה בכל בדיקה", + "LabelMaxEpisodesToKeep": "מספר פרקים מקסימלי לשמור", + "LabelMaxEpisodesToKeepHelp": "ערך של 0 קובע ללא מגבלה. לאחר הורדה אוטומטית של פרק חדש יימחק את הפרק הישן ביותר אם יש לך יותר מ-X פרקים. פעולה זו תמחק רק פרק אחד לכל הורדה חדשה.", "LabelMediaPlayer": "נגן מדיה", "LabelMediaType": "סוג מדיה", "LabelMetaTag": "תג מטא", @@ -375,6 +438,7 @@ "LabelMetadataOrderOfPrecedenceDescription": "מקורות המטא-נתונים עם עדיפות גבוהה יחליפו מקורות עם עדיפות נמוכה יותר", "LabelMetadataProvider": "ספק מטא-נתונים", "LabelMinute": "דקה", + "LabelMinutes": "דקות", "LabelMissing": "חסר", "LabelMissingEbook": "אין ספר אלקטרוני", "LabelMissingSupplementaryEbook": "אין ספר אלקטרוני נלווה", @@ -387,10 +451,11 @@ "LabelNarrators": "מספרים", "LabelNew": "חדש", "LabelNewPassword": "סיסמה חדשה", - "LabelNewestAuthors": "הסופרים החדשים ביותר", + "LabelNewestAuthors": "הסופרים האחרונים", "LabelNewestEpisodes": "הפרקים החדשים ביותר", "LabelNextBackupDate": "תאריך הגיבוי הבא", "LabelNextScheduledRun": "הרצה מתוזמנת הבאה", + "LabelNoCustomMetadataProviders": "אין ספקי מטא-נתונים מותאמים אישית", "LabelNoEpisodesSelected": "לא נבחרו פרקים", "LabelNotFinished": "לא הושלם", "LabelNotStarted": "לא התחיל", @@ -405,7 +470,9 @@ "LabelNotificationsMaxQueueSize": "גודל התור המרבי לאירועי התראה", "LabelNotificationsMaxQueueSizeHelp": "האירועים מוגבלים לשליחה אחת לשנייה. האירועים יתעלמו אם התור מלא. הגדרה זו נועדה למנוע ספאם התראות.", "LabelNumberOfBooks": "מספר הספרים", - "LabelNumberOfEpisodes": "מספר הפרקים", + "LabelNumberOfEpisodes": "# פרקים", + "LabelOpenIDAdvancedPermsClaimDescription": "שם OpenID claim המכילה הרשאות מתקדמות לפעולות משתמש בתוך האפליקציה, אשר יחולו על תפקידים שאינם מנהלי מערכת (אם הוגדרה). אם התביעה חסרה בתגובה, הגישה ל-ABS תידחה. אם אפשרות אחת חסרה, היא תטופל כ-false יש לוודא שטענת ספק הזהויות תואמת את המבנה הצפוי:", + "LabelOpenIDClaims": "השאר את האפשרויות הבאות ריקות כדי להשבית הקצאת קבוצות והרשאות מתקדמת, ולאחר מכן להקצות אוטומטית את קבוצת 'משתמש'.", "LabelOpenRSSFeed": "פתח ערוץ RSS", "LabelOverwrite": "לשכפל", "LabelPassword": "סיסמה", @@ -433,13 +500,15 @@ "LabelProvider": "ספק", "LabelPubDate": "תאריך פרסום", "LabelPublishYear": "שנת הפרסום", + "LabelPublishedDate": "פורסם {0}", "LabelPublisher": "מוציא לאור", "LabelRSSFeedCustomOwnerEmail": "אימייל בעלים מותאם אישית", "LabelRSSFeedCustomOwnerName": "שם בעלים מותאם אישית", - "LabelRSSFeedOpen": "פתח ערוץ RSS", + "LabelRSSFeedOpen": "ערוץ RSS פתוח", "LabelRSSFeedPreventIndexing": "מנע רישום", "LabelRSSFeedSlug": "Slug של ערוץ ה-RSS", "LabelRSSFeedURL": "כתובת ערוץ ה-RSS", + "LabelRandomly": "באופן אקראי", "LabelRead": "קריאה", "LabelReadAgain": "קרא שוב", "LabelReadEbookWithoutProgress": "קרא/י ספר אלקטרוני ללא שמירת התקדמות", @@ -465,7 +534,7 @@ "LabelSeriesProgress": "התקדמות בסדרה", "LabelServerYearReview": "השנה בסקירה של השרת ({0})", "LabelSetEbookAsPrimary": "קבע כראשי", - "LabelSetEbookAsSupplementary": "קבע כספר אלקטרוני נלווה", + "LabelSetEbookAsSupplementary": "קבע כמשלים", "LabelSettingsAudiobooksOnly": "רק ספרי קול", "LabelSettingsAudiobooksOnlyHelp": "הפעלת ההגדרה הזו תתעלם מקבצי ספרים אלקטרוניים אלא אם כן הם נמצאים בתיקיית ספרי קול, שבמקרה זה יקבעו כספרים אלקטרוניים נלווים", "LabelSettingsBookshelfViewHelp": "עיצוב סקאומורפי עם מדפי עץ", @@ -500,7 +569,7 @@ "LabelShowAll": "הצג הכל", "LabelSize": "גודל", "LabelSleepTimer": "טיימר שינה", - "LabelStart": "התחלה", + "LabelStart": "התחל", "LabelStartTime": "זמן התחלה", "LabelStarted": "התחיל", "LabelStartedAt": "התחיל ב", @@ -576,8 +645,8 @@ "LabelViewQueue": "הצג תור נגן", "LabelVolume": "עוצמת קול", "LabelWeekdaysToRun": "ימי השבוע להרצה", - "LabelYearReviewHide": "הסתר שנת סקירה", - "LabelYearReviewShow": "הצג שנת סקירה", + "LabelYearReviewHide": "הסתר סקירת שנה", + "LabelYearReviewShow": "הצג סקירת שנה", "LabelYourAudiobookDuration": "משך הספר הקולי שלך", "LabelYourBookmarks": "הסימניות שלך", "LabelYourPlaylists": "הפלייליסטים שלך", @@ -628,8 +697,8 @@ "MessageDownloadingEpisode": "מוריד פרק", "MessageDragFilesIntoTrackOrder": "גרור קבצים לסדר ההשמעה נכון", "MessageEmbedFinished": "ההטמעה הושלמה!", - "MessageEpisodesQueuedForDownload": "{0} פרקים בתור להורדה", - "MessageFeedURLWillBe": "כתובת URL של העדכון תהיה {0}", + "MessageEpisodesQueuedForDownload": "{0} פרק/ים בתור להורדה", + "MessageFeedURLWillBe": "כתובת ה- URL של הערוץ תהיה {0}", "MessageFetching": "מושך...", "MessageForceReScanDescription": "תבוצע סריקה מחדש כמו סריקה חדש מאפס, תגי ID3 של קבצי קול, קבצי OPF, וקבצי טקסט ייסרקו כחדשים.", "MessageImportantNotice": "הודעה חשובה!", @@ -644,7 +713,7 @@ "MessageMapChapterTitles": "מפה שמות פרקים לפרקי הספר השמורים שלך ללא שינוי תגי זמן", "MessageMarkAllEpisodesFinished": "סמן את כל הפרקים כהסתיימו", "MessageMarkAllEpisodesNotFinished": "סמן את כל הפרקים כלא הסתיימו", - "MessageMarkAsFinished": "סמן כהסתיים", + "MessageMarkAsFinished": "סמן כהושלם", "MessageMarkAsNotFinished": "סמן כלא הסתיים", "MessageMatchBooksDescription": "ינסה להתאים ספרים בספריית הספרים שלך עם ספר מספק החיפוש הנבחר וימלא פרטים ריקים ותמונות כריכה. לא יחליף פרטים קיימים.", "MessageNoAudioTracks": "אין רצועות שמע", @@ -674,7 +743,7 @@ "MessageNoSeries": "אין סדרות", "MessageNoTags": "אין תגיות", "MessageNoTasksRunning": "אין משימות פעילות", - "MessageNoUpdatesWereNecessary": "לא היה צורך בעדכונים", + "MessageNoUpdatesWereNecessary": "לא נדרש עדכון", "MessageNoUserPlaylists": "אין לך רשימות השמעה", "MessageNotYetImplemented": "עדיין לא מיושם", "MessageOr": "או", @@ -682,6 +751,7 @@ "MessagePlayChapter": "הקשב לתחילת הפרק", "MessagePlaylistCreateFromCollection": "צור רשימת השמעה מאוסף", "MessagePodcastHasNoRSSFeedForMatching": "לפודקאסט אין כתובת URL של ערוץ RSS להתאמה", + "MessagePodcastSearchField": "הזן מונח חיפוש או כתובת URL של ערוץ RSS", "MessageQuickMatchDescription": "ממלא פרטים ריקים וכריכות עם התוצאה הראשונה מ '{0}'. לא ימחק פרטים אלא אם הגדרת השרת 'העדף מטה-נתונים מותאמים' מופעלת.", "MessageRemoveChapter": "הסר פרק", "MessageRemoveEpisodes": "הסר {0} פרקים", @@ -708,7 +778,7 @@ "NoteChangeRootPassword": "המשתמש root הוא המשתמש היחיד שיכולה להיות לו סיסמה ריקה", "NoteChapterEditorTimes": "הערה: זמן ההתחלה של הפרק הראשון חייב להישאר 0:00 וזמן ההתחלה של הפרק האחרון לא יכול לחרוג מהזמן של ספר השמע.", "NoteFolderPicker": "הערה: תיקיות שכבר מופו לא יוצגו", - "NoteRSSFeedPodcastAppsHttps": "אזהרה: רוב יישומי הפודקאסט דורשים שכתובת ה-URL ערוץ ה-RSS תשתמש ב-HTTPS", + "NoteRSSFeedPodcastAppsHttps": "אזהרה: רוב אפליקציות הפודקאסטים ידרשו שכתובת האתר של ערוץ ה-RSS תשתמש ב-HTTPS", "NoteRSSFeedPodcastAppsPubDate": "אזהרה: פרק אחד או יותר לא מכילים תאריך פרסום. חלק מיישומי הפודקאסט דורשים זאת.", "NoteUploaderFoldersWithMediaFiles": "תיקיות עם קבצי מדיה יעובדו כפריטי ספריה נפרדים.", "NoteUploaderOnlyAudioFiles": "אם מועלים רק קבצי שמע, כל קובץ שמע יעובד כספר שמע נפרד.", @@ -741,7 +811,7 @@ "ToastCollectionUpdateSuccess": "האוסף עודכן בהצלחה", "ToastItemCoverUpdateSuccess": "כריכת הפריט עודכנה בהצלחה", "ToastItemDetailsUpdateSuccess": "פרטי הפריט עודכנו בהצלחה", - "ToastItemMarkedAsFinishedFailed": "סימון כפריט כהושלם נכשל", + "ToastItemMarkedAsFinishedFailed": "סימון כפריט שהושלם נכשל", "ToastItemMarkedAsFinishedSuccess": "הפריט סומן כהושלם בהצלחה", "ToastItemMarkedAsNotFinishedFailed": "סימון כפריט שלא הושלם נכשל", "ToastItemMarkedAsNotFinishedSuccess": "הפריט סומן כלא הושלם בהצלחה", From 32fc4f65552f2d7907280d69352570ed543b0bab Mon Sep 17 00:00:00 2001 From: Charlie Date: Sun, 1 Jun 2025 14:45:53 +0200 Subject: [PATCH 041/171] Translated using Weblate (French) Currently translated at 99.9% (1104 of 1105 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/fr/ --- client/strings/fr.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/client/strings/fr.json b/client/strings/fr.json index c6a28ddd..03a0cdee 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -530,6 +530,7 @@ "LabelReleaseDate": "Date de parution", "LabelRemoveAllMetadataAbs": "Supprimer tous les fichiers metadata.abs", "LabelRemoveAllMetadataJson": "Supprimer tous les fichiers metadata.json", + "LabelRemoveAudibleBranding": "Supprimer l’intro et la fin Audible des chapitres", "LabelRemoveCover": "Supprimer la couverture", "LabelRemoveMetadataFile": "Supprimer les fichiers de métadonnées dans les dossiers des éléments de la bibliothèque", "LabelRemoveMetadataFileHelp": "Supprimer tous les fichiers metadata.json et metadata.abs de vos dossiers {0}.", @@ -705,6 +706,8 @@ "LabelYourProgress": "Votre progression", "MessageAddToPlayerQueue": "Ajouter en file d’attente", "MessageAppriseDescription": "Nécessite une instance d’API Apprise pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes.
L’URL de l’API Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur http://192.168.1.1:8337 alors vous devez mettre http://192.168.1.1:8337/notify.", + "MessageAsinCheck": "Assurez-vous d’utiliser l’ASIN de la bonne région Audible, et non d’Amazon.", + "MessageAuthenticationOIDCChangesRestart": "Redémarrez votre serveur après avoir enregistré pour appliquer les modifications OIDC.", "MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression des utilisateurs, les détails des éléments de la bibliothèque, les paramètres du serveur et les images stockées dans /metadata/items & /metadata/authors. Les sauvegardes n’incluent pas les fichiers stockés dans les dossiers de votre bibliothèque.", "MessageBackupsLocationEditNote": "Remarque : Mettre à jour l'emplacement de sauvegarde ne déplacera pas ou ne modifiera pas les sauvegardes existantes", "MessageBackupsLocationNoEditNote": "Remarque : l’emplacement de sauvegarde est défini via une variable d’environnement et ne peut pas être modifié ici.", @@ -723,6 +726,7 @@ "MessageChapterErrorStartGteDuration": "Horodatage invalide car il doit débuter avant la fin du livre", "MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre", "MessageChapterStartIsAfter": "Le premier chapitre est situé au début de votre livre audio", + "MessageChaptersNotFound": "Chapitres non trouvés", "MessageCheckingCron": "Vérification du cron…", "MessageConfirmCloseFeed": "Êtes-vous sûr·e de vouloir fermer ce flux ?", "MessageConfirmDeleteBackup": "Êtes-vous sûr·e de vouloir supprimer la sauvegarde de « {0} » ?", @@ -779,6 +783,7 @@ "MessageForceReScanDescription": "analysera de nouveau tous les fichiers. Les étiquettes ID3 des fichiers audio, les fichiers OPF et les fichiers texte seront analysés comme s’ils étaient nouveaux.", "MessageImportantNotice": "Information importante !", "MessageInsertChapterBelow": "Insérer le chapitre ci-dessous", + "MessageInvalidAsin": "ASIN invalide", "MessageItemsSelected": "{0} éléments sélectionnés", "MessageItemsUpdated": "{0} éléments mis à jour", "MessageJoinUsOn": "Rejoignez-nous sur", @@ -850,6 +855,7 @@ "MessageScheduleRunEveryWeekdayAtTime": "Exécuté tous les {0} à {1}", "MessageSearchResultsFor": "Résultats de recherche pour", "MessageSelected": "{0} sélectionnés", + "MessageSeriesSequenceCannotContainSpaces": "La séquence de séries ne peut pas contenir d’espaces", "MessageServerCouldNotBeReached": "Serveur inaccessible", "MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre", "MessageShareExpirationWillBe": "Expire le {0}", @@ -968,6 +974,8 @@ "ToastCachePurgeFailed": "Échec de la purge du cache", "ToastCachePurgeSuccess": "Cache purgé avec succès", "ToastChaptersHaveErrors": "Les chapitres contiennent des erreurs", + "ToastChaptersInvalidShiftAmountLast": "Durée de décalage non valide. L’heure de début du dernier chapitre pourrait dépasser la durée de ce livre audio.", + "ToastChaptersInvalidShiftAmountStart": "Durée de décalage non valide. Le premier chapitre aurait une longueur nulle ou négative et serait écrasé par le second. Augmentez la durée de début du second chapitre.", "ToastChaptersMustHaveTitles": "Les chapitre doivent avoir un titre", "ToastChaptersRemoved": "Chapitres supprimés", "ToastChaptersUpdated": "Chapitres mis à jour", From c377b57601f82f76d677b09e6bbabda732c18861 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 1 Jun 2025 16:00:16 -0500 Subject: [PATCH 042/171] Version bump v2.24.0 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 56a98514..23ec14a9 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.23.0", + "version": "2.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.23.0", + "version": "2.24.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index a2bd7e62..7985cd78 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.23.0", + "version": "2.24.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 91a5f283..a8074794 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.23.0", + "version": "2.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.23.0", + "version": "2.24.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 9b08daee..d4831736 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.23.0", + "version": "2.24.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 9968743a9349c28bd5e22268e943757d0b60567a Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Mon, 2 Jun 2025 19:32:52 +0200 Subject: [PATCH 043/171] fix wrong display and ignored values --- client/components/widgets/EncoderOptionsCard.vue | 16 ++++++++++++---- client/pages/audiobook/_id/manage.vue | 2 ++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/client/components/widgets/EncoderOptionsCard.vue b/client/components/widgets/EncoderOptionsCard.vue index 005563b4..977d766b 100644 --- a/client/components/widgets/EncoderOptionsCard.vue +++ b/client/components/widgets/EncoderOptionsCard.vue @@ -143,10 +143,18 @@ export default { localStorage.setItem('embedMetadataCodec', val) }, getEncodingOptions() { - return { - codec: this.selectedCodec || 'aac', - bitrate: this.selectedBitrate || '128k', - channels: this.selectedChannels || 2 + if (this.showAdvancedView) { + return { + codec: this.customCodec || this.selectedCodec || 'aac', + bitrate: this.customBitrate || this.selectedBitrate || '128k', + channels: this.customChannels || this.selectedChannels || 2 + } + } else { + return { + codec: this.selectedCodec || 'aac', + bitrate: this.selectedBitrate || '128k', + channels: this.selectedChannels || 2 + } } }, setPreset() { diff --git a/client/pages/audiobook/_id/manage.vue b/client/pages/audiobook/_id/manage.vue index 7afe12a9..f5db9cce 100644 --- a/client/pages/audiobook/_id/manage.vue +++ b/client/pages/audiobook/_id/manage.vue @@ -356,6 +356,8 @@ export default { const encodeOptions = this.$refs.encoderOptionsCard.getEncodingOptions() + this.encodingOptions = encodeOptions; + const queryParams = new URLSearchParams(encodeOptions) this.processing = true From b6995ba5d1573c7186b01b63d1443cc20d20503b Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Mon, 2 Jun 2025 19:33:50 +0200 Subject: [PATCH 044/171] prettier --- client/pages/audiobook/_id/manage.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/pages/audiobook/_id/manage.vue b/client/pages/audiobook/_id/manage.vue index f5db9cce..37734934 100644 --- a/client/pages/audiobook/_id/manage.vue +++ b/client/pages/audiobook/_id/manage.vue @@ -356,7 +356,7 @@ export default { const encodeOptions = this.$refs.encoderOptionsCard.getEncodingOptions() - this.encodingOptions = encodeOptions; + this.encodingOptions = encodeOptions const queryParams = new URLSearchParams(encodeOptions) From 424ef1aec31a0b53997363198a5cd15eb598577d Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Mon, 2 Jun 2025 19:34:25 +0200 Subject: [PATCH 045/171] prettier 2 --- client/components/widgets/LoadingSpinner.vue | 2 +- client/components/widgets/SeriesInputWidget.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/components/widgets/LoadingSpinner.vue b/client/components/widgets/LoadingSpinner.vue index a9c4ef47..8f3de84a 100644 --- a/client/components/widgets/LoadingSpinner.vue +++ b/client/components/widgets/LoadingSpinner.vue @@ -248,4 +248,4 @@ export default { transform: scale(0); } } - \ No newline at end of file + diff --git a/client/components/widgets/SeriesInputWidget.vue b/client/components/widgets/SeriesInputWidget.vue index d6c8cf9f..3dab0605 100644 --- a/client/components/widgets/SeriesInputWidget.vue +++ b/client/components/widgets/SeriesInputWidget.vue @@ -109,4 +109,4 @@ export default { } } } - \ No newline at end of file + From 63ccdb68f0c61a1a28e2bbedb6b49b94490f9cd3 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 2 Jun 2025 16:50:03 -0500 Subject: [PATCH 046/171] Fix m4b encoder backup file overwriting the encoded file when they have the same filename --- server/managers/AbMergeManager.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/managers/AbMergeManager.js b/server/managers/AbMergeManager.js index f6a56160..3611d294 100644 --- a/server/managers/AbMergeManager.js +++ b/server/managers/AbMergeManager.js @@ -203,7 +203,15 @@ class AbMergeManager { // Move library item tracks to cache for (const [index, trackPath] of task.data.originalTrackPaths.entries()) { const trackFilename = Path.basename(trackPath) - const moveToPath = Path.join(task.data.itemCachePath, trackFilename) + let moveToPath = Path.join(task.data.itemCachePath, trackFilename) + + // If the track is the same as the temp file, we need to rename it to avoid overwriting it + if (task.data.tempFilepath === moveToPath) { + const trackExtname = Path.extname(task.data.tempFilepath) + const newTrackFilename = Path.basename(task.data.tempFilepath, trackExtname) + '.backup' + trackExtname + moveToPath = Path.join(task.data.itemCachePath, newTrackFilename) + } + Logger.debug(`[AbMergeManager] Backing up original track "${trackPath}" to ${moveToPath}`) if (index === 0) { // copy the first track to the cache directory From 4d846e225a40ebea605edf88c2ae973095646f3b Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Wed, 4 Jun 2025 10:02:17 +0200 Subject: [PATCH 047/171] Adds ENV for MaxFailedEpisodeChecks --- server/managers/PodcastManager.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 052ba8b3..c2fae077 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -30,7 +30,7 @@ class PodcastManager { this.currentDownload = null this.failedCheckMap = {} - this.MaxFailedEpisodeChecks = 24 + this.MaxFailedEpisodeChecks = process.env.MAX_FAILED_EPISODE_CHECKS || 24 } getEpisodeDownloadsInQueue(libraryItemId) { @@ -345,7 +345,7 @@ class PodcastManager { // Allow up to MaxFailedEpisodeChecks failed attempts before disabling auto download if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0 this.failedCheckMap[libraryItem.id]++ - if (this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) { + if (this.MaxFailedEpisodeChecks != 0 && this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) { Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}" - disabling auto download`) libraryItem.media.autoDownloadEpisodes = false delete this.failedCheckMap[libraryItem.id] From 709c33f27af56a5790bdc5f8312c00b2f8f01eab Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Wed, 4 Jun 2025 10:05:16 +0200 Subject: [PATCH 048/171] ensure proper type --- server/managers/PodcastManager.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index c2fae077..54ce4c9f 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -30,7 +30,7 @@ class PodcastManager { this.currentDownload = null this.failedCheckMap = {} - this.MaxFailedEpisodeChecks = process.env.MAX_FAILED_EPISODE_CHECKS || 24 + this.MaxFailedEpisodeChecks = parseInt(process.env.MAX_FAILED_EPISODE_CHECKS, 10) || 24 } getEpisodeDownloadsInQueue(libraryItemId) { @@ -345,7 +345,7 @@ class PodcastManager { // Allow up to MaxFailedEpisodeChecks failed attempts before disabling auto download if (!this.failedCheckMap[libraryItem.id]) this.failedCheckMap[libraryItem.id] = 0 this.failedCheckMap[libraryItem.id]++ - if (this.MaxFailedEpisodeChecks != 0 && this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) { + if (this.MaxFailedEpisodeChecks !== 0 && this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) { Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}" - disabling auto download`) libraryItem.media.autoDownloadEpisodes = false delete this.failedCheckMap[libraryItem.id] From 9bb4dc3ab0a00c9d9df26390413f3a52b1071a94 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Wed, 4 Jun 2025 10:58:44 +0200 Subject: [PATCH 049/171] potential fix --- server/managers/PodcastManager.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 052ba8b3..bb4a408c 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -384,7 +384,13 @@ class PodcastManager { Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`) return null } - const feed = await getPodcastFeed(podcastLibraryItem.media.feedURL) + const feed = await Promise.race([ + getPodcastFeed(podcastLibraryItem.media.feedURL), + new Promise((_, reject) => + // The added second is to make sure that axios can fail first and only falls back later + setTimeout(() => reject(new Error('Timeout. getPodcastFeed seemed to timeout but not triggering the timeout.')), global.PodcastDownloadTimeout + 1000) + ) + ]) if (!feed?.episodes) { Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed) return null From 357176b301551b8c2551b94f41be87c28df3e950 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Wed, 4 Jun 2025 16:15:18 +0200 Subject: [PATCH 050/171] catch timeout --- server/managers/PodcastManager.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index bb4a408c..625688c9 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -390,7 +390,11 @@ class PodcastManager { // The added second is to make sure that axios can fail first and only falls back later setTimeout(() => reject(new Error('Timeout. getPodcastFeed seemed to timeout but not triggering the timeout.')), global.PodcastDownloadTimeout + 1000) ) - ]) + ]).catch((error) => { + Logger.error(`[PodcastManager] checkPodcastForNewEpisodes failed to fetch feed for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id}):`, error) + return null + }) + if (!feed?.episodes) { Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.title} (ID: ${podcastLibraryItem.id})`, feed) return null From 759c58d3f7561b6d56b61fa78ec6880f10796fa8 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Wed, 4 Jun 2025 16:38:01 +0200 Subject: [PATCH 051/171] remove any attachment --- client/components/ui/VueTrix.vue | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/client/components/ui/VueTrix.vue b/client/components/ui/VueTrix.vue index 2687d934..0836df15 100644 --- a/client/components/ui/VueTrix.vue +++ b/client/components/ui/VueTrix.vue @@ -318,10 +318,8 @@ export default { } }, handleAttachmentAdd(event) { - // Prevent pasting in images from the browser - if (!event.attachment.file) { - event.attachment.remove() - } + // Prevent pasting in images/any files from the browser + event.attachment.remove() } }, mounted() { From 6aa7c8a3d8734b55f932e8c270d830bc8644d36f Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Thu, 5 Jun 2025 13:34:18 +0200 Subject: [PATCH 052/171] added notification --- server/managers/NotificationManager.js | 48 ++++++++++++++++++++++++++ server/managers/PodcastManager.js | 2 ++ server/utils/notifications.js | 32 +++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/server/managers/NotificationManager.js b/server/managers/NotificationManager.js index 8edcf428..4f7072be 100644 --- a/server/managers/NotificationManager.js +++ b/server/managers/NotificationManager.js @@ -71,6 +71,54 @@ class NotificationManager { this.triggerNotification('onBackupCompleted', eventData) } + /** + * Handles RSS feed updates + * @param feedUrl + * @param numFailed + * @param title + * @returns {Promise} + */ + async onRSSFeedFailed(feedUrl, numFailed, title) { + if (!Database.notificationSettings.isUseable) return + + if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onRSSFeedFailed')) { + Logger.debug(`[NotificationManager] onRSSFeedFailed: No active notifications`) + return + } + + Logger.debug(`[NotificationManager] onRSSFeedFailed: RSS feed update failed for ${feedUrl}`) + const eventData = { + feedUrl: feedUrl, + numFailed: numFailed || 0, + title: title || 'Unknown Title' + } + this.triggerNotification('onRSSFeedFailed', eventData) + } + + /** + * Handles RSS feed being disabled due to too many failed updates + * @param feedUrl + * @param numFailed + * @param title + * @returns {Promise} + */ + async onRSSFeedDisabled(feedUrl, numFailed, title) { + if (!Database.notificationSettings.isUseable) return + + if (!Database.notificationSettings.getHasActiveNotificationsForEvent('onRSSFeedDisabled')) { + Logger.debug(`[NotificationManager] onRSSFeedDisabled: No active notifications`) + return + } + + Logger.debug(`[NotificationManager] onRSSFeedDisabled: RSS feed disabled due to ${numFailed} failed updates for ${feedUrl}`) + const eventData = { + feedUrl: feedUrl, + numFailed: numFailed || 0, + title: title || 'Unknown Title' + } + this.triggerNotification('onRSSFeedDisabled', eventData) + } + /** * * @param {string} errorMsg diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 625688c9..e5da8fe6 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -347,10 +347,12 @@ class PodcastManager { this.failedCheckMap[libraryItem.id]++ if (this.failedCheckMap[libraryItem.id] >= this.MaxFailedEpisodeChecks) { Logger.error(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}" - disabling auto download`) + void NotificationManager.onRSSFeedDisabled(libraryItem.media.feedURL, this.failedCheckMap[libraryItem.id], libraryItem.media.title) libraryItem.media.autoDownloadEpisodes = false delete this.failedCheckMap[libraryItem.id] } else { Logger.warn(`[PodcastManager] runEpisodeCheck ${this.failedCheckMap[libraryItem.id]} failed attempts at checking episodes for "${libraryItem.media.title}"`) + void NotificationManager.onRSSFeedFailed(libraryItem.media.feedURL, this.failedCheckMap[libraryItem.id], libraryItem.media.title) } } else if (newEpisodes.length) { delete this.failedCheckMap[libraryItem.id] diff --git a/server/utils/notifications.js b/server/utils/notifications.js index 7a3e1198..1b9612b9 100644 --- a/server/utils/notifications.js +++ b/server/utils/notifications.js @@ -60,6 +60,38 @@ module.exports.notificationData = { errorMsg: 'Example error message' } }, + { + name: 'onRSSFeedFailed', + requiresLibrary: true, + description: 'Triggered when an RSS feed request/update fails, but gets not disabled', + descriptionKey: 'NotificationOnRSSFeedFailedDescription', + variables: ['feedUrl', 'numFailed', 'title'], + defaults: { + title: 'RSS Feed Update Failed', + body: 'Failed to update RSS feed for {{title}}.\nFeed URL: {{feedUrl}}\nNumber of failed attempts: {{numFailed}}' + }, + testData: { + title: 'Test RSS Feed', + feedUrl: 'https://example.com/rss', + numFailed: 3 + } + }, + { + name: 'onRSSFeedDisabled', + requiresLibrary: true, + description: 'Triggered when an RSS feed is disabled due to too many failed attempts', + descriptionKey: 'NotificationOnRSSFeedDisabledDescription', + variables: ['feedUrl', 'numFailed', 'title'], + defaults: { + title: 'RSS Feed Disabled', + body: 'RSS feed for {{title}} has been disabled due to too many failed updates.\nFeed URL: {{feedUrl}}\nNumber of failed attempts: {{numFailed}}' + }, + testData: { + title: 'Test RSS Feed', + feedUrl: 'https://example.com/rss', + numFailed: 5 + } + }, { name: 'onTest', requiresLibrary: false, From 346df3680ce8dd415ce3d77d5ce789686e8dd5f9 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Thu, 5 Jun 2025 14:02:29 +0200 Subject: [PATCH 053/171] local strings --- client/strings/en-us.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 939eb9f4..65fd5f0e 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -918,6 +918,8 @@ "NotificationOnBackupCompletedDescription": "Triggered when a backup is completed", "NotificationOnBackupFailedDescription": "Triggered when a backup fails", "NotificationOnEpisodeDownloadedDescription": "Triggered when a podcast episode is auto-downloaded", + "NotificationOnRSSFeedFailedDescription": "Triggered when an RSS feed request/update fails, but gets not disabled", + "NotificationOnRSSFeedDisabledDescription": "Triggered when an RSS feed is disabled due to too many failed attempts", "NotificationOnTestDescription": "Event for testing the notification system", "PlaceholderNewCollection": "New collection name", "PlaceholderNewFolderPath": "New folder path", From 84c9c6cb50d6e4a725d0de6c6cba2449e50eab88 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Thu, 5 Jun 2025 14:07:35 +0200 Subject: [PATCH 054/171] move to global --- server/Server.js | 6 ++++++ server/managers/PodcastManager.js | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/server/Server.js b/server/Server.js index 17c959c0..317dab38 100644 --- a/server/Server.js +++ b/server/Server.js @@ -91,6 +91,12 @@ class Server { global.PodcastDownloadTimeout = 30000 } + if (process.env.MAX_FAILED_EPISODE_CHECKS) { + global.MaxFailedEpisodeChecks = process.env.MAX_FAILED_EPISODE_CHECKS + } else { + global.MaxFailedEpisodeChecks = 24 + } + if (!fs.pathExistsSync(global.ConfigPath)) { fs.mkdirSync(global.ConfigPath) } diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 54ce4c9f..0c7da925 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -30,7 +30,7 @@ class PodcastManager { this.currentDownload = null this.failedCheckMap = {} - this.MaxFailedEpisodeChecks = parseInt(process.env.MAX_FAILED_EPISODE_CHECKS, 10) || 24 + this.MaxFailedEpisodeChecks = global.MaxFailedEpisodeChecks } getEpisodeDownloadsInQueue(libraryItemId) { From f0525d4f0de8392b87d788fda3a0a6bda57d15c8 Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Thu, 5 Jun 2025 14:09:35 +0200 Subject: [PATCH 055/171] abc is hard --- client/strings/en-us.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 65fd5f0e..afecd671 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -918,8 +918,8 @@ "NotificationOnBackupCompletedDescription": "Triggered when a backup is completed", "NotificationOnBackupFailedDescription": "Triggered when a backup fails", "NotificationOnEpisodeDownloadedDescription": "Triggered when a podcast episode is auto-downloaded", - "NotificationOnRSSFeedFailedDescription": "Triggered when an RSS feed request/update fails, but gets not disabled", "NotificationOnRSSFeedDisabledDescription": "Triggered when an RSS feed is disabled due to too many failed attempts", + "NotificationOnRSSFeedFailedDescription": "Triggered when an RSS feed request/update fails, but gets not disabled", "NotificationOnTestDescription": "Event for testing the notification system", "PlaceholderNewCollection": "New collection name", "PlaceholderNewFolderPath": "New folder path", From 8e0185907502926b818fc36a2fea844c5dbac588 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 5 Jun 2025 14:31:12 -0500 Subject: [PATCH 056/171] Cast PODCAST_DOWNLOAD_TIMEOUT and MAX_FAILED_EPISODE_CHECKS env vars to numbers --- server/Server.js | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/server/Server.js b/server/Server.js index 317dab38..5c6f3c16 100644 --- a/server/Server.js +++ b/server/Server.js @@ -12,6 +12,7 @@ const { version } = require('../package.json') // Utils const fileUtils = require('./utils/fileUtils') +const { toNumber } = require('./utils/index') const Logger = require('./Logger') const Auth = require('./Auth') @@ -84,18 +85,8 @@ class Server { global.DisableSsrfRequestFilter = (url) => whitelistedUrls.includes(new URL(url).hostname) } } - - if (process.env.PODCAST_DOWNLOAD_TIMEOUT) { - global.PodcastDownloadTimeout = process.env.PODCAST_DOWNLOAD_TIMEOUT - } else { - global.PodcastDownloadTimeout = 30000 - } - - if (process.env.MAX_FAILED_EPISODE_CHECKS) { - global.MaxFailedEpisodeChecks = process.env.MAX_FAILED_EPISODE_CHECKS - } else { - global.MaxFailedEpisodeChecks = 24 - } + global.PodcastDownloadTimeout = toNumber(process.env.PODCAST_DOWNLOAD_TIMEOUT, 30000) + global.MaxFailedEpisodeChecks = toNumber(process.env.MAX_FAILED_EPISODE_CHECKS, 24) if (!fs.pathExistsSync(global.ConfigPath)) { fs.mkdirSync(global.ConfigPath) From eda7036f70c9a3a1cd699231c8ac9531368aee31 Mon Sep 17 00:00:00 2001 From: Jan Kubovy Date: Fri, 6 Jun 2025 10:43:52 +0000 Subject: [PATCH 057/171] Use fuse.js for podcast episode search Replace levenshtein distance with fuse.js fuzzy searching library. Search in episode's title and subtitle --- package-lock.json | 9 +++++++++ package.json | 1 + server/utils/podcastUtils.js | 39 +++++++++++++++++------------------- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index a8074794..9147d9e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "cookie-parser": "^1.4.6", "express": "^4.17.1", "express-session": "^1.17.3", + "fuse.js": "^7.1.0", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", "lru-cache": "^10.0.3", @@ -2105,6 +2106,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "engines": { + "node": ">=10" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", diff --git a/package.json b/package.json index d4831736..2a77ec87 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "cookie-parser": "^1.4.6", "express": "^4.17.1", "express-session": "^1.17.3", + "fuse.js": "^7.1.0", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", "lru-cache": "^10.0.3", diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 3a1df198..74a71cc1 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -3,6 +3,7 @@ const ssrfFilter = require('ssrf-req-filter') const Logger = require('../Logger') const { xmlToJSON, levenshteinDistance, timestampToSeconds } = require('./index') const htmlSanitizer = require('../utils/htmlSanitizer') +const Fuse = require('fuse.js') /** * @typedef RssPodcastChapter @@ -407,7 +408,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { }) } -// Return array of episodes ordered by closest match (Levenshtein distance of 6 or less) +// Return array of episodes ordered by closest match using fuse.js module.exports.findMatchingEpisodes = async (feedUrl, searchTitle) => { const feed = await this.getPodcastFeed(feedUrl).catch(() => { return null @@ -420,32 +421,28 @@ module.exports.findMatchingEpisodes = async (feedUrl, searchTitle) => { * * @param {RssPodcast} feed * @param {string} searchTitle - * @returns {Array<{ episode: RssPodcastEpisode, levenshtein: number }>} + * @returns {Array<{ episode: RssPodcastEpisode }>} */ module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => { - searchTitle = searchTitle.toLowerCase().trim() if (!feed?.episodes) { return null } + const fuseOptions = { + ignoreDiacritics: true, + threshold: 0.4, // default 0.6 return too many matches + keys: [ + {name: 'title', weight: 0.7}, // prefer match in title + {name: 'subtitle', weight: 0.3} + ] + } + const fuse = new Fuse(feed.episodes, fuseOptions) + const matches = [] - feed.episodes.forEach((ep) => { - if (!ep.title) return - const epTitle = ep.title.toLowerCase().trim() - if (epTitle === searchTitle) { - matches.push({ - episode: ep, - levenshtein: 0 - }) - } else { - const levenshtein = levenshteinDistance(searchTitle, epTitle, true) - if (levenshtein <= 6 && epTitle.length > levenshtein) { - matches.push({ - episode: ep, - levenshtein - }) - } - } + fuse.search(searchTitle).forEach((match) => { + matches.push({ + episode: match.item + }) }) - return matches.sort((a, b) => a.levenshtein - b.levenshtein) + return matches } From 81640464ba71a917d199a885f86658ea38a9fb5a Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 6 Jun 2025 17:05:07 -0500 Subject: [PATCH 058/171] Update cleanDatabase to remove duplicate mediaProgresses --- server/Database.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/Database.js b/server/Database.js index 52827e3f..2413a269 100644 --- a/server/Database.js +++ b/server/Database.js @@ -765,6 +765,15 @@ class Database { if (badSessionsRemoved > 0) { Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`) } + + // Remove mediaProgresses with duplicate mediaItemId (remove the oldest updatedAt) + const [duplicateMediaProgresses] = await this.sequelize.query(`SELECT id, mediaItemId FROM mediaProgresses WHERE (mediaItemId, updatedAt) IN (SELECT mediaItemId, MIN(updatedAt) FROM mediaProgresses GROUP BY mediaItemId HAVING COUNT(*) > 1)`) + for (const duplicateMediaProgress of duplicateMediaProgresses) { + Logger.warn(`Found duplicate mediaProgress for mediaItem "${duplicateMediaProgress.mediaItemId}" - removing it`) + await this.mediaProgressModel.destroy({ + where: { id: duplicateMediaProgress.id } + }) + } } async createTextSearchQuery(query) { From 0c5d05d3199c3aea64769a1f5f2c1535d8b3c9c6 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 7 Jun 2025 17:10:23 -0500 Subject: [PATCH 059/171] Fix chapter table on audiobook tools page uneven column widths --- client/pages/audiobook/_id/manage.vue | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/pages/audiobook/_id/manage.vue b/client/pages/audiobook/_id/manage.vue index 37734934..40672af2 100644 --- a/client/pages/audiobook/_id/manage.vue +++ b/client/pages/audiobook/_id/manage.vue @@ -28,14 +28,14 @@
-
{{ $strings.LabelMetaTag }}
-
{{ $strings.LabelValue }}
+
{{ $strings.LabelMetaTag }}
+
{{ $strings.LabelValue }}
@@ -33,10 +33,12 @@ export default { }, data() { return { + loadingUsers: false, selectedApiKey: null, showApiKeyModal: false, showApiKeyCreatedModal: false, - numApiKeys: 0 + numApiKeys: 0, + users: [] } }, methods: { @@ -45,7 +47,6 @@ export default { this.selectedApiKey = apiKey this.showApiKeyCreatedModal = true if (this.$refs.apiKeysTable) { - console.log('apiKeyCreated', apiKey) this.$refs.apiKeysTable.addApiKey(apiKey) } }, @@ -60,9 +61,27 @@ export default { setShowApiKeyModal(selectedApiKey) { this.selectedApiKey = selectedApiKey this.showApiKeyModal = true + }, + loadUsers() { + this.loadingUsers = true + this.$axios + .$get('/api/users') + .then((res) => { + this.users = res.users.sort((a, b) => { + return a.createdAt - b.createdAt + }) + }) + .catch((error) => { + console.error('Failed', error) + }) + .finally(() => { + this.loadingUsers = false + }) } }, - mounted() {}, + mounted() { + this.loadUsers() + }, beforeDestroy() {} } diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 62443e0b..0d76f2e7 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -242,6 +242,8 @@ "LabelAlreadyInYourLibrary": "Already in your library", "LabelApiKeyCreated": "API Key \"{0}\" created successfully.", "LabelApiKeyCreatedDescription": "Make sure to copy the API key now as you will not be able to see this again.", + "LabelApiKeyUser": "Act on behalf of user", + "LabelApiKeyUserDescription": "This API key will have the same permissions as the user it is acting on behalf of. This will appear the same in logs as if the user was making the request.", "LabelApiToken": "API Token", "LabelAppend": "Append", "LabelAudioBitrate": "Audio Bitrate (e.g. 128k)", @@ -353,6 +355,7 @@ "LabelExample": "Example", "LabelExpandSeries": "Expand Series", "LabelExpandSubSeries": "Expand Sub Series", + "LabelExpired": "Expired", "LabelExpiresAt": "Expires At", "LabelExpiresInSeconds": "Expires in (seconds)", "LabelExpiresNever": "Never", @@ -418,7 +421,6 @@ "LabelLastSeen": "Last Seen", "LabelLastTime": "Last Time", "LabelLastUpdate": "Last Update", - "LabelLastUsed": "Last Used", "LabelLayout": "Layout", "LabelLayoutSinglePage": "Single page", "LabelLayoutSplitPage": "Split page", @@ -556,6 +558,7 @@ "LabelSelectAll": "Select all", "LabelSelectAllEpisodes": "Select all episodes", "LabelSelectEpisodesShowing": "Select {0} episodes showing", + "LabelSelectUser": "Select user", "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Send Ebook to...", "LabelSequence": "Sequence", @@ -1046,6 +1049,7 @@ "ToastMustHaveAtLeastOnePath": "Must have at least one path", "ToastNameEmailRequired": "Name and email are required", "ToastNameRequired": "Name is required", + "ToastNewApiKeyUserError": "Must select a user", "ToastNewEpisodesFound": "{0} new episodes found", "ToastNewUserCreatedFailed": "Failed to create account: \"{0}\"", "ToastNewUserCreatedSuccess": "New account created", diff --git a/server/Auth.js b/server/Auth.js index 1839d27f..b1d94d41 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -65,7 +65,9 @@ class Auth { new JwtStrategy( { jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]), - secretOrKey: Database.serverSettings.tokenSecret + secretOrKey: Database.serverSettings.tokenSecret, + // Handle expiration manaully in order to disable api keys that are expired + ignoreExpiration: true }, this.jwtAuthCheck.bind(this) ) @@ -1044,6 +1046,7 @@ class Auth { } await Database.updateServerSettings() + // TODO: Old method of non-expiring tokens // New token secret creation added in v2.1.0 so generate new API tokens for each user const users = await Database.userModel.findAll({ attributes: ['id', 'username', 'token'] @@ -1057,22 +1060,49 @@ class Auth { } /** - * Checks if the user in the validated jwt_payload really exists and is active. + * Checks if the user or api key in the validated jwt_payload exists and is active. * @param {Object} jwt_payload * @param {function} done */ async jwtAuthCheck(jwt_payload, done) { - // load user by id from the jwt token - const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId) + if (jwt_payload.type === 'api') { + const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId) - if (!user?.isActive) { - // deny login - done(null, null) - return + if (!apiKey?.isActive) { + done(null, null) + return + } + + // Check if the api key is expired and deactivate it + if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) { + done(null, null) + + apiKey.isActive = false + await apiKey.save() + Logger.info(`[Auth] API key ${apiKey.id} is expired - deactivated`) + return + } + + const user = await Database.userModel.getUserById(apiKey.userId) + done(null, user) + } else { + // Check if the jwt is expired + if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) { + done(null, null) + return + } + + // load user by id from the jwt token + const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId) + + if (!user?.isActive) { + // deny login + done(null, null) + return + } + // approve login + done(null, user) } - // approve login - done(null, user) - return } /** diff --git a/server/Database.js b/server/Database.js index b632d040..213c2c61 100644 --- a/server/Database.js +++ b/server/Database.js @@ -670,6 +670,7 @@ class Database { * Remove playback sessions that are 3 seconds or less * Remove duplicate mediaProgresses * Remove expired auth sessions + * Deactivate expired api keys */ async cleanDatabase() { // Remove invalid Podcast records @@ -802,6 +803,23 @@ WHERE EXISTS ( // Remove expired Session records await this.cleanupExpiredSessions() + + // Deactivate expired api keys + await this.deactivateExpiredApiKeys() + } + + /** + * Deactivate expired api keys + */ + async deactivateExpiredApiKeys() { + try { + const affectedCount = await this.apiKeyModel.deactivateExpiredApiKeys() + if (affectedCount > 0) { + Logger.info(`[Database] Deactivated ${affectedCount} expired api keys`) + } + } catch (error) { + Logger.error(`[Database] Error deactivating expired api keys: ${error.message}`) + } } /** diff --git a/server/controllers/ApiKeyController.js b/server/controllers/ApiKeyController.js index 776ddcbe..0166479f 100644 --- a/server/controllers/ApiKeyController.js +++ b/server/controllers/ApiKeyController.js @@ -20,7 +20,19 @@ class ApiKeyController { * @param {Response} res */ async getAll(req, res) { - const apiKeys = await Database.apiKeyModel.findAll() + const apiKeys = await Database.apiKeyModel.findAll({ + include: [ + { + model: Database.userModel, + attributes: ['id', 'username', 'type'] + }, + { + model: Database.userModel, + as: 'createdByUser', + attributes: ['id', 'username', 'type'] + } + ] + }) return res.json({ apiKeys: apiKeys.map((a) => a.toJSON()) @@ -42,10 +54,21 @@ class ApiKeyController { Logger.warn(`[ApiKeyController] create: Invalid expiresIn: ${req.body.expiresIn}`) return res.sendStatus(400) } + if (!req.body.userId || typeof req.body.userId !== 'string') { + Logger.warn(`[ApiKeyController] create: Invalid userId: ${req.body.userId}`) + return res.sendStatus(400) + } + const user = await Database.userModel.getUserById(req.body.userId) + if (!user) { + Logger.warn(`[ApiKeyController] create: User not found: ${req.body.userId}`) + return res.sendStatus(400) + } + if (user.type === 'root' && !req.user.isRoot) { + Logger.warn(`[ApiKeyController] create: Root user API key cannot be created by non-root user`) + return res.sendStatus(403) + } const keyId = uuidv4() // Generate key id ahead of time to use in JWT - - const permissions = Database.apiKeyModel.mergePermissionsWithDefault(req.body.permissions) const apiKey = await Database.apiKeyModel.generateApiKey(keyId, req.body.name, req.body.expiresIn) if (!apiKey) { @@ -60,9 +83,9 @@ class ApiKeyController { id: keyId, name: req.body.name, expiresAt, - permissions, - userId: req.user.id, - isActive: !!req.body.isActive + userId: req.body.userId, + isActive: !!req.body.isActive, + createdByUserId: req.user.id }) Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`) @@ -76,34 +99,64 @@ class ApiKeyController { /** * PATCH: /api/api-keys/:id - * Only isActive and permissions can be updated because name and expiresIn are in the JWT + * Only isActive and userId can be updated because name and expiresIn are in the JWT * * @param {RequestWithUser} req * @param {Response} res */ async update(req, res) { - const apiKey = await Database.apiKeyModel.findByPk(req.params.id) + const apiKey = await Database.apiKeyModel.findByPk(req.params.id, { + include: { + model: Database.userModel + } + }) if (!apiKey) { return res.sendStatus(404) } + // Only root user can update root user API keys + if (apiKey.user.type === 'root' && !req.user.isRoot) { + Logger.warn(`[ApiKeyController] update: Root user API key cannot be updated by non-root user`) + return res.sendStatus(403) + } + + let hasUpdates = false + if (req.body.userId !== undefined) { + if (typeof req.body.userId !== 'string') { + Logger.warn(`[ApiKeyController] update: Invalid userId: ${req.body.userId}`) + return res.sendStatus(400) + } + const user = await Database.userModel.getUserById(req.body.userId) + if (!user) { + Logger.warn(`[ApiKeyController] update: User not found: ${req.body.userId}`) + return res.sendStatus(400) + } + if (user.type === 'root' && !req.user.isRoot) { + Logger.warn(`[ApiKeyController] update: Root user API key cannot be created by non-root user`) + return res.sendStatus(403) + } + if (apiKey.userId !== req.body.userId) { + apiKey.userId = req.body.userId + hasUpdates = true + } + } if (req.body.isActive !== undefined) { if (typeof req.body.isActive !== 'boolean') { return res.sendStatus(400) } - - apiKey.isActive = req.body.isActive + if (apiKey.isActive !== req.body.isActive) { + apiKey.isActive = req.body.isActive + hasUpdates = true + } } - if (req.body.permissions && Object.keys(req.body.permissions).length > 0) { - const permissions = Database.apiKeyModel.mergePermissionsWithDefault(req.body.permissions) - apiKey.permissions = permissions + if (hasUpdates) { + await apiKey.save() + Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`) + } else { + Logger.info(`[ApiKeyController] No updates needed to API key "${apiKey.name}"`) } - await apiKey.save() - - Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`) - return res.json({ apiKey: apiKey.toJSON() }) diff --git a/server/managers/CronManager.js b/server/managers/CronManager.js index adc14177..d3e65212 100644 --- a/server/managers/CronManager.js +++ b/server/managers/CronManager.js @@ -36,6 +36,7 @@ class CronManager { * Closes open share sessions that have not been updated in 24 hours * Closes open playback sessions that have not been updated in 36 hours * Cleans up expired auth sessions + * Deactivates expired api keys * TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner */ initOpenSessionCleanupCron() { @@ -44,6 +45,7 @@ class CronManager { ShareManager.closeStaleOpenShareSessions() await this.playbackSessionManager.closeStaleOpenSessions() await Database.cleanupExpiredSessions() + await Database.deactivateExpiredApiKeys() }) } diff --git a/server/migrations/v2.26.0-create-auth-tables.js b/server/migrations/v2.26.0-create-auth-tables.js index 2c86411e..a1480462 100644 --- a/server/migrations/v2.26.0-create-auth-tables.js +++ b/server/migrations/v2.26.0-create-auth-tables.js @@ -80,7 +80,11 @@ async function up({ context: { queryInterface, logger } }) { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - name: DataTypes.STRING, + name: { + type: DataTypes.STRING, + allowNull: false + }, + description: DataTypes.TEXT, expiresAt: DataTypes.DATE, lastUsedAt: DataTypes.DATE, isActive: { @@ -105,6 +109,17 @@ async function up({ context: { queryInterface, logger } }) { }, key: 'id' }, + onDelete: 'CASCADE' + }, + createdByUserId: { + type: DataTypes.UUID, + references: { + model: { + tableName: 'users', + as: 'createdByUser' + }, + key: 'id' + }, onDelete: 'SET NULL' } }) diff --git a/server/models/ApiKey.js b/server/models/ApiKey.js index 54cc036a..7b61f731 100644 --- a/server/models/ApiKey.js +++ b/server/models/ApiKey.js @@ -1,5 +1,6 @@ const { DataTypes, Model, Op } = require('sequelize') const jwt = require('jsonwebtoken') +const { LRUCache } = require('lru-cache') const Logger = require('../Logger') /** @@ -17,6 +18,32 @@ const Logger = require('../Logger') * @property {string[]} itemTagsSelected */ +class ApiKeyCache { + constructor() { + this.cache = new LRUCache({ max: 100 }) + } + + getById(id) { + const apiKey = this.cache.get(id) + return apiKey + } + + set(apiKey) { + apiKey.fromCache = true + this.cache.set(apiKey.id, apiKey) + } + + delete(apiKeyId) { + this.cache.delete(apiKeyId) + } + + maybeInvalidate(apiKey) { + if (!apiKey.fromCache) this.delete(apiKey.id) + } +} + +const apiKeyCache = new ApiKeyCache() + class ApiKey extends Model { constructor(values, options) { super(values, options) @@ -25,13 +52,15 @@ class ApiKey extends Model { this.id /** @type {string} */ this.name + /** @type {string} */ + this.description /** @type {Date} */ this.expiresAt /** @type {Date} */ this.lastUsedAt /** @type {boolean} */ this.isActive - /** @type {Object} */ + /** @type {ApiKeyPermissions} */ this.permissions /** @type {Date} */ this.createdAt @@ -39,6 +68,8 @@ class ApiKey extends Model { this.updatedAt /** @type {UUIDV4} */ this.userId + /** @type {UUIDV4} */ + this.createdByUserId // Expanded properties @@ -104,18 +135,24 @@ class ApiKey extends Model { } /** - * Clean up expired api keys from the database - * @returns {Promise} Number of api keys deleted + * Deactivate expired api keys + * @returns {Promise} Number of api keys affected */ - static async cleanupExpiredApiKeys() { - const deletedCount = await ApiKey.destroy({ - where: { - expiresAt: { - [Op.lt]: new Date() + static async deactivateExpiredApiKeys() { + const [affectedCount] = await ApiKey.update( + { + isActive: false + }, + { + where: { + isActive: true, + expiresAt: { + [Op.lt]: new Date() + } } } - }) - return deletedCount + ) + return affectedCount } /** @@ -152,6 +189,24 @@ class ApiKey extends Model { }) } + /** + * Get an api key by id, from cache or database + * @param {string} apiKeyId + * @returns {Promise} + */ + static async getById(apiKeyId) { + if (!apiKeyId) return null + + const cachedApiKey = apiKeyCache.getById(apiKeyId) + if (cachedApiKey) return cachedApiKey + + const apiKey = await ApiKey.findByPk(apiKeyId) + if (!apiKey) return null + + apiKeyCache.set(apiKey) + return apiKey + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize @@ -164,7 +219,11 @@ class ApiKey extends Model { defaultValue: DataTypes.UUIDV4, primaryKey: true }, - name: DataTypes.STRING, + name: { + type: DataTypes.STRING, + allowNull: false + }, + description: DataTypes.TEXT, expiresAt: DataTypes.DATE, lastUsedAt: DataTypes.DATE, isActive: { @@ -182,9 +241,30 @@ class ApiKey extends Model { const { user } = sequelize.models user.hasMany(ApiKey, { - onDelete: 'SET NULL' + onDelete: 'CASCADE' }) ApiKey.belongsTo(user) + + user.hasMany(ApiKey, { + foreignKey: 'createdByUserId', + onDelete: 'SET NULL' + }) + ApiKey.belongsTo(user, { as: 'createdByUser', foreignKey: 'createdByUserId' }) + } + + async update(values, options) { + apiKeyCache.maybeInvalidate(this) + return await super.update(values, options) + } + + async save(options) { + apiKeyCache.maybeInvalidate(this) + return await super.save(options) + } + + async destroy(options) { + apiKeyCache.delete(this.id) + await super.destroy(options) } } From 8b995a179ddf52fedcd1275edbe0a091465485e7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 30 Jun 2025 17:31:31 -0500 Subject: [PATCH 120/171] Add support for returning refresh token for mobile clients --- client/store/user.js | 5 +++- server/Auth.js | 36 +++++++++++++++++++++------- server/controllers/UserController.js | 3 +++ server/models/User.js | 5 ---- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/client/store/user.js b/client/store/user.js index 787d67db..e37568f1 100644 --- a/client/store/user.js +++ b/client/store/user.js @@ -152,8 +152,11 @@ export const mutations = { setUser(state, user) { state.user = user if (user) { + // Use accessToken from user if included in response (for login) if (user.accessToken) localStorage.setItem('token', user.accessToken) - else { + else if (localStorage.getItem('token')) { + user.accessToken = localStorage.getItem('token') + } else { console.error('No access token found for user', user) } } else { diff --git a/server/Auth.js b/server/Auth.js index b1d94d41..b811a5db 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -466,14 +466,29 @@ class Auth { // return the user login response json if the login was successfull const userResponse = await this.getUserLoginResponsePayload(req.user) - this.setRefreshTokenCookie(req, res, req.user.refreshToken) + // Check if mobile app wants refresh token in response + const returnTokens = req.query.return_tokens === 'true' || req.headers['x-return-tokens'] === 'true' + + userResponse.user.refreshToken = returnTokens ? req.user.refreshToken : null + userResponse.user.accessToken = req.user.accessToken + + if (!returnTokens) { + this.setRefreshTokenCookie(req, res, req.user.refreshToken) + } res.json(userResponse) }) // Refresh token route router.post('/auth/refresh', async (req, res) => { - const refreshToken = req.cookies.refresh_token + let refreshToken = req.cookies.refresh_token + + // For mobile clients, the refresh token is sent in the authorization header + let shouldReturnRefreshToken = false + if (!refreshToken && req.headers.authorization?.startsWith('Bearer ')) { + refreshToken = req.headers.authorization.split(' ')[1] + shouldReturnRefreshToken = true + } if (!refreshToken) { return res.status(401).json({ error: 'No refresh token provided' }) @@ -507,10 +522,12 @@ class Auth { return res.status(401).json({ error: 'User not found or inactive' }) } - const newAccessToken = await this.rotateTokensForSession(session, user, req, res) + const newTokens = await this.rotateTokensForSession(session, user, req, res) - user.accessToken = newAccessToken const userResponse = await this.getUserLoginResponsePayload(user) + + userResponse.user.accessToken = newTokens.accessToken + userResponse.user.refreshToken = shouldReturnRefreshToken ? newTokens.refreshToken : null res.json(userResponse) } catch (error) { if (error.name === 'TokenExpiredError') { @@ -961,7 +978,7 @@ class Auth { * @param {import('./models/User')} user * @param {Request} req * @param {Response} res - * @returns {Promise} newAccessToken + * @returns {Promise<{ accessToken:string, refreshToken:string }>} */ async rotateTokensForSession(session, user, req, res) { // Generate new tokens @@ -978,7 +995,10 @@ class Auth { // Set new refresh token cookie this.setRefreshTokenCookie(req, res, newRefreshToken) - return newAccessToken + return { + accessToken: newAccessToken, + refreshToken: newRefreshToken + } } /** @@ -996,7 +1016,7 @@ class Auth { // So rotate token for current session const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } }) if (currentSession) { - const newAccessToken = await this.rotateTokensForSession(currentSession, user, req, res) + const newTokens = await this.rotateTokensForSession(currentSession, user, req, res) // Invalidate all sessions for the user except the current one await Database.sessionModel.destroy({ @@ -1008,7 +1028,7 @@ class Auth { } }) - return newAccessToken + return newTokens.accessToken } else { Logger.error(`[Auth] No session found to rotate tokens for refresh token ${currentRefreshToken}`) } diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index 0a99b84e..48c98150 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -336,6 +336,9 @@ class UserController { const newAccessToken = await this.auth.invalidateJwtSessionsForUser(user, req, res) if (newAccessToken) { user.accessToken = newAccessToken + // Refresh tokens are only returned for mobile clients + // Mobile apps currently do not use this API endpoint so always set to null + user.refreshToken = null Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username} and rotated tokens for current session`) } else { Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`) diff --git a/server/models/User.js b/server/models/User.js index 9b26b0ff..588b53bb 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -112,10 +112,6 @@ class User extends Model { this.updatedAt /** @type {import('./MediaProgress')[]?} - Only included when extended */ this.mediaProgresses - - // Temporary accessToken, not stored in database - /** @type {string} */ - this.accessToken } // Excludes "root" since their can only be 1 root user @@ -526,7 +522,6 @@ class User extends Model { type: this.type, // TODO: Old non-expiring token token: this.type === 'root' && hideRootToken ? '' : this.token, - accessToken: this.accessToken || null, mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [], seriesHideFromContinueListening: [...seriesHideFromContinueListening], bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [], From 293851d9310dca2fb1807ed610aefae1208e491f Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 30 Jun 2025 17:49:05 -0500 Subject: [PATCH 121/171] Fix missing translation in remove podcast episode modal #4434 --- client/components/modals/podcast/RemoveEpisode.vue | 2 +- client/strings/en-us.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/client/components/modals/podcast/RemoveEpisode.vue b/client/components/modals/podcast/RemoveEpisode.vue index 38dd71cf..b2cebe84 100644 --- a/client/components/modals/podcast/RemoveEpisode.vue +++ b/client/components/modals/podcast/RemoveEpisode.vue @@ -11,7 +11,7 @@ {{ $getString('MessageConfirmRemoveEpisode', [episodeTitle]) }}

{{ $getString('MessageConfirmRemoveEpisodes', [episodes.length]) }}

-

Note: This does not delete the audio file unless toggling "Hard delete file"

+

{{ $strings.MessageConfirmRemoveEpisodeNote }}

diff --git a/client/strings/en-us.json b/client/strings/en-us.json index f6288912..3ce20e1e 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -757,6 +757,7 @@ "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", "MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?", + "MessageConfirmRemoveEpisodeNote": "Note: This does not delete the audio file unless toggling \"Hard delete file\"", "MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?", "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveMetadataFiles": "Are you sure you want to remove all metadata.{0} files in your library item folders?", From 44ff90a6f2aa354a2cc4c58bd4b7c7ac9d8efe81 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 1 Jul 2025 16:31:26 -0500 Subject: [PATCH 122/171] Update refresh endpoint to support override cookie token --- server/Auth.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/Auth.js b/server/Auth.js index b811a5db..df2d2115 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -484,8 +484,9 @@ class Auth { let refreshToken = req.cookies.refresh_token // For mobile clients, the refresh token is sent in the authorization header + // Force return refresh token if x-return-tokens header is true let shouldReturnRefreshToken = false - if (!refreshToken && req.headers.authorization?.startsWith('Bearer ')) { + if (req.headers.authorization?.startsWith('Bearer ') && (!refreshToken || req.headers['x-return-tokens'] === 'true')) { refreshToken = req.headers.authorization.split(' ')[1] shouldReturnRefreshToken = true } From 596bddf791de0781010b17fba5536a367a68e8b4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 1 Jul 2025 16:48:07 -0500 Subject: [PATCH 123/171] Fix manually setting updatedAt of mediaProgresses using progress sync lastUpdate timestamp #4366 --- server/models/MediaProgress.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/server/models/MediaProgress.js b/server/models/MediaProgress.js index 1638a3e4..0ebe2f59 100644 --- a/server/models/MediaProgress.js +++ b/server/models/MediaProgress.js @@ -183,7 +183,7 @@ class MediaProgress extends Model { * @param {import('./User').ProgressUpdatePayload} progressPayload * @returns {Promise} */ - applyProgressUpdate(progressPayload) { + async applyProgressUpdate(progressPayload) { if (!this.extraData) this.extraData = {} if (progressPayload.isFinished !== undefined) { if (progressPayload.isFinished && !this.isFinished) { @@ -243,14 +243,23 @@ class MediaProgress extends Model { this.finishedAt = null } + await this.save() + // For local sync if (progressPayload.lastUpdate) { - this.updatedAt = progressPayload.lastUpdate - Logger.info(`[MediaProgress] Manually setting updatedAt to ${this.updatedAt} (media item ${this.mediaItemId})`) - this.changed('updatedAt', true) + if (isNaN(new Date(progressPayload.lastUpdate))) { + Logger.warn(`[MediaProgress] Invalid date provided for lastUpdate: ${progressPayload.lastUpdate} (media item ${this.mediaItemId})`) + } else { + const escapedDate = this.sequelize.escape(new Date(progressPayload.lastUpdate)) + Logger.info(`[MediaProgress] Manually setting updatedAt to ${escapedDate} (media item ${this.mediaItemId})`) + + await this.sequelize.query(`UPDATE "mediaProgresses" SET "updatedAt" = ${escapedDate} WHERE "id" = '${this.id}'`) + + await this.reload() + } } - return this.save({ silent: !!progressPayload.lastUpdate }) + return this } } From df60aeb4569cdea4625658e987a8b29ceb24a8ef Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 2 Jul 2025 17:30:00 -0500 Subject: [PATCH 124/171] Update narrator name to be clickable to filter by narrator --- client/pages/library/_library/narrators.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/pages/library/_library/narrators.vue b/client/pages/library/_library/narrators.vue index 17160213..9e58bf14 100644 --- a/client/pages/library/_library/narrators.vue +++ b/client/pages/library/_library/narrators.vue @@ -10,7 +10,7 @@ -

{{ narrator.name }}

+ {{ narrator.name }}
From f127a7beb548e29969873027c6d4389dadb9a13a Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 3 Jul 2025 17:31:38 -0500 Subject: [PATCH 125/171] Update router for internal-api routes --- server/Server.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/Server.js b/server/Server.js index 22a53a3a..7bf3e048 100644 --- a/server/Server.js +++ b/server/Server.js @@ -309,12 +309,14 @@ class Server { }) ) router.use(express.urlencoded({ extended: true, limit: '5mb' })) - router.use(express.json({ limit: '10mb' })) router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router) router.use('/hls', this.hlsRouter.router) router.use('/public', this.publicRouter.router) + // Skip JSON parsing for internal-api routes + router.use(/^(?!\/internal-api).*/, express.json({ limit: '10mb' })) + // Static folder router.use(express.static(Path.join(global.appRoot, 'static'))) @@ -404,6 +406,7 @@ class Server { const handle = nextApp.getRequestHandler() await nextApp.prepare() router.get('*', (req, res) => handle(req, res)) + router.post('/internal-api/*', (req, res) => handle(req, res)) } const unixSocketPrefix = 'unix/' From cdc37ddb0f71c74c4caf1f53dbb9cf891b3768a7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 4 Jul 2025 13:54:37 -0500 Subject: [PATCH 126/171] Use x-refresh-token for alt method of passing refresh token, check x-refresh-token for logout --- server/Auth.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index df2d2115..1b3ba601 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -483,11 +483,11 @@ class Auth { router.post('/auth/refresh', async (req, res) => { let refreshToken = req.cookies.refresh_token - // For mobile clients, the refresh token is sent in the authorization header - // Force return refresh token if x-return-tokens header is true + // If x-refresh-token header is present, use it instead of the cookie + // and return the refresh token in the response let shouldReturnRefreshToken = false - if (req.headers.authorization?.startsWith('Bearer ') && (!refreshToken || req.headers['x-return-tokens'] === 'true')) { - refreshToken = req.headers.authorization.split(' ')[1] + if (req.headers['x-refresh-token']) { + refreshToken = req.headers['x-refresh-token'] shouldReturnRefreshToken = true } @@ -495,6 +495,8 @@ class Auth { return res.status(401).json({ error: 'No refresh token provided' }) } + Logger.debug(`[Auth] refreshing token. shouldReturnRefreshToken: ${shouldReturnRefreshToken}`) + try { // Verify the refresh token const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret) @@ -820,7 +822,9 @@ class Auth { // Logout route router.post('/logout', async (req, res) => { - const refreshToken = req.cookies.refresh_token + // Refresh token be alternatively be sent in the header + const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token'] + // Clear refresh token cookie res.clearCookie('refresh_token', { path: '/' @@ -829,12 +833,15 @@ class Auth { // Invalidate the session in database using refresh token if (refreshToken) { try { + Logger.info(`[Auth] logout: Invalidating session for refresh token: ${refreshToken}`) await Database.sessionModel.destroy({ where: { refreshToken } }) } catch (error) { Logger.error(`[Auth] Error destroying session: ${error.message}`) } + } else { + Logger.info(`[Auth] logout: No refresh token on request`) } // TODO: invalidate possible JWTs From 8dbe1e4e5d2fac4ed1b20e4cc3dcd52abc14a168 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 4 Jul 2025 16:49:45 -0500 Subject: [PATCH 127/171] Fix express.json position --- server/Server.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/Server.js b/server/Server.js index 7bf3e048..639ae210 100644 --- a/server/Server.js +++ b/server/Server.js @@ -310,13 +310,13 @@ class Server { ) router.use(express.urlencoded({ extended: true, limit: '5mb' })) + // Skip JSON parsing for internal-api routes + router.use(/^(?!\/internal-api).*/, express.json({ limit: '10mb' })) + router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router) router.use('/hls', this.hlsRouter.router) router.use('/public', this.publicRouter.router) - // Skip JSON parsing for internal-api routes - router.use(/^(?!\/internal-api).*/, express.json({ limit: '10mb' })) - // Static folder router.use(express.static(Path.join(global.appRoot, 'static'))) From e59babdf24cfa3283b9ea6fd6c8f0e22a76f15ec Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 5 Jul 2025 17:46:18 -0500 Subject: [PATCH 128/171] Force re-login if using old token, show alert if admin user, add isOldToken flag to user --- client/pages/login.vue | 30 +++++++++++++++++++++++++++--- server/Auth.js | 14 ++++++++++++++ server/models/User.js | 3 +++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index 3f48509f..71fa8c2b 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -40,6 +40,15 @@

{{ error }}

+
+ +
+

Authentication has been improved for security. All users will be required to re-login.

+ More info +
+
+
+
@@ -85,7 +94,8 @@ export default { MetadataPath: '', login_local: true, login_openid: false, - authFormData: null + authFormData: null, + showNewAuthSystemAdminMessage: false } }, watch: { @@ -184,6 +194,7 @@ export default { }, async submitForm() { this.error = null + this.showNewAuthSystemAdminMessage = false this.processing = true const payload = { @@ -217,15 +228,28 @@ export default { } }) .then((res) => { + // Force re-login if user is using an old token with no expiration + if (res.user.isOldToken) { + if (res.user.type === 'admin' || res.user.type === 'root') { + this.username = res.user.username + // Show message to admin users about new auth system + this.showNewAuthSystemAdminMessage = true + } else { + // Regular users just shown login + this.username = res.user.username + } + return false + } this.setUser(res) - this.processing = false return true }) .catch((error) => { console.error('Authorize error', error) - this.processing = false return false }) + .finally(() => { + this.processing = false + }) }, checkStatus() { this.processing = true diff --git a/server/Auth.js b/server/Auth.js index 1b3ba601..d2250f17 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -492,6 +492,7 @@ class Auth { } if (!refreshToken) { + Logger.error(`[Auth] Failed to refresh token. No refresh token provided`) return res.status(401).json({ error: 'No refresh token provided' }) } @@ -502,6 +503,7 @@ class Auth { const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret) if (decoded.type !== 'refresh') { + Logger.error(`[Auth] Failed to refresh token. Invalid token type: ${decoded.type}`) return res.status(401).json({ error: 'Invalid token type' }) } @@ -510,6 +512,7 @@ class Auth { }) if (!session) { + Logger.error(`[Auth] Failed to refresh token. Session not found for refresh token: ${refreshToken}`) return res.status(401).json({ error: 'Invalid refresh token' }) } @@ -522,6 +525,7 @@ class Auth { const user = await Database.userModel.getUserById(decoded.userId) if (!user?.isActive) { + Logger.error(`[Auth] Failed to refresh token. User not found or inactive for user id: ${decoded.userId}`) return res.status(401).json({ error: 'User not found or inactive' }) } @@ -1128,6 +1132,16 @@ class Auth { done(null, null) return } + + // TODO: Temporary flag to report old tokens to users + // May be a better place for this but here means we dont have to decode the token again + if (!jwt_payload.exp && !user.isOldToken) { + Logger.debug(`[Auth] User ${user.username} is using an access token without an expiration`) + user.isOldToken = true + } else if (jwt_payload.exp && user.isOldToken !== undefined) { + delete user.isOldToken + } + // approve login done(null, user) } diff --git a/server/models/User.js b/server/models/User.js index 588b53bb..154587a7 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -522,6 +522,9 @@ class User extends Model { type: this.type, // TODO: Old non-expiring token token: this.type === 'root' && hideRootToken ? '' : this.token, + // TODO: Temporary flag not saved in db that is set in Auth.js jwtAuthCheck + // Necessary to detect apps using old tokens that no longer match the old token stored on the user + isOldToken: this.isOldToken, mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [], seriesHideFromContinueListening: [...seriesHideFromContinueListening], bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [], From e201247d69566350739e0eefa5eca767e814cea1 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 6 Jul 2025 11:07:01 -0500 Subject: [PATCH 129/171] Handle socket re-authentication, fix socket toast to be re-usable, socket cleanup --- client/components/app/LazyBookshelf.vue | 6 ---- client/layouts/default.vue | 43 +++++++++++++++++++++++-- client/plugins/axios.js | 7 +++- server/Auth.js | 2 ++ server/SocketAuthority.js | 24 ++++++++++---- 5 files changed, 66 insertions(+), 16 deletions(-) diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index 61331fb9..854b61b2 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -778,10 +778,6 @@ export default { windowResize() { this.executeRebuild() }, - socketInit() { - // Server settings are set on socket init - this.executeRebuild() - }, initListeners() { window.addEventListener('resize', this.windowResize) @@ -794,7 +790,6 @@ export default { }) this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities) - this.$eventBus.$on('socket_init', this.socketInit) this.$eventBus.$on('user-settings', this.settingsUpdated) if (this.$root.socket) { @@ -826,7 +821,6 @@ export default { } this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities) - this.$eventBus.$off('socket_init', this.socketInit) this.$eventBus.$off('user-settings', this.settingsUpdated) if (this.$root.socket) { diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 33e7aa15..9f15af67 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -33,6 +33,7 @@ export default { return { socket: null, isSocketConnected: false, + isSocketAuthenticated: false, isFirstSocketConnection: true, socketConnectionToastId: null, currentLang: null, @@ -81,9 +82,28 @@ export default { document.body.classList.add('app-bar') } }, + tokenRefreshed(newAccessToken) { + if (this.isSocketConnected && !this.isSocketAuthenticated) { + console.log('[SOCKET] Re-authenticating socket after token refresh') + this.socket.emit('auth', newAccessToken) + } + }, updateSocketConnectionToast(content, type, timeout) { if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) { - this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false) + const toastUpdateOptions = { + content: content, + options: { + timeout: timeout, + type: type, + closeButton: false, + position: 'bottom-center', + onClose: () => { + this.socketConnectionToastId = null + }, + closeOnClick: timeout !== null + } + } + this.$toast.update(this.socketConnectionToastId, toastUpdateOptions, false) } else { this.socketConnectionToastId = this.$toast[type](content, { position: 'bottom-center', timeout: timeout, closeButton: false, closeOnClick: timeout !== null }) } @@ -109,7 +129,7 @@ export default { this.updateSocketConnectionToast(this.$strings.ToastSocketDisconnected, 'error', null) }, reconnect() { - console.error('[SOCKET] reconnected') + console.log('[SOCKET] reconnected') }, reconnectAttempt(val) { console.log(`[SOCKET] reconnect attempt ${val}`) @@ -120,6 +140,10 @@ export default { reconnectFailed() { console.error('[SOCKET] reconnect failed') }, + authFailed(payload) { + console.error('[SOCKET] auth failed', payload.message) + this.isSocketAuthenticated = false + }, init(payload) { console.log('Init Payload', payload) @@ -127,7 +151,7 @@ export default { this.$store.commit('users/setUsersOnline', payload.usersOnline) } - this.$eventBus.$emit('socket_init') + this.isSocketAuthenticated = true }, streamOpen(stream) { if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamOpen(stream) @@ -354,6 +378,15 @@ export default { this.$store.commit('scanners/removeCustomMetadataProvider', provider) }, initializeSocket() { + if (this.$root.socket) { + // Can happen in dev due to hot reload + console.warn('Socket already initialized') + this.socket = this.$root.socket + this.isSocketConnected = this.$root.socket?.connected + this.isFirstSocketConnection = false + this.socketConnectionToastId = null + return + } this.socket = this.$nuxtSocket({ name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod', persist: 'main', @@ -364,6 +397,7 @@ export default { path: `${this.$config.routerBasePath}/socket.io` }) this.$root.socket = this.socket + this.isSocketAuthenticated = false console.log('Socket initialized') // Pre-defined socket events @@ -377,6 +411,7 @@ export default { // Event received after authorizing socket this.socket.on('init', this.init) + this.socket.on('auth_failed', this.authFailed) // Stream Listeners this.socket.on('stream_open', this.streamOpen) @@ -571,6 +606,7 @@ export default { this.updateBodyClass() this.resize() this.$eventBus.$on('change-lang', this.changeLanguage) + this.$eventBus.$on('token_refreshed', this.tokenRefreshed) window.addEventListener('resize', this.resize) window.addEventListener('keydown', this.keyDown) @@ -594,6 +630,7 @@ export default { }, beforeDestroy() { this.$eventBus.$off('change-lang', this.changeLanguage) + this.$eventBus.$off('token_refreshed', this.tokenRefreshed) window.removeEventListener('resize', this.resize) window.removeEventListener('keydown', this.keyDown) } diff --git a/client/plugins/axios.js b/client/plugins/axios.js index c95067d1..2724acb3 100644 --- a/client/plugins/axios.js +++ b/client/plugins/axios.js @@ -1,4 +1,4 @@ -export default function ({ $axios, store, $config, app }) { +export default function ({ $axios, store, $root, app }) { // Track if we're currently refreshing to prevent multiple refresh attempts let isRefreshing = false let failedQueue = [] @@ -82,6 +82,11 @@ export default function ({ $axios, store, $config, app }) { // Update the token in store and localStorage store.commit('user/setUser', response.user) + // Emit event used to re-authenticate socket in default.vue since $root is not available here + if (app.$eventBus) { + app.$eventBus.$emit('token_refreshed', newAccessToken) + } + // Update the original request with new token if (!originalRequest.headers) { originalRequest.headers = {} diff --git a/server/Auth.js b/server/Auth.js index d2250f17..b2fcebf9 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1054,6 +1054,8 @@ class Auth { /** * Function to validate a jwt token for a given user + * Used to authenticate socket connections + * TODO: Support API keys for web socket connections * * @param {string} token * @returns {Object} tokens data diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index 050e7e2f..68b647ff 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -231,6 +231,9 @@ class SocketAuthority { * When setting up a socket connection the user needs to be associated with a socket id * for this the client will send a 'auth' event that includes the users API token * + * Sends event 'init' to the socket. For admins this contains an array of users online. + * For failed authentication it sends event 'auth_failed' with a message + * * @param {SocketIO.Socket} socket * @param {string} token JWT */ @@ -242,7 +245,7 @@ class SocketAuthority { if (!token_data?.userId) { // Token invalid Logger.error('Cannot validate socket - invalid token') - return socket.emit('invalid_token') + return socket.emit('auth_failed', { message: 'Invalid token' }) } // get the user via the id from the decoded jwt. @@ -250,7 +253,11 @@ class SocketAuthority { if (!user) { // user not found Logger.error('Cannot validate socket - invalid token') - return socket.emit('invalid_token') + return socket.emit('auth_failed', { message: 'Invalid token' }) + } + if (!user.isActive) { + Logger.error('Cannot validate socket - user is not active') + return socket.emit('auth_failed', { message: 'Invalid user' }) } const client = this.clients[socket.id] @@ -260,13 +267,18 @@ class SocketAuthority { } if (client.user !== undefined) { - Logger.debug(`[SocketAuthority] Authenticating socket client already has user`, client.user.username) + if (client.user.id === user.id) { + // Allow re-authentication of a socket to the same user + Logger.info(`[SocketAuthority] Authenticating socket already associated to user "${client.user.username}"`) + } else { + // Allow re-authentication of a socket to a different user but shouldn't happen + Logger.warn(`[SocketAuthority] Authenticating socket to user "${user.username}", but is already associated with a different user "${client.user.username}"`) + } + } else { + Logger.debug(`[SocketAuthority] Authenticating socket to user "${user.username}"`) } client.user = user - - Logger.debug(`[SocketAuthority] User Online ${client.user.username}`) - this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions)) // Update user lastSeen without firing sequelize bulk update hooks From e24eaab3f187eaa8cac3b9bc73d710d1303154f3 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 6 Jul 2025 13:10:14 -0500 Subject: [PATCH 130/171] Log when token expiry is set via env var, api-keys create/update returns with user association --- client/components/tables/ApiKeysTable.vue | 2 +- client/pages/config/api-keys/index.vue | 5 +---- server/Auth.js | 6 ++++++ server/controllers/ApiKeyController.js | 6 ++++++ 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/client/components/tables/ApiKeysTable.vue b/client/components/tables/ApiKeysTable.vue index 037000b5..feab4e68 100644 --- a/client/components/tables/ApiKeysTable.vue +++ b/client/components/tables/ApiKeysTable.vue @@ -93,7 +93,7 @@ export default { this.$toast.error(data.error) } else { this.removeApiKey(apiKey.id) - this.$emit('deleted', apiKey.id) + this.$emit('numApiKeys', this.apiKeys.length) } }) .catch((error) => { diff --git a/client/pages/config/api-keys/index.vue b/client/pages/config/api-keys/index.vue index edc4d59f..2523feed 100644 --- a/client/pages/config/api-keys/index.vue +++ b/client/pages/config/api-keys/index.vue @@ -19,7 +19,7 @@ - +
@@ -50,9 +50,6 @@ export default { this.$refs.apiKeysTable.addApiKey(apiKey) } }, - apiKeyDeleted() { - this.numApiKeys-- - }, apiKeyUpdated(apiKey) { if (this.$refs.apiKeysTable) { this.$refs.apiKeysTable.updateApiKey(apiKey) diff --git a/server/Auth.js b/server/Auth.js index b2fcebf9..c445b45e 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -25,6 +25,12 @@ class Auth { this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours + if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) { + Logger.info(`[Auth] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`) + } + if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) { + Logger.info(`[Auth] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`) + } } /** diff --git a/server/controllers/ApiKeyController.js b/server/controllers/ApiKeyController.js index 0166479f..f60480df 100644 --- a/server/controllers/ApiKeyController.js +++ b/server/controllers/ApiKeyController.js @@ -87,6 +87,9 @@ class ApiKeyController { isActive: !!req.body.isActive, createdByUserId: req.user.id }) + apiKeyInstance.dataValues.user = await apiKeyInstance.getUser({ + attributes: ['id', 'username', 'type'] + }) Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`) return res.json({ @@ -152,6 +155,9 @@ class ApiKeyController { if (hasUpdates) { await apiKey.save() + apiKey.dataValues.user = await apiKey.getUser({ + attributes: ['id', 'username', 'type'] + }) Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`) } else { Logger.info(`[ApiKeyController] No updates needed to API key "${apiKey.name}"`) From 97afd22f81d58c3a9c086404bbdd7b8eb3328c1e Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 6 Jul 2025 16:43:03 -0500 Subject: [PATCH 131/171] Refactor Auth to breakout functions in TokenManager, handle token generation for OIDC --- client/pages/login.vue | 1 + server/Auth.js | 593 ++++++--------------------- server/SocketAuthority.js | 5 +- server/auth/TokenManager.js | 379 +++++++++++++++++ server/controllers/UserController.js | 4 +- server/models/User.js | 94 ++++- 6 files changed, 603 insertions(+), 473 deletions(-) create mode 100644 server/auth/TokenManager.js diff --git a/client/pages/login.vue b/client/pages/login.vue index 71fa8c2b..5d447ed9 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -304,6 +304,7 @@ export default { } }, async mounted() { + // Token passed as query parameter after successful oidc login if (this.$route.query?.setToken) { localStorage.setItem('token', this.$route.query.setToken) } diff --git a/server/Auth.js b/server/Auth.js index c445b45e..a4c52781 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,16 +1,17 @@ +const { Request, Response, NextFunction } = require('express') const axios = require('axios') const passport = require('passport') -const { Op } = require('sequelize') -const { Request, Response, NextFunction } = require('express') -const bcrypt = require('./libs/bcryptjs') -const jwt = require('./libs/jsonwebtoken') -const requestIp = require('./libs/requestIp') -const LocalStrategy = require('./libs/passportLocal') +const OpenIDClient = require('openid-client') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt -const OpenIDClient = require('openid-client') + const Database = require('./Database') const Logger = require('./Logger') +const TokenManager = require('./auth/TokenManager') + +const bcrypt = require('./libs/bcryptjs') +const requestIp = require('./libs/requestIp') +const LocalStrategy = require('./libs/passportLocal') const { escapeRegExp } = require('./utils') /** @@ -23,26 +24,23 @@ class Auth { const escapedRouterBasePath = escapeRegExp(global.RouterBasePath) this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)] - this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days - this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours - if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) { - Logger.info(`[Auth] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`) - } - if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) { - Logger.info(`[Auth] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`) - } + this.tokenManager = new TokenManager() } /** * Checks if the request should not be authenticated. * @param {Request} req * @returns {boolean} - * @private */ authNotNeeded(req) { return req.method === 'GET' && this.ignorePatterns.some((pattern) => pattern.test(req.path)) } + /** + * Middleware to register passport in express-session + * + * @param {function} middleware + */ ifAuthNeeded(middleware) { return (req, res, next) => { if (this.authNotNeeded(req)) { @@ -52,6 +50,67 @@ class Auth { } } + /** + * middleware to use in express to only allow authenticated users. + * + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + isAuthenticated(req, res, next) { + return passport.authenticate('jwt', { session: false })(req, res, next) + } + + /** + * Generate a token which is used to encrpt/protect the jwts. + */ + async initTokenSecret() { + return this.tokenManager.initTokenSecret() + } + + /** + * Function to generate a jwt token for a given user + * TODO: Old method with no expiration + * @deprecated + * + * @param {{ id:string, username:string }} user + * @returns {string} + */ + generateAccessToken(user) { + return this.tokenManager.generateAccessToken(user) + } + + /** + * Invalidate all JWT sessions for a given user + * If user is current user and refresh token is valid, rotate tokens for the current session + * + * @param {import('./models/User')} user + * @param {Request} req + * @param {Response} res + * @returns {Promise} accessToken only if user is current user and refresh token is valid + */ + async invalidateJwtSessionsForUser(user, req, res) { + return this.tokenManager.invalidateJwtSessionsForUser(user, req, res) + } + + /** + * Return the login info payload for a user + * + * @param {import('./models/User')} user + * @returns {Promise} jsonPayload + */ + async getUserLoginResponsePayload(user) { + const libraryIds = await Database.libraryModel.getAllLibraryIds() + return { + user: user.toOldJSONForBrowser(), + userDefaultLibraryId: user.getDefaultLibraryId(libraryIds), + serverSettings: Database.serverSettings.toJSONForBrowser(), + ereaderDevices: Database.emailSettings.getEReaderDevices(user), + Source: global.Source + } + } + + // #region Passport strategies /** * Inializes all passportjs strategies and other passportjs ralated initialization. */ @@ -75,7 +134,7 @@ class Auth { // Handle expiration manaully in order to disable api keys that are expired ignoreExpiration: true }, - this.jwtAuthCheck.bind(this) + this.tokenManager.jwtAuthCheck.bind(this) ) ) @@ -161,7 +220,7 @@ class Auth { throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`) } - let user = await this.findOrCreateUser(userinfo) + let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo, this) if (!user?.isActive) { throw new Error('User not active or not found') @@ -183,94 +242,7 @@ class Auth { ) ) } - - /** - * Finds an existing user by OpenID subject identifier, or by email/username based on server settings, - * or creates a new user if configured to do so. - * - * @returns {Promise} - */ - async findOrCreateUser(userinfo) { - let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub) - - // Matched by sub - if (user) { - Logger.debug(`[Auth] openid: User found by sub`) - return user - } - - // Match existing user by email - if (Database.serverSettings.authOpenIDMatchExistingBy === 'email') { - if (userinfo.email) { - // Only disallow when email_verified explicitly set to false (allow both if not set or true) - if (userinfo.email_verified === false) { - Logger.warn(`[Auth] openid: User not found and email "${userinfo.email}" is not verified`) - return null - } else { - Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`) - user = await Database.userModel.getUserByEmail(userinfo.email) - - if (user?.authOpenIDSub) { - Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`) - return null // User is linked to a different OpenID subject; do not proceed. - } - } - } else { - Logger.warn(`[Auth] openid: User not found and no email in userinfo`) - // We deny login, because if the admin whishes to match email, it makes sense to require it - return null - } - } - // Match existing user by username - else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username') { - let username - - if (userinfo.preferred_username) { - Logger.info(`[Auth] openid: User not found, checking existing with userinfo.preferred_username "${userinfo.preferred_username}"`) - username = userinfo.preferred_username - } else if (userinfo.username) { - Logger.info(`[Auth] openid: User not found, checking existing with userinfo.username "${userinfo.username}"`) - username = userinfo.username - } else { - Logger.warn(`[Auth] openid: User not found and neither preferred_username nor username in userinfo`) - return null - } - - user = await Database.userModel.getUserByUsername(username) - - if (user?.authOpenIDSub) { - Logger.warn(`[Auth] openid: User found with username "${username}" but is already matched with sub "${user.authOpenIDSub}"`) - return null // User is linked to a different OpenID subject; do not proceed. - } - } - - // Found existing user via email or username - if (user) { - if (!user.isActive) { - Logger.warn(`[Auth] openid: User found but is not active`) - return null - } - - // Update user with OpenID sub - if (!user.extraData) user.extraData = {} - user.extraData.authOpenIDSub = userinfo.sub - user.changed('extraData', true) - await user.save() - - Logger.debug(`[Auth] openid: User found by email/username`) - return user - } - - // If no existing user was matched, auto-register if configured - if (Database.serverSettings.authOpenIDAutoRegister) { - Logger.info(`[Auth] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo) - user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo, this) - return user - } - - Logger.warn(`[Auth] openid: User not found and auto-register is disabled`) - return null - } + // #endregion /** * Validates the presence and content of the group claim in userinfo. @@ -418,22 +390,6 @@ class Auth { res.cookie('auth_method', authMethod, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true }) } - /** - * Sets the refresh token cookie - * @param {Request} req - * @param {Response} res - * @param {string} refreshToken - */ - setRefreshTokenCookie(req, res, refreshToken) { - res.cookie('refresh_token', refreshToken, { - httpOnly: true, - secure: req.secure || req.get('x-forwarded-proto') === 'https', - sameSite: 'lax', - maxAge: this.RefreshTokenExpiry * 1000, - path: '/' - }) - } - /** * Informs the client in the right mode about a successfull login and the token * (clients choise is restored from cookies). @@ -442,25 +398,56 @@ class Auth { * @param {Response} res */ async handleLoginSuccessBasedOnCookie(req, res) { - // get userLogin json (information about the user, server and the session) - const data_json = await this.getUserLoginResponsePayload(req.user) + // Handle token generation and get userResponse object + // TODO: where to check if refresh tokens should be returned? + const userResponse = await this.handleLoginSuccess(req, res, false) if (this.isAuthMethodAPIBased(req.cookies.auth_method)) { // REST request - send data - res.json(data_json) + res.json(userResponse) } else { // UI request -> check if we have a callback url // TODO: do we want to somehow limit the values for auth_cb? if (req.cookies.auth_cb) { let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : '' // UI request -> redirect to auth_cb url and send the jwt token as parameter - res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}${stateQuery}`) + res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.accessToken}${stateQuery}`) } else { res.status(400).send('No callback or already expired') } } } + /** + * After login success from local or oidc + * req.user is set by passport.authenticate + * + * attaches the access token to the user in the response + * if returnTokens is true, also attaches the refresh token to the user in the response + * + * if returnTokens is false, sets the refresh token cookie + * + * @param {Request} req + * @param {Response} res + * @param {boolean} returnTokens + */ + async handleLoginSuccess(req, res, returnTokens = false) { + // Create tokens and session + const { accessToken, refreshToken } = await this.tokenManager.createTokensAndSession(req.user, req) + + const userResponse = await this.getUserLoginResponsePayload(req.user) + + userResponse.user.refreshToken = returnTokens ? refreshToken : null + userResponse.user.accessToken = accessToken + + if (!returnTokens) { + this.tokenManager.setRefreshTokenCookie(req, res, refreshToken) + } + + return userResponse + } + + // #region Auth routes /** * Creates all (express) routes required for authentication. * @@ -469,19 +456,10 @@ class Auth { async initAuthRoutes(router) { // Local strategy login route (takes username and password) router.post('/login', passport.authenticate('local'), async (req, res) => { - // return the user login response json if the login was successfull - const userResponse = await this.getUserLoginResponsePayload(req.user) - // Check if mobile app wants refresh token in response const returnTokens = req.query.return_tokens === 'true' || req.headers['x-return-tokens'] === 'true' - userResponse.user.refreshToken = returnTokens ? req.user.refreshToken : null - userResponse.user.accessToken = req.user.accessToken - - if (!returnTokens) { - this.setRefreshTokenCookie(req, res, req.user.refreshToken) - } - + const userResponse = await this.handleLoginSuccess(req, res, returnTokens) res.json(userResponse) }) @@ -504,67 +482,16 @@ class Auth { Logger.debug(`[Auth] refreshing token. shouldReturnRefreshToken: ${shouldReturnRefreshToken}`) - try { - // Verify the refresh token - const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret) - - if (decoded.type !== 'refresh') { - Logger.error(`[Auth] Failed to refresh token. Invalid token type: ${decoded.type}`) - return res.status(401).json({ error: 'Invalid token type' }) - } - - const session = await Database.sessionModel.findOne({ - where: { refreshToken: refreshToken } - }) - - if (!session) { - Logger.error(`[Auth] Failed to refresh token. Session not found for refresh token: ${refreshToken}`) - return res.status(401).json({ error: 'Invalid refresh token' }) - } - - // Check if session is expired in database - if (session.expiresAt < new Date()) { - Logger.info(`[Auth] Session expired in database, cleaning up`) - await session.destroy() - return res.status(401).json({ error: 'Refresh token expired' }) - } - - const user = await Database.userModel.getUserById(decoded.userId) - if (!user?.isActive) { - Logger.error(`[Auth] Failed to refresh token. User not found or inactive for user id: ${decoded.userId}`) - return res.status(401).json({ error: 'User not found or inactive' }) - } - - const newTokens = await this.rotateTokensForSession(session, user, req, res) - - const userResponse = await this.getUserLoginResponsePayload(user) - - userResponse.user.accessToken = newTokens.accessToken - userResponse.user.refreshToken = shouldReturnRefreshToken ? newTokens.refreshToken : null - res.json(userResponse) - } catch (error) { - if (error.name === 'TokenExpiredError') { - Logger.info(`[Auth] Refresh token expired, cleaning up session`) - - // Clean up the expired session from database - try { - await Database.sessionModel.destroy({ - where: { refreshToken: refreshToken } - }) - Logger.info(`[Auth] Expired session cleaned up`) - } catch (cleanupError) { - Logger.error(`[Auth] Error cleaning up expired session: ${cleanupError.message}`) - } - - return res.status(401).json({ error: 'Refresh token expired' }) - } else if (error.name === 'JsonWebTokenError') { - Logger.error(`[Auth] Invalid refresh token format: ${error.message}`) - return res.status(401).json({ error: 'Invalid refresh token' }) - } else { - Logger.error(`[Auth] Refresh token error: ${error.message}`) - return res.status(401).json({ error: 'Invalid refresh token' }) - } + const refreshResponse = await this.tokenManager.handleRefreshToken(refreshToken, req, res) + if (refreshResponse.error) { + return res.status(401).json({ error: refreshResponse.error }) } + + const userResponse = await this.getUserLoginResponsePayload(refreshResponse.user) + + userResponse.user.accessToken = refreshResponse.accessToken + userResponse.user.refreshToken = shouldReturnRefreshToken ? refreshResponse.refreshToken : null + res.json(userResponse) }) // openid strategy login route (this redirects to the configured openid login provider) @@ -906,255 +833,9 @@ class Auth { }) }) } + // #endregion - /** - * middleware to use in express to only allow authenticated users. - * - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next - */ - isAuthenticated(req, res, next) { - return passport.authenticate('jwt', { session: false })(req, res, next) - } - - /** - * Function to generate a jwt token for a given user - * TODO: Old method with no expiration - * - * @param {{ id:string, username:string }} user - * @returns {string} token - */ - generateAccessToken(user) { - return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret) - } - - /** - * Generate access token for a given user - * - * @param {{ id:string, username:string }} user - * @returns {Promise} - */ - generateTempAccessToken(user) { - return new Promise((resolve) => { - jwt.sign({ userId: user.id, username: user.username, type: 'access' }, global.ServerSettings.tokenSecret, { expiresIn: this.AccessTokenExpiry }, (err, token) => { - if (err) { - Logger.error(`[Auth] Error generating access token for user ${user.id}: ${err}`) - resolve(null) - } else { - resolve(token) - } - }) - }) - } - - /** - * Generate refresh token for a given user - * - * @param {{ id:string, username:string }} user - * @returns {Promise} - */ - generateRefreshToken(user) { - return new Promise((resolve) => { - jwt.sign({ userId: user.id, username: user.username, type: 'refresh' }, global.ServerSettings.tokenSecret, { expiresIn: this.RefreshTokenExpiry }, (err, token) => { - if (err) { - Logger.error(`[Auth] Error generating refresh token for user ${user.id}: ${err}`) - resolve(null) - } else { - resolve(token) - } - }) - }) - } - - /** - * Create tokens and session for a given user - * - * @param {{ id:string, username:string }} user - * @param {Request} req - * @returns {Promise<{ accessToken:string, refreshToken:string, session:import('./models/Session') }>} - */ - async createTokensAndSession(user, req) { - const ipAddress = requestIp.getClientIp(req) - const userAgent = req.headers['user-agent'] - const [accessToken, refreshToken] = await Promise.all([this.generateTempAccessToken(user), this.generateRefreshToken(user)]) - - // Calculate expiration time for the refresh token - const expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) - - const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt) - user.accessToken = accessToken - // Store refresh token on user object for cookie setting - user.refreshToken = refreshToken - return { accessToken, refreshToken, session } - } - - /** - * Rotate tokens for a given session - * - * @param {import('./models/Session')} session - * @param {import('./models/User')} user - * @param {Request} req - * @param {Response} res - * @returns {Promise<{ accessToken:string, refreshToken:string }>} - */ - async rotateTokensForSession(session, user, req, res) { - // Generate new tokens - const [newAccessToken, newRefreshToken] = await Promise.all([this.generateTempAccessToken(user), this.generateRefreshToken(user)]) - - // Calculate new expiration time - const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) - - // Update the session with the new refresh token and expiration - session.refreshToken = newRefreshToken - session.expiresAt = newExpiresAt - await session.save() - - // Set new refresh token cookie - this.setRefreshTokenCookie(req, res, newRefreshToken) - - return { - accessToken: newAccessToken, - refreshToken: newRefreshToken - } - } - - /** - * Invalidate all JWT sessions for a given user - * If user is current user and refresh token is valid, rotate tokens for the current session - * - * @param {Request} req - * @param {Response} res - * @returns {Promise} accessToken only if user is current user and refresh token is valid - */ - async invalidateJwtSessionsForUser(user, req, res) { - const currentRefreshToken = req.cookies.refresh_token - if (req.user.id === user.id && currentRefreshToken) { - // Current user is the same as the user to invalidate sessions for - // So rotate token for current session - const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } }) - if (currentSession) { - const newTokens = await this.rotateTokensForSession(currentSession, user, req, res) - - // Invalidate all sessions for the user except the current one - await Database.sessionModel.destroy({ - where: { - id: { - [Op.ne]: currentSession.id - }, - userId: user.id - } - }) - - return newTokens.accessToken - } else { - Logger.error(`[Auth] No session found to rotate tokens for refresh token ${currentRefreshToken}`) - } - } - - // Current user is not the same as the user to invalidate sessions for (or no refresh token) - // So invalidate all sessions for the user - await Database.sessionModel.destroy({ where: { userId: user.id } }) - return null - } - - /** - * Function to validate a jwt token for a given user - * Used to authenticate socket connections - * TODO: Support API keys for web socket connections - * - * @param {string} token - * @returns {Object} tokens data - */ - static validateAccessToken(token) { - try { - return jwt.verify(token, global.ServerSettings.tokenSecret) - } catch (err) { - return null - } - } - - /** - * Generate a token which is used to encrpt/protect the jwts. - */ - async initTokenSecret() { - if (process.env.TOKEN_SECRET) { - // User can supply their own token secret - Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET - } else { - Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') - } - await Database.updateServerSettings() - - // TODO: Old method of non-expiring tokens - // New token secret creation added in v2.1.0 so generate new API tokens for each user - const users = await Database.userModel.findAll({ - attributes: ['id', 'username', 'token'] - }) - if (users.length) { - for (const user of users) { - user.token = await this.generateAccessToken(user) - await user.save({ hooks: false }) - } - } - } - - /** - * Checks if the user or api key in the validated jwt_payload exists and is active. - * @param {Object} jwt_payload - * @param {function} done - */ - async jwtAuthCheck(jwt_payload, done) { - if (jwt_payload.type === 'api') { - const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId) - - if (!apiKey?.isActive) { - done(null, null) - return - } - - // Check if the api key is expired and deactivate it - if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) { - done(null, null) - - apiKey.isActive = false - await apiKey.save() - Logger.info(`[Auth] API key ${apiKey.id} is expired - deactivated`) - return - } - - const user = await Database.userModel.getUserById(apiKey.userId) - done(null, user) - } else { - // Check if the jwt is expired - if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) { - done(null, null) - return - } - - // load user by id from the jwt token - const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId) - - if (!user?.isActive) { - // deny login - done(null, null) - return - } - - // TODO: Temporary flag to report old tokens to users - // May be a better place for this but here means we dont have to decode the token again - if (!jwt_payload.exp && !user.isOldToken) { - Logger.debug(`[Auth] User ${user.username} is using an access token without an expiration`) - user.isOldToken = true - } else if (jwt_payload.exp && user.isOldToken !== undefined) { - delete user.isOldToken - } - - // approve login - done(null, user) - } - } - + // #region Local Auth /** * Checks if a username and password tuple is valid and the user active. * @param {Request} req @@ -1187,9 +868,6 @@ class Auth { // approve login Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) - // Create tokens and session, updates user.accessToken and user.refreshToken - await this.createTokensAndSession(user, req) - done(null, user) return } else if (!user.pash) { @@ -1204,9 +882,6 @@ class Auth { // approve login Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) - // Create tokens and session, updates user.accessToken and user.refreshToken - await this.createTokensAndSession(user, req) - done(null, user) return } @@ -1244,23 +919,6 @@ class Auth { }) } - /** - * Return the login info payload for a user - * - * @param {import('./models/User')} user - * @returns {Promise} jsonPayload - */ - async getUserLoginResponsePayload(user) { - const libraryIds = await Database.libraryModel.getAllLibraryIds() - return { - user: user.toOldJSONForBrowser(), - userDefaultLibraryId: user.getDefaultLibraryId(libraryIds), - serverSettings: Database.serverSettings.toJSONForBrowser(), - ereaderDevices: Database.emailSettings.getEReaderDevices(user), - Source: global.Source - } - } - /** * * @param {string} password @@ -1322,6 +980,7 @@ class Auth { }) } } + // #endregion } module.exports = Auth diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index 68b647ff..da31ba4a 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -1,7 +1,7 @@ const SocketIO = require('socket.io') const Logger = require('./Logger') const Database = require('./Database') -const Auth = require('./Auth') +const TokenManager = require('./auth/TokenManager') /** * @typedef SocketClient @@ -240,7 +240,8 @@ class SocketAuthority { async authenticateSocket(socket, token) { // we don't use passport to authenticate the jwt we get over the socket connection. // it's easier to directly verify/decode it. - const token_data = Auth.validateAccessToken(token) + // TODO: Support API keys for web socket connections + const token_data = TokenManager.validateAccessToken(token) if (!token_data?.userId) { // Token invalid diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js new file mode 100644 index 00000000..cc4783b5 --- /dev/null +++ b/server/auth/TokenManager.js @@ -0,0 +1,379 @@ +const { Op } = require('sequelize') + +const Database = require('../Database') +const Logger = require('../Logger') + +const requestIp = require('../libs/requestIp') +const jwt = require('../libs/jsonwebtoken') + +class TokenManager { + constructor() { + this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days + this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours + + if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) { + Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`) + } + if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) { + Logger.info(`[TokenManager] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`) + } + } + + /** + * Generate a token which is used to encrypt/protect the jwts. + */ + async initTokenSecret() { + if (process.env.TOKEN_SECRET) { + // User can supply their own token secret + Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET + } else { + Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') + } + await Database.updateServerSettings() + + // TODO: Old method of non-expiring tokens + // New token secret creation added in v2.1.0 so generate new API tokens for each user + const users = await Database.userModel.findAll({ + attributes: ['id', 'username', 'token'] + }) + if (users.length) { + for (const user of users) { + user.token = this.generateAccessToken(user) + await user.save({ hooks: false }) + } + } + } + + /** + * Sets the refresh token cookie + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {string} refreshToken + */ + setRefreshTokenCookie(req, res, refreshToken) { + res.cookie('refresh_token', refreshToken, { + httpOnly: true, + secure: req.secure || req.get('x-forwarded-proto') === 'https', + sameSite: 'lax', + maxAge: this.RefreshTokenExpiry * 1000, + path: '/' + }) + } + + /** + * Function to validate a jwt token for a given user + * Used to authenticate socket connections + * TODO: Support API keys for web socket connections + * + * @param {string} token + * @returns {Object} tokens data + */ + static validateAccessToken(token) { + try { + return jwt.verify(token, global.ServerSettings.tokenSecret) + } catch (err) { + return null + } + } + + /** + * Function to generate a jwt token for a given user + * TODO: Old method with no expiration + * @deprecated + * + * @param {{ id:string, username:string }} user + * @returns {string} + */ + generateAccessToken(user) { + return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret) + } + + /** + * Generate access token for a given user + * + * @param {{ id:string, username:string }} user + * @returns {string} + */ + generateTempAccessToken(user) { + const payload = { + userId: user.id, + username: user.username, + type: 'access' + } + const options = { + expiresIn: this.AccessTokenExpiry + } + try { + return jwt.sign(payload, global.ServerSettings.tokenSecret, options) + } catch (error) { + Logger.error(`[TokenManager] Error generating access token for user ${user.id}: ${error}`) + return null + } + } + + /** + * Generate refresh token for a given user + * + * @param {{ id:string, username:string }} user + * @returns {string} + */ + generateRefreshToken(user) { + const payload = { + userId: user.id, + username: user.username, + type: 'refresh' + } + const options = { + expiresIn: this.RefreshTokenExpiry + } + try { + return jwt.sign(payload, global.ServerSettings.tokenSecret, options) + } catch (error) { + Logger.error(`[TokenManager] Error generating refresh token for user ${user.id}: ${error}`) + return null + } + } + + /** + * Create tokens and session for a given user + * + * @param {{ id:string, username:string }} user + * @param {import('express').Request} req + * @returns {Promise<{ accessToken:string, refreshToken:string, session:import('../models/Session') }>} + */ + async createTokensAndSession(user, req) { + const ipAddress = requestIp.getClientIp(req) + const userAgent = req.headers['user-agent'] + const accessToken = this.generateTempAccessToken(user) + const refreshToken = this.generateRefreshToken(user) + + // Calculate expiration time for the refresh token + const expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) + + const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt) + + return { + accessToken, + refreshToken, + session + } + } + + /** + * Rotate tokens for a given session + * + * @param {import('../models/Session')} session + * @param {import('../models/User')} user + * @param {import('express').Request} req + * @param {import('express').Response} res + * @returns {Promise<{ accessToken:string, refreshToken:string }>} + */ + async rotateTokensForSession(session, user, req, res) { + // Generate new tokens + const newAccessToken = this.generateTempAccessToken(user) + const newRefreshToken = this.generateRefreshToken(user) + + // Calculate new expiration time + const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) + + // Update the session with the new refresh token and expiration + session.refreshToken = newRefreshToken + session.expiresAt = newExpiresAt + await session.save() + + // Set new refresh token cookie + this.setRefreshTokenCookie(req, res, newRefreshToken) + + return { + accessToken: newAccessToken, + refreshToken: newRefreshToken + } + } + + /** + * Check if the jwt is valid + * + * @param {Object} jwt_payload + * @param {Function} done - passportjs callback + */ + async jwtAuthCheck(jwt_payload, done) { + if (jwt_payload.type === 'api') { + // Api key based authentication + const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId) + + if (!apiKey?.isActive) { + done(null, null) + return + } + + // Check if the api key is expired and deactivate it + if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) { + done(null, null) + + apiKey.isActive = false + await apiKey.save() + Logger.info(`[TokenManager] API key ${apiKey.id} is expired - deactivated`) + return + } + + const user = await Database.userModel.getUserById(apiKey.userId) + done(null, user) + } else { + // JWT based authentication + + // Check if the jwt is expired + if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) { + done(null, null) + return + } + + // load user by id from the jwt token + const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId) + + if (!user?.isActive) { + // deny login + done(null, null) + return + } + + // TODO: Temporary flag to report old tokens to users + // May be a better place for this but here means we dont have to decode the token again + if (!jwt_payload.exp && !user.isOldToken) { + Logger.debug(`[TokenManager] User ${user.username} is using an access token without an expiration`) + user.isOldToken = true + } else if (jwt_payload.exp && user.isOldToken !== undefined) { + delete user.isOldToken + } + + // approve login + done(null, user) + } + } + + /** + * Handle refresh token + * + * @param {string} refreshToken + * @param {import('express').Request} req + * @param {import('express').Response} res + * @returns {Promise<{ accessToken?:string, refreshToken?:string, user?:import('../models/User'), error?:string }>} + */ + async handleRefreshToken(refreshToken, req, res) { + try { + // Verify the refresh token + const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret) + + if (decoded.type !== 'refresh') { + Logger.error(`[TokenManager] Failed to refresh token. Invalid token type: ${decoded.type}`) + return { + error: 'Invalid token type' + } + } + + const session = await Database.sessionModel.findOne({ + where: { refreshToken: refreshToken } + }) + + if (!session) { + Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`) + return { + error: 'Invalid refresh token' + } + } + + // Check if session is expired in database + if (session.expiresAt < new Date()) { + Logger.info(`[TokenManager] Session expired in database, cleaning up`) + await session.destroy() + return { + error: 'Refresh token expired' + } + } + + const user = await Database.userModel.getUserById(decoded.userId) + if (!user?.isActive) { + Logger.error(`[TokenManager] Failed to refresh token. User not found or inactive for user id: ${decoded.userId}`) + return { + error: 'User not found or inactive' + } + } + + const newTokens = await this.rotateTokensForSession(session, user, req, res) + return { + accessToken: newTokens.accessToken, + refreshToken: newTokens.refreshToken, + user + } + } catch (error) { + if (error.name === 'TokenExpiredError') { + Logger.info(`[TokenManager] Refresh token expired, cleaning up session`) + + // Clean up the expired session from database + try { + await Database.sessionModel.destroy({ + where: { refreshToken: refreshToken } + }) + Logger.info(`[TokenManager] Expired session cleaned up`) + } catch (cleanupError) { + Logger.error(`[TokenManager] Error cleaning up expired session: ${cleanupError.message}`) + } + + return { + error: 'Refresh token expired' + } + } else if (error.name === 'JsonWebTokenError') { + Logger.error(`[TokenManager] Invalid refresh token format: ${error.message}`) + return { + error: 'Invalid refresh token' + } + } else { + Logger.error(`[TokenManager] Refresh token error: ${error.message}`) + return { + error: 'Invalid refresh token' + } + } + } + } + + /** + * Invalidate all JWT sessions for a given user + * If user is current user and refresh token is valid, rotate tokens for the current session + * + * @param {import('../models/User')} user + * @param {import('express').Request} req + * @param {import('express').Response} res + * @returns {Promise} accessToken only if user is current user and refresh token is valid + */ + async invalidateJwtSessionsForUser(user, req, res) { + const currentRefreshToken = req.cookies.refresh_token + if (req.user.id === user.id && currentRefreshToken) { + // Current user is the same as the user to invalidate sessions for + // So rotate token for current session + const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } }) + if (currentSession) { + const newTokens = await this.rotateTokensForSession(currentSession, user, req, res) + + // Invalidate all sessions for the user except the current one + await Database.sessionModel.destroy({ + where: { + id: { + [Op.ne]: currentSession.id + }, + userId: user.id + } + }) + + return newTokens.accessToken + } else { + Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`) + } + } + + // Current user is not the same as the user to invalidate sessions for (or no refresh token) + // So invalidate all sessions for the user + await Database.sessionModel.destroy({ where: { userId: user.id } }) + return null + } +} + +module.exports = TokenManager diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index 48c98150..2ed92616 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -128,7 +128,7 @@ class UserController { const userId = uuidv4() const pash = await this.auth.hashPass(req.body.password) - const token = await this.auth.generateAccessToken({ id: userId, username: req.body.username }) + const token = this.auth.generateAccessToken({ id: userId, username: req.body.username }) const userType = req.body.type || 'user' // librariesAccessible and itemTagsSelected can be on req.body or req.body.permissions @@ -327,7 +327,7 @@ class UserController { if (hasUpdates) { if (shouldUpdateToken) { - user.token = await this.auth.generateAccessToken(user) + user.token = this.auth.generateAccessToken(user) Logger.info(`[UserController] User ${user.username} has generated a new api token`) } diff --git a/server/models/User.js b/server/models/User.js index 154587a7..3f06b238 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -190,7 +190,7 @@ class User extends Model { static async createRootUser(username, pash, auth) { const userId = uuidv4() - const token = await auth.generateAccessToken({ id: userId, username }) + const token = auth.generateAccessToken({ id: userId, username }) const newUser = { id: userId, @@ -208,6 +208,96 @@ class User extends Model { return this.create(newUser) } + /** + * Finds an existing user by OpenID subject identifier, or by email/username based on server settings, + * or creates a new user if configured to do so. + * + * @param {Object} userinfo + * @param {import('../Auth')} auth + * @returns {Promise} + */ + static async findOrCreateUserFromOpenIdUserInfo(userinfo, auth) { + let user = await this.getUserByOpenIDSub(userinfo.sub) + + // Matched by sub + if (user) { + Logger.debug(`[User] openid: User found by sub`) + return user + } + + // Match existing user by email + if (global.ServerSettings.authOpenIDMatchExistingBy === 'email') { + if (userinfo.email) { + // Only disallow when email_verified explicitly set to false (allow both if not set or true) + if (userinfo.email_verified === false) { + Logger.warn(`[User] openid: User not found and email "${userinfo.email}" is not verified`) + return null + } else { + Logger.info(`[User] openid: User not found, checking existing with email "${userinfo.email}"`) + user = await this.getUserByEmail(userinfo.email) + + if (user?.authOpenIDSub) { + Logger.warn(`[User] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`) + return null // User is linked to a different OpenID subject; do not proceed. + } + } + } else { + Logger.warn(`[User] openid: User not found and no email in userinfo`) + // We deny login, because if the admin whishes to match email, it makes sense to require it + return null + } + } + // Match existing user by username + else if (global.ServerSettings.authOpenIDMatchExistingBy === 'username') { + let username + + if (userinfo.preferred_username) { + Logger.info(`[User] openid: User not found, checking existing with userinfo.preferred_username "${userinfo.preferred_username}"`) + username = userinfo.preferred_username + } else if (userinfo.username) { + Logger.info(`[User] openid: User not found, checking existing with userinfo.username "${userinfo.username}"`) + username = userinfo.username + } else { + Logger.warn(`[User] openid: User not found and neither preferred_username nor username in userinfo`) + return null + } + + user = await this.getUserByUsername(username) + + if (user?.authOpenIDSub) { + Logger.warn(`[User] openid: User found with username "${username}" but is already matched with sub "${user.authOpenIDSub}"`) + return null // User is linked to a different OpenID subject; do not proceed. + } + } + + // Found existing user via email or username + if (user) { + if (!user.isActive) { + Logger.warn(`[User] openid: User found but is not active`) + return null + } + + // Update user with OpenID sub + if (!user.extraData) user.extraData = {} + user.extraData.authOpenIDSub = userinfo.sub + user.changed('extraData', true) + await user.save() + + Logger.debug(`[User] openid: User found by email/username`) + return user + } + + // If no existing user was matched, auto-register if configured + if (global.ServerSettings.authOpenIDAutoRegister) { + Logger.info(`[User] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo) + user = await this.createUserFromOpenIdUserInfo(userinfo, auth) + return user + } + + Logger.warn(`[User] openid: User not found and auto-register is disabled`) + return null + } + /** * Create user from openid userinfo * @param {Object} userinfo @@ -220,7 +310,7 @@ class User extends Model { const username = userinfo.preferred_username || userinfo.name || userinfo.sub const email = userinfo.email && userinfo.email_verified ? userinfo.email : null - const token = await auth.generateAccessToken({ id: userId, username }) + const token = auth.generateAccessToken({ id: userId, username }) const newUser = { id: userId, From ce803dd6de252ca7aaf65055c7f566136093d3c2 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 6 Jul 2025 17:39:03 -0500 Subject: [PATCH 132/171] Use getServerSetting to ensure serverSettings is set before accessing --- client/components/cards/LazyBookCard.vue | 2 +- client/components/cards/LazySeriesCard.vue | 2 +- client/components/modals/BookmarksModal.vue | 4 ++-- client/components/modals/ListeningSessionModal.vue | 4 ++-- client/components/modals/ShareModal.vue | 2 +- client/components/modals/changelog/ViewModal.vue | 2 +- client/components/tables/BackupsTable.vue | 4 ++-- client/components/tables/UsersTable.vue | 4 ++-- client/components/tables/podcast/LazyEpisodeRow.vue | 2 +- client/components/tables/podcast/LazyEpisodesTable.vue | 4 ++-- client/components/widgets/CronExpressionBuilder.vue | 2 +- client/pages/config/rss-feeds.vue | 4 ++-- client/pages/config/sessions.vue | 4 ++-- client/pages/config/users/_id/index.vue | 4 ++-- client/pages/config/users/_id/sessions.vue | 4 ++-- client/pages/item/_id/index.vue | 2 +- client/pages/library/_library/podcast/latest.vue | 2 +- 17 files changed, 26 insertions(+), 26 deletions(-) diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index 35c959fa..41b73310 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -198,7 +198,7 @@ export default { return this.store.getters['user/getSizeMultiplier'] }, dateFormat() { - return this.store.state.serverSettings.dateFormat + return this.store.getters['getServerSetting']('dateFormat') }, _libraryItem() { return this.libraryItem || {} diff --git a/client/components/cards/LazySeriesCard.vue b/client/components/cards/LazySeriesCard.vue index 3532095b..34cea7e2 100644 --- a/client/components/cards/LazySeriesCard.vue +++ b/client/components/cards/LazySeriesCard.vue @@ -71,7 +71,7 @@ export default { return this.height * this.sizeMultiplier }, dateFormat() { - return this.store.state.serverSettings.dateFormat + return this.store.getters['getServerSetting']('dateFormat') }, labelFontSize() { if (this.width < 160) return 0.75 diff --git a/client/components/modals/BookmarksModal.vue b/client/components/modals/BookmarksModal.vue index de8c72b7..d84a8ed8 100644 --- a/client/components/modals/BookmarksModal.vue +++ b/client/components/modals/BookmarksModal.vue @@ -79,10 +79,10 @@ export default { return !this.bookmarks.find((bm) => Math.abs(this.currentTime - bm.time) < 1) }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') } }, methods: { diff --git a/client/components/modals/ListeningSessionModal.vue b/client/components/modals/ListeningSessionModal.vue index a2469836..ecf00f78 100644 --- a/client/components/modals/ListeningSessionModal.vue +++ b/client/components/modals/ListeningSessionModal.vue @@ -159,10 +159,10 @@ export default { return 'Unknown' }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') }, isOpenSession() { return !!this._session.open diff --git a/client/components/modals/ShareModal.vue b/client/components/modals/ShareModal.vue index 24994b22..bd0c9acf 100644 --- a/client/components/modals/ShareModal.vue +++ b/client/components/modals/ShareModal.vue @@ -144,7 +144,7 @@ export default { expirationDateString() { if (!this.expireDurationSeconds) return this.$strings.LabelPermanent const dateMs = Date.now() + this.expireDurationSeconds * 1000 - return this.$formatDatetime(dateMs, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat) + return this.$formatDatetime(dateMs, this.$store.getters['getServerSetting']('dateFormat'), this.$store.getters['getServerSetting']('timeFormat')) } }, methods: { diff --git a/client/components/modals/changelog/ViewModal.vue b/client/components/modals/changelog/ViewModal.vue index 1b332a1d..939ee71d 100644 --- a/client/components/modals/changelog/ViewModal.vue +++ b/client/components/modals/changelog/ViewModal.vue @@ -40,7 +40,7 @@ export default { } }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, releasesToShow() { return this.versionData?.releasesToShow || [] diff --git a/client/components/tables/BackupsTable.vue b/client/components/tables/BackupsTable.vue index 769c8d25..f769abdb 100644 --- a/client/components/tables/BackupsTable.vue +++ b/client/components/tables/BackupsTable.vue @@ -78,10 +78,10 @@ export default { return this.$store.getters['user/getToken'] }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') } }, methods: { diff --git a/client/components/tables/UsersTable.vue b/client/components/tables/UsersTable.vue index 20f41228..c7171018 100644 --- a/client/components/tables/UsersTable.vue +++ b/client/components/tables/UsersTable.vue @@ -76,10 +76,10 @@ export default { return usermap }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') } }, methods: { diff --git a/client/components/tables/podcast/LazyEpisodeRow.vue b/client/components/tables/podcast/LazyEpisodeRow.vue index ae99e6d3..ee189961 100644 --- a/client/components/tables/podcast/LazyEpisodeRow.vue +++ b/client/components/tables/podcast/LazyEpisodeRow.vue @@ -112,7 +112,7 @@ export default { return this.episode?.publishedAt }, dateFormat() { - return this.store.state.serverSettings.dateFormat + return this.store.getters['getServerSetting']('dateFormat') }, itemProgress() { return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episodeId) diff --git a/client/components/tables/podcast/LazyEpisodesTable.vue b/client/components/tables/podcast/LazyEpisodesTable.vue index b23cc560..d23ee3d3 100644 --- a/client/components/tables/podcast/LazyEpisodesTable.vue +++ b/client/components/tables/podcast/LazyEpisodesTable.vue @@ -239,10 +239,10 @@ export default { }) }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') } }, methods: { diff --git a/client/components/widgets/CronExpressionBuilder.vue b/client/components/widgets/CronExpressionBuilder.vue index 77b5a54c..600ed81a 100644 --- a/client/components/widgets/CronExpressionBuilder.vue +++ b/client/components/widgets/CronExpressionBuilder.vue @@ -85,7 +85,7 @@ export default { nextRun() { if (!this.cronExpression) return '' const parsed = this.$getNextScheduledDate(this.cronExpression) - return this.$formatJsDatetime(parsed, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat) || '' + return this.$formatJsDatetime(parsed, this.$store.getters['getServerSetting']('dateFormat'), this.$store.getters['getServerSetting']('timeFormat')) || '' }, description() { if ((this.selectedInterval !== 'custom' || !this.selectedWeekdays.length) && this.selectedInterval !== 'daily') return '' diff --git a/client/pages/config/rss-feeds.vue b/client/pages/config/rss-feeds.vue index 56823920..b5ba90a3 100644 --- a/client/pages/config/rss-feeds.vue +++ b/client/pages/config/rss-feeds.vue @@ -78,10 +78,10 @@ export default { }, computed: { dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') } }, methods: { diff --git a/client/pages/config/sessions.vue b/client/pages/config/sessions.vue index 0b1bdaa7..135922d3 100644 --- a/client/pages/config/sessions.vue +++ b/client/pages/config/sessions.vue @@ -250,10 +250,10 @@ export default { return user?.username || null }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') }, numSelected() { return this.listeningSessions.filter((s) => s.selected).length diff --git a/client/pages/config/users/_id/index.vue b/client/pages/config/users/_id/index.vue index e2f8e208..f0e6647f 100644 --- a/client/pages/config/users/_id/index.vue +++ b/client/pages/config/users/_id/index.vue @@ -129,10 +129,10 @@ export default { return this.listeningSessions.sessions[0] }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') } }, methods: { diff --git a/client/pages/config/users/_id/sessions.vue b/client/pages/config/users/_id/sessions.vue index ab33b4b1..764983d7 100644 --- a/client/pages/config/users/_id/sessions.vue +++ b/client/pages/config/users/_id/sessions.vue @@ -98,10 +98,10 @@ export default { return this.$store.getters['users/getIsUserOnline'](this.user.id) }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, timeFormat() { - return this.$store.state.serverSettings.timeFormat + return this.$store.getters['getServerSetting']('timeFormat') } }, methods: { diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 6042f92a..1d8f0f20 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -193,7 +193,7 @@ export default { return `${process.env.serverUrl}/api/items/${this.libraryItemId}/download?token=${this.userToken}` }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') }, userIsAdminOrUp() { return this.$store.getters['user/getIsAdminOrUp'] diff --git a/client/pages/library/_library/podcast/latest.vue b/client/pages/library/_library/podcast/latest.vue index 4da25f3e..4f12043e 100644 --- a/client/pages/library/_library/podcast/latest.vue +++ b/client/pages/library/_library/podcast/latest.vue @@ -141,7 +141,7 @@ export default { return episodeIds }, dateFormat() { - return this.$store.state.serverSettings.dateFormat + return this.$store.getters['getServerSetting']('dateFormat') } }, methods: { From d9cfcc86e77971966c556ac19be90cca5c7b3741 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 09:16:07 -0500 Subject: [PATCH 133/171] Update oidc to return refresh token in response body for mobile --- server/Auth.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index a4c52781..6b2f2bd8 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -399,10 +399,11 @@ class Auth { */ async handleLoginSuccessBasedOnCookie(req, res) { // Handle token generation and get userResponse object - // TODO: where to check if refresh tokens should be returned? - const userResponse = await this.handleLoginSuccess(req, res, false) + // For API based auth (e.g. mobile), we will return the refresh token in the response + const isApiBased = this.isAuthMethodAPIBased(req.cookies.auth_method) + const userResponse = await this.handleLoginSuccess(req, res, isApiBased) - if (this.isAuthMethodAPIBased(req.cookies.auth_method)) { + if (isApiBased) { // REST request - send data res.json(userResponse) } else { From 9c8900560c8261ce885f138e3b2de5c8f77a0687 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 15:04:40 -0500 Subject: [PATCH 134/171] Seperate out auth strategies, update change password to return error status codes --- client/pages/account.vue | 21 +- server/Auth.js | 539 ++--------------------------- server/auth/LocalAuthStrategy.js | 186 ++++++++++ server/auth/OidcAuthStrategy.js | 488 ++++++++++++++++++++++++++ server/controllers/MeController.js | 18 +- 5 files changed, 729 insertions(+), 523 deletions(-) create mode 100644 server/auth/LocalAuthStrategy.js create mode 100644 server/auth/OidcAuthStrategy.js diff --git a/client/pages/account.vue b/client/pages/account.vue index b157f570..e9b5da3c 100644 --- a/client/pages/account.vue +++ b/client/pages/account.vue @@ -182,18 +182,19 @@ export default { password: this.password, newPassword: this.newPassword }) - .then((res) => { - if (res.success) { - this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess) - this.resetForm() - } else { - this.$toast.error(res.error || this.$strings.ToastUnknownError) - } - this.changingPassword = false + .then(() => { + this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess) + this.resetForm() }) .catch((error) => { - console.error(error) - this.$toast.error(this.$strings.ToastUnknownError) + console.error('Failed to change password', error) + let errorMessage = this.$strings.ToastUnknownError + if (error.response?.data && typeof error.response.data === 'string') { + errorMessage = error.response.data + } + this.$toast.error(errorMessage) + }) + .finally(() => { this.changingPassword = false }) }, diff --git a/server/Auth.js b/server/Auth.js index 6b2f2bd8..e62df0b8 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,17 +1,14 @@ const { Request, Response, NextFunction } = require('express') -const axios = require('axios') const passport = require('passport') -const OpenIDClient = require('openid-client') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt const Database = require('./Database') const Logger = require('./Logger') const TokenManager = require('./auth/TokenManager') +const LocalAuthStrategy = require('./auth/LocalAuthStrategy') +const OidcAuthStrategy = require('./auth/OidcAuthStrategy') -const bcrypt = require('./libs/bcryptjs') -const requestIp = require('./libs/requestIp') -const LocalStrategy = require('./libs/passportLocal') const { escapeRegExp } = require('./utils') /** @@ -19,12 +16,12 @@ const { escapeRegExp } = require('./utils') */ class Auth { constructor() { - // Map of openId sessions indexed by oauth2 state-variable - this.openIdAuthSession = new Map() const escapedRouterBasePath = escapeRegExp(global.RouterBasePath) this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)] this.tokenManager = new TokenManager() + this.localAuthStrategy = new LocalAuthStrategy() + this.oidcAuthStrategy = new OidcAuthStrategy() } /** @@ -117,12 +114,12 @@ class Auth { async initPassportJs() { // Check if we should load the local strategy (username + password login) if (global.ServerSettings.authActiveAuthMethods.includes('local')) { - this.initAuthStrategyPassword() + this.localAuthStrategy.init() } // Check if we should load the openid strategy if (global.ServerSettings.authActiveAuthMethods.includes('openid')) { - this.initAuthStrategyOpenID() + this.oidcAuthStrategy.init() } // Load the JwtStrategy (always) -> for bearer token auth @@ -165,169 +162,21 @@ class Auth { }.bind(this) ) } - - /** - * Passport use LocalStrategy - */ - initAuthStrategyPassword() { - passport.use(new LocalStrategy({ passReqToCallback: true }, this.localAuthCheckUserPw.bind(this))) - } - - /** - * Passport use OpenIDClient.Strategy - */ - initAuthStrategyOpenID() { - if (!Database.serverSettings.isOpenIDAuthSettingsValid) { - Logger.error(`[Auth] Cannot init openid auth strategy - invalid settings`) - return - } - - // Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing - OpenIDClient.custom.setHttpOptionsDefaults({ timeout: 10000 }) - - const openIdIssuerClient = new OpenIDClient.Issuer({ - issuer: global.ServerSettings.authOpenIDIssuerURL, - authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL, - token_endpoint: global.ServerSettings.authOpenIDTokenURL, - userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL, - jwks_uri: global.ServerSettings.authOpenIDJwksURL, - end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL - }).Client - const openIdClient = new openIdIssuerClient({ - client_id: global.ServerSettings.authOpenIDClientID, - client_secret: global.ServerSettings.authOpenIDClientSecret, - id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm - }) - passport.use( - 'openid-client', - new OpenIDClient.Strategy( - { - client: openIdClient, - params: { - redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, - scope: 'openid profile email' - } - }, - async (tokenset, userinfo, done) => { - try { - Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) - - if (!userinfo.sub) { - throw new Error('Invalid userinfo, no sub') - } - - if (!this.validateGroupClaim(userinfo)) { - throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`) - } - - let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo, this) - - if (!user?.isActive) { - throw new Error('User not active or not found') - } - - await this.setUserGroup(user, userinfo) - await this.updateUserPermissions(user, userinfo) - - // We also have to save the id_token for later (used for logout) because we cannot set cookies here - user.openid_id_token = tokenset.id_token - - return done(null, user) - } catch (error) { - Logger.error(`[Auth] openid callback error: ${error?.message}\n${error?.stack}`) - - return done(null, null, 'Unauthorized') - } - } - ) - ) - } // #endregion - /** - * Validates the presence and content of the group claim in userinfo. - */ - validateGroupClaim(userinfo) { - const groupClaimName = Database.serverSettings.authOpenIDGroupClaim - if (!groupClaimName) - // Allow no group claim when configured like this - return true - - // If configured it must exist in userinfo - if (!userinfo[groupClaimName]) { - return false - } - return true - } - - /** - * Sets the user group based on group claim in userinfo. - * - * @param {import('./models/User')} user - * @param {Object} userinfo - */ - async setUserGroup(user, userinfo) { - const groupClaimName = Database.serverSettings.authOpenIDGroupClaim - if (!groupClaimName) - // No group claim configured, don't set anything - return - - if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`) - - const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase()) - const rolesInOrderOfPriority = ['admin', 'user', 'guest'] - - let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role)) - if (userType) { - if (user.type === 'root') { - // Check OpenID Group - if (userType !== 'admin') { - throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`) - } else { - // If root user is logging in via OpenID, we will not change the type - return - } - } - - if (user.type !== userType) { - Logger.info(`[Auth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`) - user.type = userType - await user.save() - } - } else { - throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`) - } - } - - /** - * Updates user permissions based on the advanced permissions claim. - * - * @param {import('./models/User')} user - * @param {Object} userinfo - */ - async updateUserPermissions(user, userinfo) { - const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim - if (!absPermissionsClaim) - // No advanced permissions claim configured, don't set anything - return - - if (user.type === 'admin' || user.type === 'root') return - - const absPermissions = userinfo[absPermissionsClaim] - if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`) - - if (await user.updatePermissionsFromExternalJSON(absPermissions)) { - Logger.info(`[Auth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`) - } - } - /** * Unuse strategy * * @param {string} name */ unuseAuthStrategy(name) { - passport.unuse(name) + if (name === 'openid') { + this.oidcAuthStrategy.unuse() + } else if (name === 'local') { + this.localAuthStrategy.unuse() + } else { + Logger.error('[Auth] Invalid auth strategy ' + name) + } } /** @@ -337,9 +186,9 @@ class Auth { */ useAuthStrategy(name) { if (name === 'openid') { - this.initAuthStrategyOpenID() + this.oidcAuthStrategy.init() } else if (name === 'local') { - this.initAuthStrategyPassword() + this.localAuthStrategy.init() } else { Logger.error('[Auth] Invalid auth strategy ' + name) } @@ -496,153 +345,27 @@ class Auth { }) // openid strategy login route (this redirects to the configured openid login provider) - router.get('/auth/openid', (req, res, next) => { - // Get the OIDC client from the strategy - // We need to call the client manually, because the strategy does not support forwarding the code challenge - // for API or mobile clients - const oidcStrategy = passport._strategy('openid-client') - const client = oidcStrategy._client - const sessionKey = oidcStrategy._key + router.get('/auth/openid', (req, res) => { + const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req) - try { - const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' - const hostUrl = new URL(`${protocol}://${req.get('host')}`) - const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge - - // Only allow code flow (for mobile clients) - if (req.query.response_type && req.query.response_type !== 'code') { - Logger.debug(`[Auth] OIDC Invalid response_type=${req.query.response_type}`) - return res.status(400).send('Invalid response_type, only code supported') - } - - // Generate a state on web flow or if no state supplied - const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state - - // Redirect URL for the SSO provider - let redirectUri - if (isMobileFlow) { - // Mobile required redirect uri - // If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect - // where we will handle the redirect to it - if (!req.query.redirect_uri || !isValidRedirectUri(req.query.redirect_uri)) { - Logger.debug(`[Auth] Invalid redirect_uri=${req.query.redirect_uri}`) - return res.status(400).send('Invalid redirect_uri') - } - // We cannot save the supplied redirect_uri in the session, because it the mobile client uses browser instead of the API - // for the request to mobile-redirect and as such the session is not shared - this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri }) - - redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString() - } else { - redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString() - - if (req.query.state) { - Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`) - return res.status(400).send('Invalid state, not allowed on web flow') - } - } - oidcStrategy._params.redirect_uri = redirectUri - Logger.debug(`[Auth] OIDC redirect_uri=${redirectUri}`) - - let { code_challenge, code_challenge_method, code_verifier } = generatePkce(req, isMobileFlow) - - req.session[sessionKey] = { - ...req.session[sessionKey], - state: state, - max_age: oidcStrategy._params.max_age, - response_type: 'code', - code_verifier: code_verifier, // not null if web flow - mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out - sso_redirect_uri: oidcStrategy._params.redirect_uri // Save the redirect_uri (for the SSO Provider) for the callback - } - - var scope = 'openid profile email' - if (global.ServerSettings.authOpenIDGroupClaim) { - scope += ' ' + global.ServerSettings.authOpenIDGroupClaim - } - if (global.ServerSettings.authOpenIDAdvancedPermsClaim) { - scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim - } - - const authorizationUrl = client.authorizationUrl({ - ...oidcStrategy._params, - state: state, - response_type: 'code', - scope: scope, - code_challenge, - code_challenge_method - }) - - this.paramsToCookies(req, res, isMobileFlow ? 'openid-mobile' : 'openid') - - res.redirect(authorizationUrl) - } catch (error) { - Logger.error(`[Auth] Error in /auth/openid route: ${error}\n${error?.stack}`) - res.status(500).send('Internal Server Error') + if (authorizationUrlResponse.error) { + return res.status(authorizationUrlResponse.status).send(authorizationUrlResponse.error) } - function generatePkce(req, isMobileFlow) { - if (isMobileFlow) { - if (!req.query.code_challenge) { - throw new Error('code_challenge required for mobile flow (PKCE)') - } - if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') { - throw new Error('Only S256 code_challenge_method method supported') - } - return { - code_challenge: req.query.code_challenge, - code_challenge_method: req.query.code_challenge_method || 'S256' - } - } else { - const code_verifier = OpenIDClient.generators.codeVerifier() - const code_challenge = OpenIDClient.generators.codeChallenge(code_verifier) - return { code_challenge, code_challenge_method: 'S256', code_verifier } - } - } + this.paramsToCookies(req, res, authorizationUrlResponse.isMobileFlow ? 'openid-mobile' : 'openid') - function isValidRedirectUri(uri) { - // Check if the redirect_uri is in the whitelist - return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*') - } + res.redirect(authorizationUrlResponse.authorizationUrl) }) // This will be the oauth2 callback route for mobile clients // It will redirect to an app-link like audiobookshelf://oauth - router.get('/auth/openid/mobile-redirect', (req, res) => { - try { - // Extract the state parameter from the request - const { state, code } = req.query - - // Check if the state provided is in our list - if (!state || !this.openIdAuthSession.has(state)) { - Logger.error('[Auth] /auth/openid/mobile-redirect route: State parameter mismatch') - return res.status(400).send('State parameter mismatch') - } - - let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri - - if (!mobile_redirect_uri) { - Logger.error('[Auth] No redirect URI') - return res.status(400).send('No redirect URI') - } - - this.openIdAuthSession.delete(state) - - const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` - // Redirect to the overwrite URI saved in the map - res.redirect(redirectUri) - } catch (error) { - Logger.error(`[Auth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`) - res.status(500).send('Internal Server Error') - } - }) + router.get('/auth/openid/mobile-redirect', (req, res) => this.oidcAuthStrategy.handleMobileRedirect(req, res)) // openid strategy callback route (this receives the token from the configured openid login provider) router.get( '/auth/openid/callback', (req, res, next) => { - const oidcStrategy = passport._strategy('openid-client') - const sessionKey = oidcStrategy._key + const sessionKey = this.oidcAuthStrategy.getStrategy()._key if (!req.session[sessionKey]) { return res.status(400).send('No session') @@ -719,43 +442,16 @@ class Auth { return res.sendStatus(403) } - if (!req.query.issuer) { + if (!req.query.issuer || typeof req.query.issuer !== 'string') { return res.status(400).send("Invalid request. Query param 'issuer' is required") } - // Strip trailing slash - let issuerUrl = req.query.issuer - if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1) - - // Append config pathname and validate URL - let configUrl = null - try { - configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`) - if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) { - throw new Error('Invalid pathname') - } - } catch (error) { - Logger.error(`[Auth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error) - return res.status(400).send("Invalid request. Query param 'issuer' is invalid") + const openIdIssuerConfig = await this.oidcAuthStrategy.getIssuerConfig(req.query.issuer) + if (openIdIssuerConfig.error) { + return res.status(openIdIssuerConfig.status).send(openIdIssuerConfig.error) } - axios - .get(configUrl.toString()) - .then(({ data }) => { - res.json({ - issuer: data.issuer, - authorization_endpoint: data.authorization_endpoint, - token_endpoint: data.token_endpoint, - userinfo_endpoint: data.userinfo_endpoint, - end_session_endpoint: data.end_session_endpoint, - jwks_uri: data.jwks_uri, - id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported - }) - }) - .catch((error) => { - Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error) - res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`) - }) + res.json(openIdIssuerConfig) }) // Logout route @@ -782,7 +478,6 @@ class Auth { Logger.info(`[Auth] logout: No refresh token on request`) } - // TODO: invalidate possible JWTs req.logout((err) => { if (err) { res.sendStatus(500) @@ -794,36 +489,7 @@ class Auth { let logoutUrl = null if (authMethod === 'openid' || authMethod === 'openid-mobile') { - // If we are using openid, we need to redirect to the logout endpoint - // node-openid-client does not support doing it over passport - const oidcStrategy = passport._strategy('openid-client') - const client = oidcStrategy._client - - if (client.issuer.end_session_endpoint && client.issuer.end_session_endpoint.length > 0) { - let postLogoutRedirectUri = null - - if (authMethod === 'openid') { - const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' - const host = req.get('host') - // TODO: ABS does currently not support subfolders for installation - // If we want to support it we need to include a config for the serverurl - postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login` - } - // else for openid-mobile we keep postLogoutRedirectUri on null - // nice would be to redirect to the app here, but for example Authentik does not implement - // the post_logout_redirect_uri parameter at all and for other providers - // we would also need again to implement (and even before get to know somehow for 3rd party apps) - // the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect). - // Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like - // &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution - // (The URL needs to be whitelisted in the config of the SSO/ID provider) - - logoutUrl = client.endSessionUrl({ - id_token_hint: req.cookies.openid_id_token, - post_logout_redirect_uri: postLogoutRedirectUri - }) - } - + logoutUrl = this.oidcAuthStrategy.getEndSessionUrl(req, req.cookies.openid_id_token, authMethod) res.clearCookie('openid_id_token') } @@ -835,153 +501,6 @@ class Auth { }) } // #endregion - - // #region Local Auth - /** - * Checks if a username and password tuple is valid and the user active. - * @param {Request} req - * @param {string} username - * @param {string} password - * @param {Promise} done - */ - async localAuthCheckUserPw(req, username, password, done) { - // Load the user given it's username - const user = await Database.userModel.getUserByUsername(username.toLowerCase()) - - if (!user?.isActive) { - if (user) { - this.logFailedLocalAuthLoginAttempt(req, user.username, 'User is not active') - } else { - this.logFailedLocalAuthLoginAttempt(req, username, 'User not found') - } - done(null, null) - return - } - - // Check passwordless root user - if (user.type === 'root' && !user.pash) { - if (password) { - // deny login - this.logFailedLocalAuthLoginAttempt(req, user.username, 'Root user has no password set') - done(null, null) - return - } - // approve login - Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) - - done(null, user) - return - } else if (!user.pash) { - this.logFailedLocalAuthLoginAttempt(req, user.username, 'User has no password set. Might have been created with OpenID') - done(null, null) - return - } - - // Check password match - const compare = await bcrypt.compare(password, user.pash) - if (compare) { - // approve login - Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) - - done(null, user) - return - } - // deny login - this.logFailedLocalAuthLoginAttempt(req, user.username, 'Invalid password') - done(null, null) - return - } - - /** - * - * @param {Request} req - * @param {string} username - * @param {string} message - */ - logFailedLocalAuthLoginAttempt(req, username, message) { - if (!req || !username || !message) return - Logger.error(`[Auth] Failed login attempt for username "${username}" from ip ${requestIp.getClientIp(req)} (${message})`) - } - - /** - * Hashes a password with bcrypt. - * @param {string} password - * @returns {Promise} hash - */ - hashPass(password) { - return new Promise((resolve) => { - bcrypt.hash(password, 8, (err, hash) => { - if (err) { - resolve(null) - } else { - resolve(hash) - } - }) - }) - } - - /** - * - * @param {string} password - * @param {import('./models/User')} user - * @returns {Promise} - */ - comparePassword(password, user) { - if (user.type === 'root' && !password && !user.pash) return true - if (!password || !user.pash) return false - return bcrypt.compare(password, user.pash) - } - - /** - * User changes their password from request - * TODO: Update responses to use error status codes - * - * @param {import('./controllers/MeController').RequestWithUser} req - * @param {Response} res - */ - async userChangePassword(req, res) { - let { password, newPassword } = req.body - newPassword = newPassword || '' - const matchingUser = req.user - - // Only root can have an empty password - if (matchingUser.type !== 'root' && !newPassword) { - return res.json({ - error: 'Invalid new password - Only root can have an empty password' - }) - } - - // Check password match - const compare = await this.comparePassword(password, matchingUser) - if (!compare) { - return res.json({ - error: 'Invalid password' - }) - } - - let pw = '' - if (newPassword) { - pw = await this.hashPass(newPassword) - if (!pw) { - return res.json({ - error: 'Hash failed' - }) - } - } - try { - await matchingUser.update({ pash: pw }) - Logger.info(`[Auth] User "${matchingUser.username}" changed password`) - res.json({ - success: true - }) - } catch (error) { - Logger.error(`[Auth] User "${matchingUser.username}" failed to change password`, error) - res.json({ - error: 'Unknown error' - }) - } - } - // #endregion } module.exports = Auth diff --git a/server/auth/LocalAuthStrategy.js b/server/auth/LocalAuthStrategy.js new file mode 100644 index 00000000..e9499d13 --- /dev/null +++ b/server/auth/LocalAuthStrategy.js @@ -0,0 +1,186 @@ +const passport = require('passport') +const LocalStrategy = require('../libs/passportLocal') +const Database = require('../Database') +const Logger = require('../Logger') + +const bcrypt = require('../libs/bcryptjs') +const requestIp = require('../libs/requestIp') + +/** + * Local authentication strategy using username/password + */ +class LocalAuthStrategy { + constructor() { + this.name = 'local' + this.strategy = null + } + + /** + * Get the passport strategy instance + * @returns {LocalStrategy} + */ + getStrategy() { + if (!this.strategy) { + this.strategy = new LocalStrategy({ passReqToCallback: true }, this.verifyCredentials.bind(this)) + } + return this.strategy + } + + /** + * Initialize the strategy with passport + */ + init() { + passport.use(this.name, this.getStrategy()) + } + + /** + * Remove the strategy from passport + */ + unuse() { + passport.unuse(this.name) + this.strategy = null + } + + /** + * Verify user credentials + * @param {import('express').Request} req + * @param {string} username + * @param {string} password + * @param {Function} done - Passport callback + */ + async verifyCredentials(req, username, password, done) { + // Load the user given it's username + const user = await Database.userModel.getUserByUsername(username.toLowerCase()) + + if (!user?.isActive) { + if (user) { + this.logFailedLoginAttempt(req, user.username, 'User is not active') + } else { + this.logFailedLoginAttempt(req, username, 'User not found') + } + done(null, null) + return + } + + // Check passwordless root user + if (user.type === 'root' && !user.pash) { + if (password) { + // deny login + this.logFailedLoginAttempt(req, user.username, 'Root user has no password set') + done(null, null) + return + } + // approve login + Logger.info(`[LocalAuth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) + + done(null, user) + return + } else if (!user.pash) { + this.logFailedLoginAttempt(req, user.username, 'User has no password set. Might have been created with OpenID') + done(null, null) + return + } + + // Check password match + const compare = await bcrypt.compare(password, user.pash) + if (compare) { + // approve login + Logger.info(`[LocalAuth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) + + done(null, user) + return + } + + // deny login + this.logFailedLoginAttempt(req, user.username, 'Invalid password') + done(null, null) + } + + /** + * Log failed login attempts + * @param {import('express').Request} req + * @param {string} username + * @param {string} message + */ + logFailedLoginAttempt(req, username, message) { + if (!req || !username || !message) return + Logger.error(`[LocalAuth] Failed login attempt for username "${username}" from ip ${requestIp.getClientIp(req)} (${message})`) + } + + /** + * Hash a password with bcrypt + * @param {string} password + * @returns {Promise} hash + */ + hashPassword(password) { + return new Promise((resolve) => { + bcrypt.hash(password, 8, (err, hash) => { + if (err) { + resolve(null) + } else { + resolve(hash) + } + }) + }) + } + + /** + * Compare password with user's hashed password + * @param {string} password + * @param {import('../models/User')} user + * @returns {Promise} + */ + comparePassword(password, user) { + if (user.type === 'root' && !password && !user.pash) return true + if (!password || !user.pash) return false + return bcrypt.compare(password, user.pash) + } + + /** + * Change user password + * @param {import('../models/User')} user + * @param {string} password + * @param {string} newPassword + */ + async changePassword(user, password, newPassword) { + // Only root can have an empty password + if (user.type !== 'root' && !newPassword) { + return { + error: 'Invalid new password - Only root can have an empty password' + } + } + + // Check password match + const compare = await this.comparePassword(password, user) + if (!compare) { + return { + error: 'Invalid password' + } + } + + let pw = '' + if (newPassword) { + pw = await this.hashPassword(newPassword) + if (!pw) { + return { + error: 'Hash failed' + } + } + } + + try { + await user.update({ pash: pw }) + Logger.info(`[LocalAuth] User "${user.username}" changed password`) + return { + success: true + } + } catch (error) { + Logger.error(`[LocalAuth] User "${user.username}" failed to change password`, error) + return { + error: 'Unknown error' + } + } + } +} + +module.exports = LocalAuthStrategy diff --git a/server/auth/OidcAuthStrategy.js b/server/auth/OidcAuthStrategy.js new file mode 100644 index 00000000..c3f6cfb2 --- /dev/null +++ b/server/auth/OidcAuthStrategy.js @@ -0,0 +1,488 @@ +const { Request, Response } = require('express') +const passport = require('passport') +const OpenIDClient = require('openid-client') +const axios = require('axios') +const Database = require('../Database') +const Logger = require('../Logger') + +/** + * OpenID Connect authentication strategy + */ +class OidcAuthStrategy { + constructor() { + this.name = 'openid-client' + this.strategy = null + this.client = null + // Map of openId sessions indexed by oauth2 state-variable + this.openIdAuthSession = new Map() + } + + /** + * Get the passport strategy instance + * @returns {OpenIDClient.Strategy} + */ + getStrategy() { + if (!this.strategy) { + this.strategy = new OpenIDClient.Strategy( + { + client: this.getClient(), + params: { + redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, + scope: this.getScope() + } + }, + this.verifyCallback.bind(this) + ) + } + return this.strategy + } + + /** + * Get the OpenID Connect client + * @returns {OpenIDClient.Client} + */ + getClient() { + if (!this.client) { + if (!Database.serverSettings.isOpenIDAuthSettingsValid) { + throw new Error('OpenID Connect settings are not valid') + } + + // Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing + OpenIDClient.custom.setHttpOptionsDefaults({ timeout: 10000 }) + + const openIdIssuerClient = new OpenIDClient.Issuer({ + issuer: global.ServerSettings.authOpenIDIssuerURL, + authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL, + token_endpoint: global.ServerSettings.authOpenIDTokenURL, + userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL, + jwks_uri: global.ServerSettings.authOpenIDJwksURL, + end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL + }).Client + + this.client = new openIdIssuerClient({ + client_id: global.ServerSettings.authOpenIDClientID, + client_secret: global.ServerSettings.authOpenIDClientSecret, + id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm + }) + } + return this.client + } + + /** + * Get the scope string for the OpenID Connect request + * @returns {string} + */ + getScope() { + let scope = 'openid profile email' + if (global.ServerSettings.authOpenIDGroupClaim) { + scope += ' ' + global.ServerSettings.authOpenIDGroupClaim + } + if (global.ServerSettings.authOpenIDAdvancedPermsClaim) { + scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim + } + return scope + } + + /** + * Initialize the strategy with passport + */ + init() { + if (!Database.serverSettings.isOpenIDAuthSettingsValid) { + Logger.error(`[OidcAuth] Cannot init openid auth strategy - invalid settings`) + return + } + passport.use(this.name, this.getStrategy()) + } + + /** + * Remove the strategy from passport + */ + unuse() { + passport.unuse(this.name) + this.strategy = null + this.client = null + } + + /** + * Verify callback for OpenID Connect authentication + * @param {Object} tokenset + * @param {Object} userinfo + * @param {Function} done - Passport callback + */ + async verifyCallback(tokenset, userinfo, done) { + try { + Logger.debug(`[OidcAuth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) + + if (!userinfo.sub) { + throw new Error('Invalid userinfo, no sub') + } + + if (!this.validateGroupClaim(userinfo)) { + throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`) + } + + let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo, this) + + if (!user?.isActive) { + throw new Error('User not active or not found') + } + + await this.setUserGroup(user, userinfo) + await this.updateUserPermissions(user, userinfo) + + // We also have to save the id_token for later (used for logout) because we cannot set cookies here + user.openid_id_token = tokenset.id_token + + return done(null, user) + } catch (error) { + Logger.error(`[OidcAuth] openid callback error: ${error?.message}\n${error?.stack}`) + return done(null, null, 'Unauthorized') + } + } + + /** + * Validates the presence and content of the group claim in userinfo. + * @param {Object} userinfo + * @returns {boolean} + */ + validateGroupClaim(userinfo) { + const groupClaimName = Database.serverSettings.authOpenIDGroupClaim + if (!groupClaimName) + // Allow no group claim when configured like this + return true + + // If configured it must exist in userinfo + if (!userinfo[groupClaimName]) { + return false + } + return true + } + + /** + * Sets the user group based on group claim in userinfo. + * @param {import('../models/User')} user + * @param {Object} userinfo + */ + async setUserGroup(user, userinfo) { + const groupClaimName = Database.serverSettings.authOpenIDGroupClaim + if (!groupClaimName) + // No group claim configured, don't set anything + return + + if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`) + + const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase()) + const rolesInOrderOfPriority = ['admin', 'user', 'guest'] + + let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role)) + if (userType) { + if (user.type === 'root') { + // Check OpenID Group + if (userType !== 'admin') { + throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`) + } else { + // If root user is logging in via OpenID, we will not change the type + return + } + } + + if (user.type !== userType) { + Logger.info(`[OidcAuth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`) + user.type = userType + await user.save() + } + } else { + throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`) + } + } + + /** + * Updates user permissions based on the advanced permissions claim. + * @param {import('../models/User')} user + * @param {Object} userinfo + */ + async updateUserPermissions(user, userinfo) { + const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim + if (!absPermissionsClaim) + // No advanced permissions claim configured, don't set anything + return + + if (user.type === 'admin' || user.type === 'root') return + + const absPermissions = userinfo[absPermissionsClaim] + if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`) + + if (await user.updatePermissionsFromExternalJSON(absPermissions)) { + Logger.info(`[OidcAuth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`) + } + } + + /** + * Generate PKCE parameters for the authorization request + * @param {Request} req + * @param {boolean} isMobileFlow + * @returns {Object|{error: string}} + */ + generatePkce(req, isMobileFlow) { + if (isMobileFlow) { + if (!req.query.code_challenge) { + return { + error: 'code_challenge required for mobile flow (PKCE)' + } + } + if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') { + return { + error: 'Only S256 code_challenge_method method supported' + } + } + return { + code_challenge: req.query.code_challenge, + code_challenge_method: req.query.code_challenge_method || 'S256' + } + } else { + const code_verifier = OpenIDClient.generators.codeVerifier() + const code_challenge = OpenIDClient.generators.codeChallenge(code_verifier) + return { code_challenge, code_challenge_method: 'S256', code_verifier } + } + } + + /** + * Check if a redirect URI is valid + * @param {string} uri + * @returns {boolean} + */ + isValidRedirectUri(uri) { + // Check if the redirect_uri is in the whitelist + return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*') + } + + /** + * Get the authorization URL for OpenID Connect + * Calls client manually because the strategy does not support forwarding the code challenge for the mobile flow + * @param {Request} req + * @returns {{ authorizationUrl: string }|{status: number, error: string}} + */ + getAuthorizationUrl(req) { + const client = this.getClient() + const strategy = this.getStrategy() + const sessionKey = strategy._key + + try { + const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' + const hostUrl = new URL(`${protocol}://${req.get('host')}`) + const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge + + // Only allow code flow (for mobile clients) + if (req.query.response_type && req.query.response_type !== 'code') { + Logger.debug(`[OidcAuth] OIDC Invalid response_type=${req.query.response_type}`) + return { + status: 400, + error: 'Invalid response_type, only code supported' + } + } + + // Generate a state on web flow or if no state supplied + const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state + + // Redirect URL for the SSO provider + let redirectUri + if (isMobileFlow) { + // Mobile required redirect uri + // If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect + // where we will handle the redirect to it + if (!req.query.redirect_uri || !this.isValidRedirectUri(req.query.redirect_uri)) { + Logger.debug(`[OidcAuth] Invalid redirect_uri=${req.query.redirect_uri}`) + return { + status: 400, + error: 'Invalid redirect_uri' + } + } + // We cannot save the supplied redirect_uri in the session, because it the mobile client uses browser instead of the API + // for the request to mobile-redirect and as such the session is not shared + this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri }) + + redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString() + } else { + redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString() + + if (req.query.state) { + Logger.debug(`[OidcAuth] Invalid state - not allowed on web openid flow`) + return { + status: 400, + error: 'Invalid state, not allowed on web flow' + } + } + } + + // Update the strategy's redirect_uri for this request + strategy._params.redirect_uri = redirectUri + Logger.debug(`[OidcAuth] OIDC redirect_uri=${redirectUri}`) + + const pkceData = this.generatePkce(req, isMobileFlow) + if (pkceData.error) { + return { + status: 400, + error: pkceData.error + } + } + + req.session[sessionKey] = { + ...req.session[sessionKey], + state: state, + max_age: strategy._params.max_age, + response_type: 'code', + code_verifier: pkceData.code_verifier, // not null if web flow + mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out + sso_redirect_uri: redirectUri // Save the redirect_uri (for the SSO Provider) for the callback + } + + const authorizationUrl = client.authorizationUrl({ + ...strategy._params, + redirect_uri: redirectUri, + state: state, + response_type: 'code', + scope: this.getScope(), + code_challenge: pkceData.code_challenge, + code_challenge_method: pkceData.code_challenge_method + }) + + return { + authorizationUrl, + isMobileFlow + } + } catch (error) { + Logger.error(`[OidcAuth] Error generating authorization URL: ${error}\n${error?.stack}`) + return { + status: 500, + error: error.message || 'Unknown error' + } + } + } + + /** + * Get the end session URL for logout + * @param {Request} req + * @param {string} idToken + * @param {string} authMethod + * @returns {string|null} + */ + getEndSessionUrl(req, idToken, authMethod) { + const client = this.getClient() + + if (client.issuer.end_session_endpoint && client.issuer.end_session_endpoint.length > 0) { + let postLogoutRedirectUri = null + + if (authMethod === 'openid') { + const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' + const host = req.get('host') + // TODO: ABS does currently not support subfolders for installation + // If we want to support it we need to include a config for the serverurl + postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login` + } + // else for openid-mobile we keep postLogoutRedirectUri on null + // nice would be to redirect to the app here, but for example Authentik does not implement + // the post_logout_redirect_uri parameter at all and for other providers + // we would also need again to implement (and even before get to know somehow for 3rd party apps) + // the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect). + // Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like + // &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution + // (The URL needs to be whitelisted in the config of the SSO/ID provider) + + return client.endSessionUrl({ + id_token_hint: idToken, + post_logout_redirect_uri: postLogoutRedirectUri + }) + } + + return null + } + + /** + * @typedef {Object} OpenIdIssuerConfig + * @property {string} issuer + * @property {string} authorization_endpoint + * @property {string} token_endpoint + * @property {string} userinfo_endpoint + * @property {string} end_session_endpoint + * @property {string} jwks_uri + * @property {string} id_token_signing_alg_values_supported + * + * Get OpenID Connect configuration from an issuer URL + * @param {string} issuerUrl + * @returns {Promise} + */ + async getIssuerConfig(issuerUrl) { + // Strip trailing slash + if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1) + + // Append config pathname and validate URL + let configUrl = null + try { + configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`) + if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) { + throw new Error('Invalid pathname') + } + } catch (error) { + Logger.error(`[OidcAuth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error) + return { + status: 400, + error: "Invalid request. Query param 'issuer' is invalid" + } + } + + try { + const { data } = await axios.get(configUrl.toString()) + return { + issuer: data.issuer, + authorization_endpoint: data.authorization_endpoint, + token_endpoint: data.token_endpoint, + userinfo_endpoint: data.userinfo_endpoint, + end_session_endpoint: data.end_session_endpoint, + jwks_uri: data.jwks_uri, + id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported + } + } catch (error) { + Logger.error(`[OidcAuth] Failed to get openid configuration at "${configUrl}"`, error) + return { + status: 400, + error: 'Failed to get openid configuration' + } + } + } + + /** + * Handle mobile redirect for OAuth2 callback + * @param {Request} req + * @param {Response} res + */ + handleMobileRedirect(req, res) { + try { + // Extract the state parameter from the request + const { state, code } = req.query + + // Check if the state provided is in our list + if (!state || !this.openIdAuthSession.has(state)) { + Logger.error('[OidcAuth] /auth/openid/mobile-redirect route: State parameter mismatch') + return res.status(400).send('State parameter mismatch') + } + + let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri + + if (!mobile_redirect_uri) { + Logger.error('[OidcAuth] No redirect URI') + return res.status(400).send('No redirect URI') + } + + this.openIdAuthSession.delete(state) + + const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` + // Redirect to the overwrite URI saved in the map + res.redirect(redirectUri) + } catch (error) { + Logger.error(`[OidcAuth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`) + res.status(500).send('Internal Server Error') + } + } +} + +module.exports = OidcAuthStrategy diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index 87acd221..9451a765 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -273,12 +273,24 @@ class MeController { * @param {RequestWithUser} req * @param {Response} res */ - updatePassword(req, res) { + async updatePassword(req, res) { if (req.user.isGuest) { Logger.error(`[MeController] Guest user "${req.user.username}" attempted to change password`) - return res.sendStatus(500) + return res.sendStatus(403) } - this.auth.userChangePassword(req, res) + + const { password, newPassword } = req.body + if (!password || !newPassword || typeof password !== 'string' || typeof newPassword !== 'string') { + return res.status(400).send('Missing or invalid password or new password') + } + + const result = await this.auth.localAuthStrategy.changePassword(req.user, password, newPassword) + + if (result.error) { + return res.status(400).send(result.error) + } + + res.sendStatus(200) } /** From ac381854e56b736be5b629e46bafb26fd62131cf Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 16:23:15 -0500 Subject: [PATCH 135/171] Add rate limiter for auth endpoints --- package-lock.json | 16 ++++++++ package.json | 1 + server/Auth.js | 16 +++++--- server/routers/ApiRouter.js | 2 +- server/utils/rateLimiterFactory.js | 61 ++++++++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 server/utils/rateLimiterFactory.js diff --git a/package-lock.json b/package-lock.json index d44ea79b..1be14fa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "axios": "^0.27.2", "cookie-parser": "^1.4.6", "express": "^4.17.1", + "express-rate-limit": "^7.5.1", "express-session": "^1.17.3", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", @@ -1893,6 +1894,21 @@ "node": ">= 0.10.0" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-session": { "version": "1.17.3", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz", diff --git a/package.json b/package.json index 2fd1a87e..3fdbf768 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "axios": "^0.27.2", "cookie-parser": "^1.4.6", "express": "^4.17.1", + "express-rate-limit": "^7.5.1", "express-session": "^1.17.3", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", diff --git a/server/Auth.js b/server/Auth.js index e62df0b8..601fe8f2 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,4 +1,5 @@ const { Request, Response, NextFunction } = require('express') +const { rateLimit } = require('express-rate-limit') const passport = require('passport') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt @@ -9,6 +10,7 @@ const TokenManager = require('./auth/TokenManager') const LocalAuthStrategy = require('./auth/LocalAuthStrategy') const OidcAuthStrategy = require('./auth/OidcAuthStrategy') +const RateLimiterFactory = require('./utils/rateLimiterFactory') const { escapeRegExp } = require('./utils') /** @@ -19,6 +21,9 @@ class Auth { const escapedRouterBasePath = escapeRegExp(global.RouterBasePath) this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)] + /** @type {import('express-rate-limit').RateLimitRequestHandler} */ + this.authRateLimiter = RateLimiterFactory.getAuthRateLimiter() + this.tokenManager = new TokenManager() this.localAuthStrategy = new LocalAuthStrategy() this.oidcAuthStrategy = new OidcAuthStrategy() @@ -305,7 +310,7 @@ class Auth { */ async initAuthRoutes(router) { // Local strategy login route (takes username and password) - router.post('/login', passport.authenticate('local'), async (req, res) => { + router.post('/login', this.authRateLimiter, passport.authenticate('local'), async (req, res) => { // Check if mobile app wants refresh token in response const returnTokens = req.query.return_tokens === 'true' || req.headers['x-return-tokens'] === 'true' @@ -314,7 +319,7 @@ class Auth { }) // Refresh token route - router.post('/auth/refresh', async (req, res) => { + router.post('/auth/refresh', this.authRateLimiter, async (req, res) => { let refreshToken = req.cookies.refresh_token // If x-refresh-token header is present, use it instead of the cookie @@ -345,7 +350,7 @@ class Auth { }) // openid strategy login route (this redirects to the configured openid login provider) - router.get('/auth/openid', (req, res) => { + router.get('/auth/openid', this.authRateLimiter, (req, res) => { const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req) if (authorizationUrlResponse.error) { @@ -359,11 +364,12 @@ class Auth { // This will be the oauth2 callback route for mobile clients // It will redirect to an app-link like audiobookshelf://oauth - router.get('/auth/openid/mobile-redirect', (req, res) => this.oidcAuthStrategy.handleMobileRedirect(req, res)) + router.get('/auth/openid/mobile-redirect', this.authRateLimiter, (req, res) => this.oidcAuthStrategy.handleMobileRedirect(req, res)) // openid strategy callback route (this receives the token from the configured openid login provider) router.get( '/auth/openid/callback', + this.authRateLimiter, (req, res, next) => { const sessionKey = this.oidcAuthStrategy.getStrategy()._key @@ -436,7 +442,7 @@ class Auth { * * @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/ */ - router.get('/auth/openid/config', this.isAuthenticated, async (req, res) => { + router.get('/auth/openid/config', this.authRateLimiter, this.isAuthenticated, async (req, res) => { if (!req.user.isAdminOrUp) { Logger.error(`[Auth] Non-admin user "${req.user.username}" attempted to get issuer config`) return res.sendStatus(403) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 8966ff66..6446ecc8 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -182,7 +182,7 @@ class ApiRouter { this.router.post('/me/item/:id/bookmark', MeController.createBookmark.bind(this)) this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this)) this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this)) - this.router.patch('/me/password', MeController.updatePassword.bind(this)) + this.router.patch('/me/password', this.auth.authRateLimiter, MeController.updatePassword.bind(this)) this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this)) this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this)) this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this)) diff --git a/server/utils/rateLimiterFactory.js b/server/utils/rateLimiterFactory.js new file mode 100644 index 00000000..6f04d5ac --- /dev/null +++ b/server/utils/rateLimiterFactory.js @@ -0,0 +1,61 @@ +const { rateLimit, RateLimitRequestHandler } = require('express-rate-limit') +const Logger = require('../Logger') + +/** + * Factory for creating authentication rate limiters + */ +class RateLimiterFactory { + constructor() { + this.authRateLimiter = null + } + + /** + * Get the authentication rate limiter + * @returns {RateLimitRequestHandler} + */ + getAuthRateLimiter() { + if (this.authRateLimiter) { + return this.authRateLimiter + } + + let windowMs = 10 * 60 * 1000 // 10 minutes default + if (parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) > 0) { + windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) + } + + let max = 20 // 20 attempts default + if (parseInt(process.env.RATE_LIMIT_AUTH_MAX) > 0) { + max = parseInt(process.env.RATE_LIMIT_AUTH_MAX) + } + + let message = 'Too many requests, please try again later.' + if (process.env.RATE_LIMIT_AUTH_MESSAGE) { + message = process.env.RATE_LIMIT_AUTH_MESSAGE + } + + this.authRateLimiter = rateLimit({ + windowMs, + max, + message, + standardHeaders: true, + legacyHeaders: false, + handler: (req, res) => { + const userAgent = req.get('User-Agent') || 'Unknown' + const endpoint = req.path + const method = req.method + + Logger.warn(`[RateLimiter] Rate limit exceeded - IP: ${req.ip}, Endpoint: ${method} ${endpoint}, User-Agent: ${userAgent}`) + + res.status(429).json({ + error: 'Too many authentication attempts, please try again later.' + }) + } + }) + + Logger.debug(`[RateLimiterFactory] Created auth rate limiter: ${max} attempts per ${windowMs / 1000 / 60} minutes`) + + return this.authRateLimiter + } +} + +module.exports = new RateLimiterFactory() From 691f291843e8b09fbf607d66915990be6c4f0470 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 16:26:17 -0500 Subject: [PATCH 136/171] Update LibraryItemController unit test --- test/server/controllers/LibraryItemController.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js index 9972bd90..5a042239 100644 --- a/test/server/controllers/LibraryItemController.test.js +++ b/test/server/controllers/LibraryItemController.test.js @@ -6,6 +6,7 @@ const Database = require('../../../server/Database') const ApiRouter = require('../../../server/routers/ApiRouter') const LibraryItemController = require('../../../server/controllers/LibraryItemController') const ApiCacheManager = require('../../../server/managers/ApiCacheManager') +const Auth = require('../../../server/Auth') const Logger = require('../../../server/Logger') describe('LibraryItemController', () => { @@ -19,6 +20,7 @@ describe('LibraryItemController', () => { await Database.buildModels() apiRouter = new ApiRouter({ + auth: new Auth(), apiCacheManager: new ApiCacheManager() }) From 4102ed8be47742c435cc7464ab1edd6cf91cf8f2 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 16:49:20 -0500 Subject: [PATCH 137/171] Fix LazySeriesCard component test --- client/cypress/tests/components/cards/LazySeriesCard.cy.js | 1 + 1 file changed, 1 insertion(+) diff --git a/client/cypress/tests/components/cards/LazySeriesCard.cy.js b/client/cypress/tests/components/cards/LazySeriesCard.cy.js index 346259d2..12eec692 100644 --- a/client/cypress/tests/components/cards/LazySeriesCard.cy.js +++ b/client/cypress/tests/components/cards/LazySeriesCard.cy.js @@ -40,6 +40,7 @@ describe('LazySeriesCard', () => { }, $store: { getters: { + getServerSetting: () => 'MM/dd/yyyy', 'user/getUserCanUpdate': true, 'user/getUserMediaProgress': (id) => null, 'user/getSizeMultiplier': 1, From 6cc7a44a22c917184177b886a8f4918e3ac344aa Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 7 Jul 2025 17:21:25 -0500 Subject: [PATCH 138/171] Update oidc redirect to pass both new and old token in url --- client/pages/login.vue | 4 ++-- server/Auth.js | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index 5d447ed9..a9d44561 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -305,8 +305,8 @@ export default { }, async mounted() { // Token passed as query parameter after successful oidc login - if (this.$route.query?.setToken) { - localStorage.setItem('token', this.$route.query.setToken) + if (this.$route.query?.accessToken) { + localStorage.setItem('token', this.$route.query.accessToken) } if (localStorage.getItem('token')) { if (await this.checkAuth()) return // if valid user no need to check status diff --git a/server/Auth.js b/server/Auth.js index 601fe8f2..1d229ceb 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -266,7 +266,8 @@ class Auth { if (req.cookies.auth_cb) { let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : '' // UI request -> redirect to auth_cb url and send the jwt token as parameter - res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.accessToken}${stateQuery}`) + // TODO: Temporarily continue sending the old token as setToken + res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.token}&accessToken=${userResponse.user.accessToken}${stateQuery}`) } else { res.status(400).send('No callback or already expired') } From 4ff735526229f9d4172071395edb82b3651bd01c Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 8 Jul 2025 09:14:07 -0500 Subject: [PATCH 139/171] Fix hashPassword --- server/Server.js | 2 +- server/controllers/UserController.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/Server.js b/server/Server.js index 639ae210..cf0ad5c7 100644 --- a/server/Server.js +++ b/server/Server.js @@ -431,7 +431,7 @@ class Server { Logger.info(`[Server] Initializing new server`) const newRoot = req.body.newRoot const rootUsername = newRoot.username || 'root' - const rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : '' + const rootPash = newRoot.password ? await this.auth.localAuthStrategy.hashPassword(newRoot.password) : '' if (!rootPash) Logger.warn(`[Server] Creating root user with no password`) await Database.createRootUser(rootUsername, rootPash, this.auth) diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index 2ed92616..e72293cb 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -127,7 +127,7 @@ class UserController { } const userId = uuidv4() - const pash = await this.auth.hashPass(req.body.password) + const pash = await this.auth.localAuthStrategy.hashPassword(req.body.password) const token = this.auth.generateAccessToken({ id: userId, username: req.body.username }) const userType = req.body.type || 'user' @@ -252,7 +252,7 @@ class UserController { // Updating password if (updatePayload.password) { - user.pash = await this.auth.hashPass(updatePayload.password) + user.pash = await this.auth.localAuthStrategy.hashPassword(updatePayload.password) hasUpdates = true } From d0d152c20d3db90ccefbc4a79d21c1b889e09af9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 8 Jul 2025 09:45:24 -0500 Subject: [PATCH 140/171] Seperate setUserToken from setUser in store --- client/pages/login.vue | 1 + client/plugins/axios.js | 3 +++ client/store/user.js | 23 +++++++++-------------- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index a9d44561..51f60600 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -189,6 +189,7 @@ export default { this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) this.$store.commit('user/setUser', user) + this.$store.commit('user/setUserToken', user.accessToken) this.$store.dispatch('user/loadUserSettings') }, diff --git a/client/plugins/axios.js b/client/plugins/axios.js index 2724acb3..87eedca2 100644 --- a/client/plugins/axios.js +++ b/client/plugins/axios.js @@ -45,6 +45,7 @@ export default function ({ $axios, store, $root, app }) { if (originalRequest.url === '/auth/refresh' || originalRequest.url === '/login') { // Refresh failed or login failed, redirect to login store.commit('user/setUser', null) + store.commit('user/setUserToken', null) app.router.push('/login') return Promise.reject(error) } @@ -81,6 +82,7 @@ export default function ({ $axios, store, $root, app }) { // Update the token in store and localStorage store.commit('user/setUser', response.user) + store.commit('user/setUserToken', newAccessToken) // Emit event used to re-authenticate socket in default.vue since $root is not available here if (app.$eventBus) { @@ -106,6 +108,7 @@ export default function ({ $axios, store, $root, app }) { // Clear user data and redirect to login store.commit('user/setUser', null) + store.commit('user/setUserToken', null) app.router.push('/login') return Promise.reject(refreshError) diff --git a/client/store/user.js b/client/store/user.js index e37568f1..04dc8447 100644 --- a/client/store/user.js +++ b/client/store/user.js @@ -151,22 +151,17 @@ export const actions = { export const mutations = { setUser(state, user) { state.user = user - if (user) { - // Use accessToken from user if included in response (for login) - if (user.accessToken) localStorage.setItem('token', user.accessToken) - else if (localStorage.getItem('token')) { - user.accessToken = localStorage.getItem('token') - } else { - console.error('No access token found for user', user) - } - } else { - localStorage.removeItem('token') - } }, setUserToken(state, token) { - if (!state.user) return - state.user.accessToken = token - localStorage.setItem('token', token) + if (!token) { + localStorage.removeItem('token') + if (state.user) { + state.user.accessToken = null + } + } else if (state.user) { + state.user.accessToken = token + localStorage.setItem('token', token) + } }, updateMediaProgress(state, { id, data }) { if (!state.user) return From 8775e55762de9494bd66fe413b7072572bd00d8d Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 8 Jul 2025 16:39:50 -0500 Subject: [PATCH 141/171] Update jwt secret handling --- server/Auth.js | 9 +--- server/Server.js | 9 ++-- server/auth/TokenManager.js | 51 +++++++++++++---------- server/controllers/ApiKeyController.js | 4 +- server/models/ApiKey.js | 5 ++- server/objects/settings/ServerSettings.js | 1 + 6 files changed, 39 insertions(+), 40 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 1d229ceb..55eb334a 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -63,13 +63,6 @@ class Auth { return passport.authenticate('jwt', { session: false })(req, res, next) } - /** - * Generate a token which is used to encrpt/protect the jwts. - */ - async initTokenSecret() { - return this.tokenManager.initTokenSecret() - } - /** * Function to generate a jwt token for a given user * TODO: Old method with no expiration @@ -132,7 +125,7 @@ class Auth { new JwtStrategy( { jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]), - secretOrKey: Database.serverSettings.tokenSecret, + secretOrKey: TokenManager.TokenSecret, // Handle expiration manaully in order to disable api keys that are expired ignoreExpiration: true }, diff --git a/server/Server.js b/server/Server.js index cf0ad5c7..99e72d0c 100644 --- a/server/Server.js +++ b/server/Server.js @@ -156,14 +156,11 @@ class Server { } await Database.init(false) + // Create or set JWT secret in token manager + await this.auth.tokenManager.initTokenSecret() await Logger.logManager.init() - // Create token secret if does not exist (Added v2.1.0) - if (!Database.serverSettings.tokenSecret) { - await this.auth.initTokenSecret() - } - await this.cleanUserData() // Remove invalid user item progress await CacheManager.ensureCachePaths() @@ -264,7 +261,7 @@ class Server { // enable express-session app.use( expressSession({ - secret: global.ServerSettings.tokenSecret, + secret: this.auth.tokenManager.TokenSecret, resave: false, saveUninitialized: false, cookie: { diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index cc4783b5..3f5cc836 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -7,8 +7,13 @@ const requestIp = require('../libs/requestIp') const jwt = require('../libs/jsonwebtoken') class TokenManager { + /** @type {string} JWT secret key */ + static TokenSecret = null + constructor() { + /** @type {number} Refresh token expiry in seconds */ this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days + /** @type {number} Access token expiry in seconds */ this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) { @@ -19,28 +24,28 @@ class TokenManager { } } + get TokenSecret() { + return TokenManager.TokenSecret + } + /** - * Generate a token which is used to encrypt/protect the jwts. + * Token secret is used to sign and verify JWTs + * Set by ENV variable "JWT_SECRET_KEY" or generated and stored on server settings if not set */ async initTokenSecret() { - if (process.env.TOKEN_SECRET) { - // User can supply their own token secret - Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET + if (process.env.JWT_SECRET_KEY) { + // Use user supplied token secret + Logger.info('[TokenManager] JWT secret key set from ENV variable') + TokenManager.TokenSecret = process.env.JWT_SECRET_KEY + } else if (!Database.serverSettings.tokenSecret) { + // Generate new token secret and store it on server settings + Logger.info('[TokenManager] JWT secret key not found, generating one') + TokenManager.TokenSecret = require('crypto').randomBytes(256).toString('base64') + Database.serverSettings.tokenSecret = TokenManager.TokenSecret + await Database.updateServerSettings() } else { - Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') - } - await Database.updateServerSettings() - - // TODO: Old method of non-expiring tokens - // New token secret creation added in v2.1.0 so generate new API tokens for each user - const users = await Database.userModel.findAll({ - attributes: ['id', 'username', 'token'] - }) - if (users.length) { - for (const user of users) { - user.token = this.generateAccessToken(user) - await user.save({ hooks: false }) - } + // Use existing token secret from server settings + TokenManager.TokenSecret = Database.serverSettings.tokenSecret } } @@ -70,7 +75,7 @@ class TokenManager { */ static validateAccessToken(token) { try { - return jwt.verify(token, global.ServerSettings.tokenSecret) + return jwt.verify(token, TokenManager.TokenSecret) } catch (err) { return null } @@ -85,7 +90,7 @@ class TokenManager { * @returns {string} */ generateAccessToken(user) { - return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret) + return jwt.sign({ userId: user.id, username: user.username }, TokenManager.TokenSecret) } /** @@ -104,7 +109,7 @@ class TokenManager { expiresIn: this.AccessTokenExpiry } try { - return jwt.sign(payload, global.ServerSettings.tokenSecret, options) + return jwt.sign(payload, TokenManager.TokenSecret, options) } catch (error) { Logger.error(`[TokenManager] Error generating access token for user ${user.id}: ${error}`) return null @@ -127,7 +132,7 @@ class TokenManager { expiresIn: this.RefreshTokenExpiry } try { - return jwt.sign(payload, global.ServerSettings.tokenSecret, options) + return jwt.sign(payload, TokenManager.TokenSecret, options) } catch (error) { Logger.error(`[TokenManager] Error generating refresh token for user ${user.id}: ${error}`) return null @@ -261,7 +266,7 @@ class TokenManager { async handleRefreshToken(refreshToken, req, res) { try { // Verify the refresh token - const decoded = jwt.verify(refreshToken, global.ServerSettings.tokenSecret) + const decoded = jwt.verify(refreshToken, TokenManager.TokenSecret) if (decoded.type !== 'refresh') { Logger.error(`[TokenManager] Failed to refresh token. Invalid token type: ${decoded.type}`) diff --git a/server/controllers/ApiKeyController.js b/server/controllers/ApiKeyController.js index f60480df..c09d0736 100644 --- a/server/controllers/ApiKeyController.js +++ b/server/controllers/ApiKeyController.js @@ -42,6 +42,8 @@ class ApiKeyController { /** * POST: /api/api-keys * + * @this {import('../routers/ApiRouter')} + * * @param {RequestWithUser} req * @param {Response} res */ @@ -69,7 +71,7 @@ class ApiKeyController { } const keyId = uuidv4() // Generate key id ahead of time to use in JWT - const apiKey = await Database.apiKeyModel.generateApiKey(keyId, req.body.name, req.body.expiresIn) + const apiKey = await Database.apiKeyModel.generateApiKey(this.auth.tokenManager.TokenSecret, keyId, req.body.name, req.body.expiresIn) if (!apiKey) { Logger.error(`[ApiKeyController] create: Error generating API key`) diff --git a/server/models/ApiKey.js b/server/models/ApiKey.js index 7b61f731..7c61611d 100644 --- a/server/models/ApiKey.js +++ b/server/models/ApiKey.js @@ -157,12 +157,13 @@ class ApiKey extends Model { /** * Generate a new api key + * @param {string} tokenSecret * @param {string} keyId * @param {string} name * @param {number} [expiresIn] - Seconds until the api key expires or undefined for no expiration * @returns {Promise} */ - static async generateApiKey(keyId, name, expiresIn) { + static async generateApiKey(tokenSecret, keyId, name, expiresIn) { const options = {} if (expiresIn && !isNaN(expiresIn) && expiresIn > 0) { options.expiresIn = expiresIn @@ -175,7 +176,7 @@ class ApiKey extends Model { name, type: 'api' }, - global.ServerSettings.tokenSecret, + tokenSecret, options, (err, token) => { if (err) { diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 29913e44..4f0aa97b 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -7,6 +7,7 @@ const User = require('../../models/User') class ServerSettings { constructor(settings) { this.id = 'server-settings' + /** @type {string} JWT secret key ONLY used when JWT_SECRET_KEY is not set in ENV */ this.tokenSecret = null // Scanner From df1391d93f6cf2a33b2420b0dc38f6ed40b6bcca Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 9 Jul 2025 13:42:53 -0500 Subject: [PATCH 142/171] Fix scanner after deleting single file books #4459 --- server/scanner/LibraryItemScanner.js | 5 +++++ server/scanner/LibraryScanner.js | 5 +++++ server/utils/fileUtils.js | 2 +- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/server/scanner/LibraryItemScanner.js b/server/scanner/LibraryItemScanner.js index 133a1e30..501df427 100644 --- a/server/scanner/LibraryItemScanner.js +++ b/server/scanner/LibraryItemScanner.js @@ -206,6 +206,11 @@ class LibraryItemScanner { async scanPotentialNewLibraryItem(libraryItemPath, library, folder, isSingleMediaItem) { const libraryItemScanData = await this.getLibraryItemScanData(libraryItemPath, library, folder, isSingleMediaItem) + if (!libraryItemScanData.libraryFiles.length) { + Logger.info(`[LibraryItemScanner] Library item at path "${libraryItemPath}" has no files - ignoring`) + return null + } + const scanLogger = new ScanLogger() scanLogger.verbose = true scanLogger.setData('libraryItem', libraryItemScanData.relPath) diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index bc174d7a..640c82d7 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -606,6 +606,11 @@ class LibraryScanner { } else if (library.settings.audiobooksOnly && !hasAudioFiles(fileUpdateGroup, itemDir)) { Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" has no audio files`) continue + } else if (!(await fs.pathExists(fullPath))) { + Logger.info(`[LibraryScanner] File update group "${itemDir}" does not exist - ignoring`) + + itemGroupingResults[itemDir] = ScanResult.NOTHING + continue } // Check if a library item is a subdirectory of this dir diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index f80c4acd..2da6b4c9 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -109,7 +109,7 @@ function getIno(path) { .stat(path, { bigint: true }) .then((data) => String(data.ino)) .catch((err) => { - Logger.error('[Utils] Failed to get ino for path', path, err) + Logger.warn(`[Utils] Failed to get ino for path "${path}"`, err) return null }) } From d9a37506677ac3983a1a488653ac91ac9ad5fc4f Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Sat, 28 Jun 2025 20:21:58 +0200 Subject: [PATCH 143/171] Translated using Weblate (German) Currently translated at 99.9% (1106 of 1107 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/de.json b/client/strings/de.json index 04de453e..6ce78103 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -168,7 +168,7 @@ "HeaderNotifications": "Benachrichtigungen", "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentifizierung", "HeaderOpenListeningSessions": "Aktive Hörbuch-Sitzungen", - "HeaderOpenRSSFeed": "RSS-Feed öffnen", + "HeaderOpenRSSFeed": "RSS-Feed offen", "HeaderOtherFiles": "Sonstige Dateien", "HeaderPasswordAuthentication": "Passwortauthentifizierung", "HeaderPermissions": "Berechtigungen", From 06535723966a4c04b825a3933df56bdc9c4b163c Mon Sep 17 00:00:00 2001 From: Vito0912 <86927734+Vito0912@users.noreply.github.com> Date: Sat, 28 Jun 2025 20:22:48 +0200 Subject: [PATCH 144/171] Translated using Weblate (German) Currently translated at 99.9% (1106 of 1107 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index 6ce78103..dbcb42c2 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -168,7 +168,7 @@ "HeaderNotifications": "Benachrichtigungen", "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentifizierung", "HeaderOpenListeningSessions": "Aktive Hörbuch-Sitzungen", - "HeaderOpenRSSFeed": "RSS-Feed offen", + "HeaderOpenRSSFeed": "RSS-Feed öffnen", "HeaderOtherFiles": "Sonstige Dateien", "HeaderPasswordAuthentication": "Passwortauthentifizierung", "HeaderPermissions": "Berechtigungen", @@ -514,7 +514,7 @@ "LabelPublishers": "Herausgeber", "LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail", "LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers", - "LabelRSSFeedOpen": "RSS Feed öffnen", + "LabelRSSFeedOpen": "RSS Feed offen", "LabelRSSFeedPreventIndexing": "Indizierung verhindern", "LabelRSSFeedSlug": "RSS-Feed-Schlagwort", "LabelRSSFeedURL": "RSS-Feed-URL", From 7d0def0edbea7dddd5e7e628ae17b9b6e00c62d7 Mon Sep 17 00:00:00 2001 From: Daniel Schosser Date: Tue, 1 Jul 2025 13:34:46 +0200 Subject: [PATCH 145/171] Translated using Weblate (German) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/de.json b/client/strings/de.json index dbcb42c2..c7821bf6 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -757,6 +757,7 @@ "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?", "MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird entfernt! Bist du dir sicher?", "MessageConfirmRemoveEpisode": "Episode \"{0}\" wird entfernt! Bist du dir sicher?", + "MessageConfirmRemoveEpisodeNote": "Hinweis: Die Audiodatei wird nicht gelöscht, es sei denn \"Datei dauerhaft löschen\" ist aktiviert.", "MessageConfirmRemoveEpisodes": "{0} Episoden werden entfernt! Bist du dir sicher?", "MessageConfirmRemoveListeningSessions": "Bist du dir sicher, dass du {0} Hörsitzungen enfernen möchtest?", "MessageConfirmRemoveMetadataFiles": "Bist du sicher, dass du alle metadata.{0} Dateien in deinen Bibliotheksordnern löschen willst?", From f2e0b9762c8c97ac663b30edf325226cbeaa8551 Mon Sep 17 00:00:00 2001 From: Daniel Schosser Date: Tue, 1 Jul 2025 13:35:38 +0200 Subject: [PATCH 146/171] Translated using Weblate (German) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/de/ --- client/strings/de.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index c7821bf6..0e62a2a0 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -32,7 +32,7 @@ "ButtonEditChapters": "Kapitel bearbeiten", "ButtonEditPodcast": "Podcast bearbeiten", "ButtonEnable": "Aktivieren", - "ButtonFireAndFail": "Abfeuern und versagen", + "ButtonFireAndFail": "Abschicken und fehlschlagen", "ButtonFireOnTest": "Test-Event abfeuern", "ButtonForceReScan": "Komplett-Scan (alle Medien)", "ButtonFullPath": "Vollständiger Pfad", @@ -757,7 +757,7 @@ "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Bist du dir sicher?", "MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird entfernt! Bist du dir sicher?", "MessageConfirmRemoveEpisode": "Episode \"{0}\" wird entfernt! Bist du dir sicher?", - "MessageConfirmRemoveEpisodeNote": "Hinweis: Die Audiodatei wird nicht gelöscht, es sei denn \"Datei dauerhaft löschen\" ist aktiviert.", + "MessageConfirmRemoveEpisodeNote": "Hinweis: Die Audiodatei wird nicht gelöscht, es sei denn \"Datei dauerhaft löschen\" ist aktiviert", "MessageConfirmRemoveEpisodes": "{0} Episoden werden entfernt! Bist du dir sicher?", "MessageConfirmRemoveListeningSessions": "Bist du dir sicher, dass du {0} Hörsitzungen enfernen möchtest?", "MessageConfirmRemoveMetadataFiles": "Bist du sicher, dass du alle metadata.{0} Dateien in deinen Bibliotheksordnern löschen willst?", From 794adf0292651dc761b217e4bee1e0ee9853fd09 Mon Sep 17 00:00:00 2001 From: DavevanIersel Date: Tue, 1 Jul 2025 21:10:49 +0200 Subject: [PATCH 147/171] Translated using Weblate (Dutch) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/ --- client/strings/nl.json | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/client/strings/nl.json b/client/strings/nl.json index c20efa12..cefa5c4c 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -24,7 +24,7 @@ "ButtonCloseSession": "Sluit Sessie", "ButtonCollections": "Collecties", "ButtonConfigureScanner": "Configureer scanner", - "ButtonCreate": "Creëer", + "ButtonCreate": "Aanmaken", "ButtonCreateBackup": "Maak back-up", "ButtonDelete": "Verwijder", "ButtonDownloadQueue": "Wachtrij", @@ -43,9 +43,9 @@ "ButtonJumpForward": "Spring vooruit", "ButtonLatest": "Meest recent", "ButtonLibrary": "Bibliotheek", - "ButtonLogout": "Log uit", + "ButtonLogout": "Uitloggen", "ButtonLookup": "Zoeken", - "ButtonManageTracks": "Beheer tracks", + "ButtonManageTracks": "Tracks beheren", "ButtonMapChapterTitles": "Hoofdstuktitels mappen", "ButtonMatchAllAuthors": "Alle auteurs matchen", "ButtonMatchBooks": "Alle boeken matchen", @@ -72,7 +72,7 @@ "ButtonQuickEmbedMetadata": "Snel Metadata Insluiten", "ButtonQuickMatch": "Snelle match", "ButtonReScan": "Nieuwe scan", - "ButtonRead": "Lees", + "ButtonRead": "Lezen", "ButtonReadLess": "Lees minder", "ButtonReadMore": "Lees meer", "ButtonRefresh": "Verversen", @@ -107,7 +107,7 @@ "ButtonUnlinkOpenId": "OpenID Ontkoppelen", "ButtonUpload": "Upload", "ButtonUploadBackup": "Upload back-up", - "ButtonUploadCover": "Upload cover", + "ButtonUploadCover": "Omslag uploaden", "ButtonUploadOPMLFile": "Upload OPML-bestand", "ButtonUserDelete": "Verwijder gebruiker {0}", "ButtonUserEdit": "Wijzig gebruiker {0}", @@ -178,7 +178,7 @@ "HeaderPlaylistItems": "Onderdelen in afspeellijst", "HeaderPodcastsToAdd": "Toe te voegen podcasts", "HeaderPresets": "Voorinstellingen", - "HeaderPreviewCover": "Preview cover", + "HeaderPreviewCover": "Voorbeeld omslag", "HeaderRSSFeedGeneral": "RSS-details", "HeaderRSSFeedIsOpen": "RSS-feed is open", "HeaderRSSFeeds": "RSS-feeds", @@ -285,7 +285,7 @@ "LabelContinueReading": "Verder lezen", "LabelContinueSeries": "Doorgaan met Serie", "LabelCover": "Omslag", - "LabelCoverImageURL": "Coverafbeelding URL", + "LabelCoverImageURL": "Omslagafbeelding-URL", "LabelCoverProvider": "Omslag bron", "LabelCreatedAt": "Gecreëerd op", "LabelCronExpression": "Cron-uitdrukking", @@ -322,7 +322,7 @@ "LabelEmailSettingsSecure": "Veilig", "LabelEmailSettingsSecureHelp": "Als 'waar', dan gebruikt de verbinding TLS om met de server te verbinden. Als 'onwaar', dan wordt TLS gebruikt als de server de STARTTLS-extensie ondersteunt. In de meeste gevallen kies je voor 'waar' verbindt met poort 465. Voo poort 587 of 25, laat op 'onwaar'. (van nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Test-adres", - "LabelEmbeddedCover": "Ingesloten cover", + "LabelEmbeddedCover": "Ingesloten omslag", "LabelEnable": "Inschakelen", "LabelEncodingBackupLocation": "Er wordt een back-up van uw originele audiobestanden opgeslagen in:", "LabelEncodingChaptersNotEmbedded": "Hoofdstukken zijn niet ingesloten in audioboeken met meerdere sporen.", @@ -331,7 +331,7 @@ "LabelEncodingInfoEmbedded": "Metagegevens worden ingesloten in de audiotracks in uw audioboekmap.", "LabelEncodingStartedNavigation": "Eenmaal de taak is gestart kan u weg navigeren van deze pagina.", "LabelEncodingTimeWarning": "Encoding kan tot 30 minuten duren.", - "LabelEncodingWarningAdvancedSettings": "Waarschuwing: update deze instellingen niet tenzij u bekend bent met de coderingsopties van ffmpeg.", + "LabelEncodingWarningAdvancedSettings": "Waarschuwing: pas deze instellingen niet aan tenzij u bekend bent met de coderingsopties van ffmpeg.", "LabelEncodingWatcherDisabled": "Als u de watcher hebt uitgeschakeld, moet u het audioboek daarna opnieuw scannen.", "LabelEnd": "Einde", "LabelEndOfChapter": "Einde van het Hoofdstuk", @@ -373,7 +373,7 @@ "LabelFull": "Vol", "LabelGenre": "Genre", "LabelGenres": "Genres", - "LabelHardDeleteFile": "Hard-delete bestand", + "LabelHardDeleteFile": "Bestand permanent verwijderen", "LabelHasEbook": "Heeft Ebook", "LabelHasSupplementaryEbook": "Heeft aanvullend Ebook", "LabelHideSubtitles": "Ondertitels Verstoppen", @@ -407,7 +407,7 @@ "LabelLastBookUpdated": "Laatst bijgewerkte boek", "LabelLastSeen": "Laatst gezien", "LabelLastTime": "Laatste keer", - "LabelLastUpdate": "Laatste update", + "LabelLastUpdate": "Laatste wijziging", "LabelLayout": "Layout", "LabelLayoutSinglePage": "Enkele pagina", "LabelLayoutSplitPage": "Gesplitste pagina", @@ -757,6 +757,7 @@ "MessageConfirmRemoveAuthor": "Weet je zeker dat je auteur \"{0}\" wil verwijderen?", "MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?", "MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?", + "MessageConfirmRemoveEpisodeNote": "Let op: Het audiobestand wordt niet verwijderd, tenzij je ‘Bestand permanent verwijderen’ inschakelt", "MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen wil verwijderen?", "MessageConfirmRemoveListeningSessions": "Weet je zeker dat je {0} luistersessies wilt verwijderen?", "MessageConfirmRemoveMetadataFiles": "Bent u zeker dat u alle metadata wil verwijderen. {0} bestanden in uw bibliotheel item folders?", From 8255e4308cf3c64fc3fbf1ae7392993e33bd8599 Mon Sep 17 00:00:00 2001 From: DavevanIersel Date: Tue, 1 Jul 2025 21:36:06 +0200 Subject: [PATCH 148/171] Translated using Weblate (Dutch) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/ --- client/strings/nl.json | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/client/strings/nl.json b/client/strings/nl.json index cefa5c4c..adac094c 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -322,7 +322,7 @@ "LabelEmailSettingsSecure": "Veilig", "LabelEmailSettingsSecureHelp": "Als 'waar', dan gebruikt de verbinding TLS om met de server te verbinden. Als 'onwaar', dan wordt TLS gebruikt als de server de STARTTLS-extensie ondersteunt. In de meeste gevallen kies je voor 'waar' verbindt met poort 465. Voo poort 587 of 25, laat op 'onwaar'. (van nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Test-adres", - "LabelEmbeddedCover": "Ingesloten omslag", + "LabelEmbeddedCover": "Omslag in bestand", "LabelEnable": "Inschakelen", "LabelEncodingBackupLocation": "Er wordt een back-up van uw originele audiobestanden opgeslagen in:", "LabelEncodingChaptersNotEmbedded": "Hoofdstukken zijn niet ingesloten in audioboeken met meerdere sporen.", @@ -532,7 +532,7 @@ "LabelRemoveAllMetadataAbs": "Verwijder alle metadata.abs bestanden", "LabelRemoveAllMetadataJson": "Verwijder alle metadata.json bestanden", "LabelRemoveAudibleBranding": "Verwijder Audible intro en outro uit hoofdstukken", - "LabelRemoveCover": "Verwijder cover", + "LabelRemoveCover": "Omslag verwijderen", "LabelRemoveMetadataFile": "Verwijder metadata bestanden in bibliotheek item folders", "LabelRemoveMetadataFileHelp": "Verwijder alle metadata.json en metadata.abs bestanden in uw {0} folders.", "LabelRowsPerPage": "Rijen per pagina", @@ -568,8 +568,8 @@ "LabelSettingsEpubsAllowScriptedContentHelp": "Sta toe dat epub-bestanden scripts uitvoeren. Het wordt aanbevolen om deze instelling uitgeschakeld te houden, tenzij u de bron van de epub-bestanden vertrouwt.", "LabelSettingsExperimentalFeatures": "Experimentele functies", "LabelSettingsExperimentalFeaturesHelp": "Functies in ontwikkeling die je feedback en testing kunnen gebruiken. Klik om de Github-discussie te openen.", - "LabelSettingsFindCovers": "Zoek covers", - "LabelSettingsFindCoversHelp": "Als je audioboek geen ingesloten cover of cover in de map heeft, zal de scanner proberen een cover te vinden.
Opmerking: Dit zal de scan-duur verlengen", + "LabelSettingsFindCovers": "Omslagen zoeken", + "LabelSettingsFindCoversHelp": "Als je audioboek geen omslag in het bestand of in de map heeft, zal de scanner automatisch proberen een omslag te vinden.
Opmerking: Dit kan de scantijd verlengen", "LabelSettingsHideSingleBookSeries": "Verberg series met een enkel boek", "LabelSettingsHideSingleBookSeriesHelp": "Series die slechts een enkel boek bevatten worden verborgen op de seriespagina en de homepagina-planken.", "LabelSettingsHomePageBookshelfView": "Boekenplank-view voor homepagina", @@ -581,16 +581,16 @@ "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "De Continue Series home page shelf toont het eerste boek dat nog niet is begonnen in series waarvan er minstens één is voltooid en er geen boeken in uitvoering zijn. Als u deze instelling inschakelt, wordt de serie voortgezet vanaf het boek dat het verst is voltooid in plaats van het eerste boek dat nog niet is begonnen.", "LabelSettingsParseSubtitles": "Parseer subtitel", "LabelSettingsParseSubtitlesHelp": "Haal subtitels uit mapnaam van audioboek.
Subtitel moet gescheiden zijn met \" - \"
b.v. \"Boektitel - Een Subtitel Hier\" heeft als subtitel \"Een Subtitel Hier\"", - "LabelSettingsPreferMatchedMetadata": "Prefereer gematchte metadata", + "LabelSettingsPreferMatchedMetadata": "Geef voorkeur aan gematchte metadata", "LabelSettingsPreferMatchedMetadataHelp": "Gematchte data zal onderdeeldetails overschrijven bij gebruik van Quick Match. Standaard vult Quick Match uitsluitend ontbrekende details aan.", "LabelSettingsSkipMatchingBooksWithASIN": "Sla matchen van boeken over die al over een ASIN beschikken", "LabelSettingsSkipMatchingBooksWithISBN": "Sla matchen van boeken over die al over een ISBN beschikken", "LabelSettingsSortingIgnorePrefixes": "Negeer voorvoegsels bij sorteren", "LabelSettingsSortingIgnorePrefixesHelp": "b.v. voor voorvoegsel \"The\" wordt titel \"The Title\" dan gesorteerd als \"Title, The\"", - "LabelSettingsSquareBookCovers": "Gebruik vierkante boekcovers", - "LabelSettingsSquareBookCoversHelp": "Prefereer gebruik van vierkante covers boven standaard 1.6:1 boekcovers", - "LabelSettingsStoreCoversWithItem": "Bewaar covers bij onderdeel", - "LabelSettingsStoreCoversWithItemHelp": "Standaard worden covers bewaard in /metadata/items, door deze instelling in te schakelen zullen covers in de map van je bibliotheekonderdeel bewaard worden. Slechts een bestand genaamd \"cover\" zal worden bewaard", + "LabelSettingsSquareBookCovers": "Gebruik vierkante boekomslagen", + "LabelSettingsSquareBookCoversHelp": "Gebruik vierkante boekomslagen in plaats van standaard 1,6:1", + "LabelSettingsStoreCoversWithItem": "Bewaar omslagen bij onderdeel", + "LabelSettingsStoreCoversWithItemHelp": "Omslagen worden standaard in /metadata/items opgeslagen. Bij inschakelen worden ze in de map van het bibliotheekitem zelf opgeslagen. Slechts een bestand genaamd \"cover\" zal worden bewaard", "LabelSettingsStoreMetadataWithItem": "Bewaar metadata bij onderdeel", "LabelSettingsStoreMetadataWithItemHelp": "Standaard worden metadata-bestanden bewaard in /metadata/items, door deze instelling in te schakelen zullen metadata bestanden in de map van je bibliotheekonderdeel bewaard worden", "LabelSettingsTimeFormat": "Tijdformat", @@ -652,12 +652,12 @@ "LabelTimeToShift": "Tijd op te schuiven in seconden", "LabelTitle": "Titel", "LabelToolsEmbedMetadata": "Metadata insluiten", - "LabelToolsEmbedMetadataDescription": "Metadata insluiten in audiobestanden, inclusief coverafbeelding en hoofdstukken.", + "LabelToolsEmbedMetadataDescription": "Metadata insluiten in audiobestanden, inclusief omslagafbeelding en hoofdstukken.", "LabelToolsM4bEncoder": "M4B Encoder", "LabelToolsMakeM4b": "Maak M4B-audioboekbestand", - "LabelToolsMakeM4bDescription": "Genereer een .M4B-audioboekbestand met ingesloten metadata, coverafbeelding en hoofdstukken.", + "LabelToolsMakeM4bDescription": "Genereer een .M4B-audioboekbestand met ingesloten metadata, omslagafbeelding en hoofdstukken.", "LabelToolsSplitM4b": "Splitst M4B in MP3's", - "LabelToolsSplitM4bDescription": "Maak MP3's van een M4B, gesplitst per hoofdstuk met ingesloten metadata, coverafbeelding en hoofdstukken.", + "LabelToolsSplitM4bDescription": "Maak MP3's van een M4B, gesplitst per hoofdstuk met ingesloten metadata, omslagafbeelding en hoofdstukken.", "LabelTotalDuration": "Totale duur", "LabelTotalTimeListened": "Totale tijd geluisterd", "LabelTrackFromFilename": "Track vanuit bestandsnaam", @@ -672,8 +672,8 @@ "LabelUndo": "Ongedaan maken", "LabelUnknown": "Onbekend", "LabelUnknownPublishDate": "Onbekende uitgeefdatum", - "LabelUpdateCover": "Cover bijwerken", - "LabelUpdateCoverHelp": "Sta overschrijven van bestaande covers toe voor de geselecteerde boeken wanneer een match is gevonden", + "LabelUpdateCover": "Omslag bijwerken", + "LabelUpdateCoverHelp": "Sta overschrijven van bestaande omslagen toe voor de geselecteerde boeken wanneer een match is gevonden", "LabelUpdateDetails": "Details bijwerken", "LabelUpdateDetailsHelp": "Sta overschrijven van bestaande details toe voor de geselecteerde boeken wanneer een match is gevonden", "LabelUpdatedAt": "Bijgewerkt op", @@ -715,7 +715,7 @@ "MessageBackupsLocationPathEmpty": "Backup locatie pad kan niet leeg zijn", "MessageBatchEditPopulateMapDetailsAllHelp": "Vul actieve velden in met data van alle items. Velden met meerdere waarden zullen worden samengevoegd", "MessageBatchEditPopulateMapDetailsItemHelp": "Vul actieve folder detail velden met de data van dit item", - "MessageBatchQuickMatchDescription": "Quick Match zal proberen ontbrekende covers en metadata voor de geselecteerde onderdelen te matchten. Schakel de opties hieronder in om Quick Match toe te staan bestaande covers en/of metadata te overschrijven.", + "MessageBatchQuickMatchDescription": "Quick Match probeert ontbrekende omslagen en metadata toe te voegen aan de geselecteerde items. Schakel de opties hieronder in om Quick Match bestaande omslagen en/of metadata te laten overschrijven.", "MessageBookshelfNoCollections": "Je hebt nog geen collecties gemaakt", "MessageBookshelfNoCollectionsHelp": "Collecties zijn publiekelijk. Alle gebruikers met toegang tot de bibliotheek kunnen ze zien.", "MessageBookshelfNoRSSFeeds": "Geen RSS-feeds geopend", @@ -799,14 +799,14 @@ "MessageMarkAllEpisodesNotFinished": "Markeer alle afleveringen als niet voltooid", "MessageMarkAsFinished": "Markeer als Voltooid", "MessageMarkAsNotFinished": "Markeer als Niet Voltooid", - "MessageMatchBooksDescription": "zal proberen boeken in de bibliotheek te matchen met een boek uit de geselecteerde bron en lege details en coverafbeelding te vullen. Overschrijft details niet.", + "MessageMatchBooksDescription": "zal proberen boeken in de bibliotheek te koppelen aan een boek uit de geselecteerde bron en ontbrekende gegevens en een omslag toe te voegen. Overschrijft geen bestaande gegevens.", "MessageNoAudioTracks": "Geen audiotracks", "MessageNoAuthors": "Geen auteurs", "MessageNoBackups": "Geen back-ups", "MessageNoBookmarks": "Geen boekwijzers", "MessageNoChapters": "Geen hoofdstukken", "MessageNoCollections": "Geen collecties", - "MessageNoCoversFound": "Geen covers gevonden", + "MessageNoCoversFound": "Geen omslagen gevonden", "MessageNoDescription": "Geen beschrijving", "MessageNoDevices": "Geen Apparaten", "MessageNoDownloadsInProgress": "Geen downloads bezig op dit moment", @@ -844,7 +844,7 @@ "MessageQuickEmbedInProgress": "Snelle inbedding in uitvoering", "MessageQuickEmbedQueue": "In de wachtrij voor snelle insluiting ({0} in wachtrij)", "MessageQuickMatchAllEpisodes": "Alle Afleveringen Snel Matchen", - "MessageQuickMatchDescription": "Vul lege onderdeeldetails & cover met eerste matchresultaat van '{0}'. Overschrijft geen details tenzij 'Prefereer gematchte metadata' serverinstelling is ingeschakeld.", + "MessageQuickMatchDescription": "Vult ontbrekende gegevens & omslag met eerste matchresultaat van '{0}'. Overschrijft gegevens alleen als de serverinstelling ‘Geef voorkeur aan gematchte metadata’ is ingeschakeld.", "MessageRemoveChapter": "Verwijder hoofdstuk", "MessageRemoveEpisodes": "Verwijder {0} aflevering(en)", "MessageRemoveFromPlayerQueue": "Verwijder uit afspeelwachtrij", @@ -852,7 +852,7 @@ "MessageReportBugsAndContribute": "Rapporteer bugs, vraag functionaliteiten aan en draag bij op", "MessageResetChaptersConfirm": "Weet je zeker dat je de hoofdstukken wil resetten en de wijzigingen die je gemaakt hebt ongedaan wil maken?", "MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op", - "MessageRestoreBackupWarning": "Herstellen met een back-up zal de volledige database in /config en de covers in /metadata/items & /metadata/authors overschrijven.

Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om covers en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.

Alle clients die van je server gebruik maken zullen automatisch worden ververst.", + "MessageRestoreBackupWarning": "Een back-up herstellen zal de volledige database in /config en de omslagen in /metadata/items & /metadata/authors overschrijven.

Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om omslagen en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.

Alle apparaten die je server gebruiken, worden automatisch ververst.", "MessageScheduleLibraryScanNote": "Voor de meeste gebruikers is het raadzaam om deze functie uitgeschakeld te laten en de folder watcher-instelling ingeschakeld te houden. De folder watcher detecteert automatisch wijzigingen in uw bibliotheekmappen. De folder watcher werkt niet voor elk bestandssysteem (zoals NFS), dus geplande bibliotheekscans kunnen in plaats daarvan worden gebruikt.", "MessageScheduleRunEveryWeekdayAtTime": "Elke {0} uitvoeren op {1}", "MessageSearchResultsFor": "Zoekresultaten voor", @@ -986,7 +986,7 @@ "ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt", "ToastCollectionRemoveSuccess": "Collectie verwijderd", "ToastCollectionUpdateSuccess": "Collectie bijgewerkt", - "ToastCoverUpdateFailed": "Cover update mislukt", + "ToastCoverUpdateFailed": "Omslag bijwerken mislukt", "ToastDateTimeInvalidOrIncomplete": "Datum en tijd ongeldig of onvolledig", "ToastDeleteFileFailed": "Bestand verwijderen mislukt", "ToastDeleteFileSuccess": "Bestand verwijderd", @@ -1008,7 +1008,7 @@ "ToastInvalidImageUrl": "Ongeldige afbeeldings-URL", "ToastInvalidMaxEpisodesToDownload": "Ongeldig maximum aantal afleveringen om te downloaden", "ToastInvalidUrl": "Ongeldige URL", - "ToastItemCoverUpdateSuccess": "Cover onderdeel bijgewerkt", + "ToastItemCoverUpdateSuccess": "Omslag bijgewerkt", "ToastItemDeletedFailed": "Item verwijderen mislukt", "ToastItemDeletedSuccess": "Verwijderd item", "ToastItemDetailsUpdateSuccess": "Details onderdeel bijgewerkt", From 6d2b1df5602ed055ac381e1e142d961b1def4af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=93=D0=BE=D1=80?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D1=96=D1=87?= Date: Tue, 1 Jul 2025 18:21:01 +0200 Subject: [PATCH 149/171] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/strings/uk.json b/client/strings/uk.json index 70cb4bb3..18d694ec 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -44,7 +44,7 @@ "ButtonLatest": "Останні", "ButtonLibrary": "Бібліотека", "ButtonLogout": "Вийти", - "ButtonLookup": "Пошук", + "ButtonLookup": "Пошуки", "ButtonManageTracks": "Керувати доріжками", "ButtonMapChapterTitles": "Призначити назви глав", "ButtonMatchAllAuthors": "Віднайти усіх авторів", @@ -757,6 +757,7 @@ "MessageConfirmRemoveAuthor": "Ви дійсно бажаєте видалити автора \"{0}\"?", "MessageConfirmRemoveCollection": "Ви дійсно бажаєте видалити добірку \"{0}\"?", "MessageConfirmRemoveEpisode": "Ви дійсно бажаєте видалити епізод \"{0}\"?", + "MessageConfirmRemoveEpisodeNote": "Примітка: Це не видаляє аудіофайл, якщо не перемикає \"файл жорсткого видалення\"", "MessageConfirmRemoveEpisodes": "Ви дійсно бажаєте видалити епізодів: {0}?", "MessageConfirmRemoveListeningSessions": "Ви дійсно бажаєте видалити сеанси прослуховування: {0}?", "MessageConfirmRemoveMetadataFiles": "Ви впевнені, що хочете видалити всі файли metadata.{0} у папках елементів вашої бібліотеки?", @@ -818,7 +819,7 @@ "MessageNoItems": "Елементи відсутні", "MessageNoItemsFound": "Елементів не знайдено", "MessageNoListeningSessions": "Сеанси прослуховування відсутні", - "MessageNoLogs": "Немає журналів", + "MessageNoLogs": "Немає журнали", "MessageNoMediaProgress": "Прогрес відсутній", "MessageNoNotifications": "Сповіщення відсутні", "MessageNoPodcastFeed": "Некоректний подкаст: немає каналу", From 576d7dc0249ad34ed8a0a883d1e2c20abbe598de Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Tue, 1 Jul 2025 07:45:41 +0200 Subject: [PATCH 150/171] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.9% (1107 of 1108 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, 3 insertions(+), 3 deletions(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 0b6c3422..84803c4b 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -613,12 +613,12 @@ "LabelStartedAt": "从这开始", "LabelStatsAudioTracks": "音轨", "LabelStatsAuthors": "作者", - "LabelStatsBestDay": "最好的一天", + "LabelStatsBestDay": "单日听书纪录", "LabelStatsDailyAverage": "每日平均值", - "LabelStatsDays": "天", + "LabelStatsDays": "连续收听", "LabelStatsDaysListened": "收听天数", "LabelStatsHours": "小时", - "LabelStatsInARow": "在一行", + "LabelStatsInARow": "天", "LabelStatsItemsFinished": "已完成的项目", "LabelStatsItemsInLibrary": "媒体库中的项目", "LabelStatsMinutes": "分钟", From d41276ba8caa7eced1b65f3c263825990520674d Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Tue, 1 Jul 2025 07:53:54 +0200 Subject: [PATCH 151/171] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.9% (1107 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 84803c4b..4e9aa8b6 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -613,7 +613,7 @@ "LabelStartedAt": "从这开始", "LabelStatsAudioTracks": "音轨", "LabelStatsAuthors": "作者", - "LabelStatsBestDay": "单日听书纪录", + "LabelStatsBestDay": "单日最高", "LabelStatsDailyAverage": "每日平均值", "LabelStatsDays": "连续收听", "LabelStatsDaysListened": "收听天数", From 84b77f4c7f067c0ce2bdf9337194466f3f968ed0 Mon Sep 17 00:00:00 2001 From: DavevanIersel Date: Thu, 3 Jul 2025 21:43:39 +0200 Subject: [PATCH 152/171] Translated using Weblate (Dutch) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/ --- client/strings/nl.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/strings/nl.json b/client/strings/nl.json index adac094c..c5c14827 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -560,7 +560,7 @@ "LabelSettingsAudiobooksOnlyHelp": "Deze instelling inschakelen zorgt ervoor dat ebook-bestanden genegeerd worden tenzij ze in een audiobook-map staan, in welk geval ze worden ingesteld als supplementaire ebooks", "LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken", "LabelSettingsChromecastSupport": "Chromecast ondersteuning", - "LabelSettingsDateFormat": "Datum format", + "LabelSettingsDateFormat": "Datumnotatie", "LabelSettingsEnableWatcher": "Bibliotheken automatisch scannen op wijzigingen", "LabelSettingsEnableWatcherForLibrary": "Bibliotheek automatisch scannen op wijzigingen", "LabelSettingsEnableWatcherHelp": "Zorgt voor het automatisch toevoegen/bijwerken van onderdelen als bestandswijzigingen worden gedetecteerd. *Vereist herstarten van server", @@ -579,7 +579,7 @@ "LabelSettingsLibraryMarkAsFinishedWhen": "Markeer media item wanneer voltooid", "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Sla eedere boeken in Serie Verderzetten over", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "De Continue Series home page shelf toont het eerste boek dat nog niet is begonnen in series waarvan er minstens één is voltooid en er geen boeken in uitvoering zijn. Als u deze instelling inschakelt, wordt de serie voortgezet vanaf het boek dat het verst is voltooid in plaats van het eerste boek dat nog niet is begonnen.", - "LabelSettingsParseSubtitles": "Parseer subtitel", + "LabelSettingsParseSubtitles": "Subtitel afleiden uit foldernaam", "LabelSettingsParseSubtitlesHelp": "Haal subtitels uit mapnaam van audioboek.
Subtitel moet gescheiden zijn met \" - \"
b.v. \"Boektitel - Een Subtitel Hier\" heeft als subtitel \"Een Subtitel Hier\"", "LabelSettingsPreferMatchedMetadata": "Geef voorkeur aan gematchte metadata", "LabelSettingsPreferMatchedMetadataHelp": "Gematchte data zal onderdeeldetails overschrijven bij gebruik van Quick Match. Standaard vult Quick Match uitsluitend ontbrekende details aan.", From f02453ac92da61e9cf98567f6d16d51141a7b177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=20=D0=93=D0=BE=D1=80?= =?UTF-8?q?=D0=BF=D0=B8=D0=BD=D1=96=D1=87?= Date: Fri, 4 Jul 2025 18:20:20 +0200 Subject: [PATCH 153/171] Translated using Weblate (Ukrainian) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/uk/ --- client/strings/uk.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/uk.json b/client/strings/uk.json index 18d694ec..4655681d 100644 --- a/client/strings/uk.json +++ b/client/strings/uk.json @@ -346,7 +346,7 @@ "LabelExample": "Приклад", "LabelExpandSeries": "Розгорнути серії", "LabelExpandSubSeries": "Розгорнути підсерії", - "LabelExplicit": "Відверта", + "LabelExplicit": "Відвертий", "LabelExplicitChecked": "Відверта (з прапорцем)", "LabelExplicitUnchecked": "Не відверта (без прапорця)", "LabelExportOPML": "Експорт OPML", From 018ca8e7ee4016e70686fca3bdc89b5e4a4247e4 Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 4 Jul 2025 11:12:31 +0200 Subject: [PATCH 154/171] Translated using Weblate (Slovak) Currently translated at 99.9% (1107 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sk/ --- client/strings/sk.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/strings/sk.json b/client/strings/sk.json index 39b3c0ae..80170dfc 100644 --- a/client/strings/sk.json +++ b/client/strings/sk.json @@ -346,7 +346,7 @@ "LabelExample": "Príklad", "LabelExpandSeries": "Rozbaliť série", "LabelExpandSubSeries": "Rozbaliť podsérie", - "LabelExplicit": "Explicitné", + "LabelExplicit": "Explicitný obsah", "LabelExplicitChecked": "Explicitné (zaškrtnuté)", "LabelExplicitUnchecked": "Ne-explicitné (nezaškrtnuté)", "LabelExportOPML": "Exportovať OPML", @@ -757,6 +757,7 @@ "MessageConfirmRemoveAuthor": "Ste si istý, že chcete odstrániť autora \"{0}\"?", "MessageConfirmRemoveCollection": "Ste si istý, že chcete odstrániť zbierku \"{0}\"?", "MessageConfirmRemoveEpisode": "Ste si istý, že chcete odstrániť epizódu \"{0}\"?", + "MessageConfirmRemoveEpisodeNote": "Poznámka: Tento krok neodstráni zvukový súbor, pokiaľ nezaškrtnete voľbu \"Nezvratné zmazanie súborov\"", "MessageConfirmRemoveEpisodes": "Ste si istý, že chcete odstrániť {0} epizód?", "MessageConfirmRemoveListeningSessions": "Ste si istý, že chcete odstrániť týchto {0} relácií?", "MessageConfirmRemoveMetadataFiles": "Ste si istý, že chcete odstrániť všetky súbory metadata.{0} z priečinkov položiek vašej knižnice?", @@ -918,6 +919,8 @@ "NotificationOnBackupCompletedDescription": "Spustené po dokončení zálohovania", "NotificationOnBackupFailedDescription": "Spustené pri zlyhaní zálohovania", "NotificationOnEpisodeDownloadedDescription": "Spustené po automatickom stiahnutí epizódy podcastu", + "NotificationOnRSSFeedDisabledDescription": "Spustí sa, keď je automatické sťahovanie epizód pozastavené z dôvodu veľkého počtu zlyhaní", + "NotificationOnRSSFeedFailedDescription": "Spustí sa v prípade, keď zlyhá požiadavka RSS zdroja na automatické stiahnutie epizódy", "NotificationOnTestDescription": "Udalosť určená na testovanie systému notifikácií", "PlaceholderNewCollection": "Názov novej zbierky", "PlaceholderNewFolderPath": "Umiestnenie nového priečinka", From 85446be0e551f9b86f7f4add252479f9284fa17d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Po=C5=BEgay?= Date: Sun, 6 Jul 2025 02:54:16 +0200 Subject: [PATCH 155/171] Translated using Weblate (Czech) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/ --- client/strings/cs.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/strings/cs.json b/client/strings/cs.json index 3ee9b087..d61f91a7 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -346,11 +346,11 @@ "LabelExample": "Příklad", "LabelExpandSeries": "Rozbalit série", "LabelExpandSubSeries": "Rozbalit podsérie", - "LabelExplicit": "Explicitní", + "LabelExplicit": "Explicitně", "LabelExplicitChecked": "Explicitní (zaškrtnuto)", "LabelExplicitUnchecked": "Není explicitní (nezaškrtnuto)", "LabelExportOPML": "Export OPML", - "LabelFeedURL": "URL zdroje", + "LabelFeedURL": "URL kanálu", "LabelFetchingMetadata": "Získávání metadat", "LabelFile": "Soubor", "LabelFileBirthtime": "Čas vzniku souboru", @@ -757,6 +757,7 @@ "MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?", "MessageConfirmRemoveCollection": "Opravdu chcete odstranit kolekci \"{0}\"?", "MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?", + "MessageConfirmRemoveEpisodeNote": "Poznámka: Tím se zvukový soubor neodstraní, pokud nepřepnete volbu “Tvrdé odstranění souboru„“.", "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ě?", From 62afa3c3ee1bd5a2e6f0e09c056f1b91aec38eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Richard=20Po=C5=BEgay?= Date: Sun, 6 Jul 2025 02:54:53 +0200 Subject: [PATCH 156/171] Translated using Weblate (Czech) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/cs/ --- client/strings/cs.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/cs.json b/client/strings/cs.json index d61f91a7..7da79857 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -757,7 +757,7 @@ "MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?", "MessageConfirmRemoveCollection": "Opravdu chcete odstranit kolekci \"{0}\"?", "MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?", - "MessageConfirmRemoveEpisodeNote": "Poznámka: Tím se zvukový soubor neodstraní, pokud nepřepnete volbu “Tvrdé odstranění souboru„“.", + "MessageConfirmRemoveEpisodeNote": "Poznámka: Tím se zvukový soubor neodstraní, pokud nepřepnete volbu “Tvrdé odstranění souboru“", "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ě?", From d09a21d92266d66c2d6de7903ebff269b76a3d8f Mon Sep 17 00:00:00 2001 From: Raj Date: Mon, 7 Jul 2025 04:07:50 +0200 Subject: [PATCH 157/171] Translated using Weblate (Gujarati) Currently translated at 16.6% (184 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/gu/ --- client/strings/gu.json | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/client/strings/gu.json b/client/strings/gu.json index f0eee434..301d673b 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -9,6 +9,9 @@ "ButtonApply": "લાગુ કરો", "ButtonApplyChapters": "પ્રકરણો લાગુ કરો", "ButtonAuthors": "લેખકો", + "ButtonBack": "પાછા", + "ButtonBatchEditPopulateFromExisting": "હાલની માહિતીમાંથી ભરો", + "ButtonBatchEditPopulateMapDetails": "નકશાની વિગત ભરો", "ButtonBrowseForFolder": "ફોલ્ડર માટે જુઓ", "ButtonCancel": "રદ કરો", "ButtonCancelEncode": "એન્કોડ રદ કરો", @@ -27,11 +30,14 @@ "ButtonEdit": "સંપાદિત કરો", "ButtonEditChapters": "પ્રકરણો સંપાદિત કરો", "ButtonEditPodcast": "પોડકાસ્ટ સંપાદિત કરો", + "ButtonEnable": "સક્રિય કરો", "ButtonForceReScan": "બળપૂર્વક ફરીથી સ્કેન કરો", "ButtonFullPath": "સંપૂર્ણ પથ", "ButtonHide": "છુપાવો", "ButtonHome": "ઘર", "ButtonIssues": "સમસ્યાઓ", + "ButtonJumpBackward": "પાછળ જાવો", + "ButtonJumpForward": "આગળ જાવો", "ButtonLatest": "નવીનતમ", "ButtonLibrary": "પુસ્તકાલય", "ButtonLogout": "લૉગ આઉટ", @@ -41,19 +47,32 @@ "ButtonMatchAllAuthors": "બધા મેળ ખાતા લેખકો શોધો", "ButtonMatchBooks": "મેળ ખાતી પુસ્તકો શોધો", "ButtonNevermind": "કંઈ વાંધો નહીં", + "ButtonNext": "આગળ જાઓ", + "ButtonNextChapter": "આગળનું અધ્યાય", + "ButtonNextItemInQueue": "કતારમાં આવતું આગળનું અધ્યાય", "ButtonOk": "ઓકે", "ButtonOpenFeed": "ફીડ ખોલો", "ButtonOpenManager": "મેનેજર ખોલો", + "ButtonPause": "વિરામ", "ButtonPlay": "ચલાવો", + "ButtonPlayAll": "બધું ચલાવો", "ButtonPlaying": "ચલાવી રહ્યું છે", "ButtonPlaylists": "પ્લેલિસ્ટ", + "ButtonPrevious": "પાછળનું", + "ButtonPreviousChapter": "પાછળનું અધ્યાય", + "ButtonProbeAudioFile": "ઑડિયો ફાઇલ તપાસો", "ButtonPurgeAllCache": "બધો Cache કાઢી નાખો", "ButtonPurgeItemsCache": "વસ્તુઓનો Cache કાઢી નાખો", "ButtonQueueAddItem": "કતારમાં ઉમેરો", "ButtonQueueRemoveItem": "કતારથી કાઢી નાખો", + "ButtonQuickEmbed": "ઝડપથી સમાવેશ કરો", + "ButtonQuickEmbedMetadata": "ઝડપથી મેટાડેટા સમાવવો", "ButtonQuickMatch": "ઝડપી મેળ ખવડાવો", "ButtonReScan": "ફરીથી સ્કેન કરો", "ButtonRead": "વાંચો", + "ButtonReadLess": "ઓછું વાંચો", + "ButtonReadMore": "વધારે વાંચો", + "ButtonRefresh": "તાજું કરો", "ButtonRemove": "કાઢી નાખો", "ButtonRemoveAll": "બધું કાઢી નાખો", "ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો", @@ -68,16 +87,21 @@ "ButtonSaveTracklist": "ટ્રેક યાદી સાચવો", "ButtonScan": "સ્કેન કરો", "ButtonScanLibrary": "પુસ્તકાલય સ્કેન કરો", + "ButtonScrollLeft": "ડાબે", + "ButtonScrollRight": "જમણે", "ButtonSearch": "શોધો", "ButtonSelectFolderPath": "ફોલ્ડર પથ પસંદ કરો", "ButtonSeries": "સિરીઝ", "ButtonSetChaptersFromTracks": "ટ્રેક્સથી પ્રકરણો સેટ કરો", + "ButtonShare": "શેર કરો", "ButtonShiftTimes": "સમય શિફ્ટ કરો", "ButtonShow": "બતાવો", "ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો", "ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો", + "ButtonStats": "આંકડા", "ButtonSubmit": "સબમિટ કરો", "ButtonTest": "પરખ કરો", + "ButtonUnlinkOpenId": "OpenID દૂર કરો", "ButtonUpload": "અપલોડ કરો", "ButtonUploadBackup": "બેકઅપ અપલોડ કરો", "ButtonUploadCover": "કવર અપલોડ કરો", @@ -86,11 +110,16 @@ "ButtonUserEdit": "વપરાશકર્તા {0} સંપાદિત કરો", "ButtonViewAll": "બધું જુઓ", "ButtonYes": "હા", + "ErrorUploadFetchMetadataAPI": "મેટાડેટા મેળવવામાં તકલીફ આવી", + "ErrorUploadFetchMetadataNoResults": "મેટાડેટા મેળવી શક્યા નહીં – કૃપા કરીને શીર્ષક અને/અથવા લેખકનું નામ અપડેટ કરવાનો પ્રયત્ન કરો", + "ErrorUploadLacksTitle": "શીર્ષક હોવું આવશ્યક છે", "HeaderAccount": "એકાઉન્ટ", + "HeaderAddCustomMetadataProvider": "કસ્ટમ મેટાડેટા પ્રોવાઇડર ઉમેરો", "HeaderAdvanced": "અડ્વાન્સડ", "HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ", "HeaderAudioTracks": "ઓડિયો ટ્રેક્સ", "HeaderAudiobookTools": "ઓડિયોબુક ફાઇલ વ્યવસ્થાપન ટૂલ્સ", + "HeaderAuthentication": "પ્રમાણીકરણ", "HeaderBackups": "બેકઅપ્સ", "HeaderChangePassword": "પાસવર્ડ બદલો", "HeaderChapters": "પ્રકરણો", @@ -99,6 +128,7 @@ "HeaderCollectionItems": "સંગ્રહ વસ્તુઓ", "HeaderCover": "આવરણ", "HeaderCurrentDownloads": "વર્તમાન ડાઉનલોડ્સ", + "HeaderCustomMetadataProviders": "કસ્ટમ મેટાડેટા પ્રોવાઇડર્સ", "HeaderDetails": "વિગતો", "HeaderDownloadQueue": "ડાઉનલોડ કતાર", "HeaderEbookFiles": "ઇબુક ફાઇલો", @@ -129,6 +159,7 @@ "HeaderMetadataToEmbed": "એમ્બેડ કરવા માટે મેટાડેટા", "HeaderNewAccount": "નવું એકાઉન્ટ", "HeaderNewLibrary": "નવી પુસ્તકાલય", + "HeaderNotificationCreate": "સૂચના બનાવો", "HeaderNotifications": "સૂચનાઓ", "HeaderOpenRSSFeed": "RSS ફીડ ખોલો", "HeaderOtherFiles": "અન્ય ફાઇલો", From f1153f9da535eb0a56167819abf72c0bd20a37de Mon Sep 17 00:00:00 2001 From: SunSpring Date: Mon, 7 Jul 2025 06:15:34 +0200 Subject: [PATCH 158/171] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 4e9aa8b6..1b8a5cd3 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -346,7 +346,7 @@ "LabelExample": "示例", "LabelExpandSeries": "展开系列", "LabelExpandSubSeries": "展开子系列", - "LabelExplicit": "信息准确", + "LabelExplicit": "信息明确", "LabelExplicitChecked": "明确(已选中)", "LabelExplicitUnchecked": "不明确 (未选中)", "LabelExportOPML": "导出 OPML", @@ -757,6 +757,7 @@ "MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?", "MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?", "MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?", + "MessageConfirmRemoveEpisodeNote": "注意: 除非切换 \"硬删除文件\", 否则不会删除音频文件", "MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?", "MessageConfirmRemoveListeningSessions": "你确定要移除 {0} 收听会话吗?", "MessageConfirmRemoveMetadataFiles": "你确实要删除库项目文件夹中的所有 metadata.{0} 文件吗?", From 448cbf8530efb9363878146cc653c4f6e69b9e3d Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Mon, 7 Jul 2025 05:11:07 +0200 Subject: [PATCH 159/171] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 1b8a5cd3..b4acc925 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -354,7 +354,7 @@ "LabelFetchingMetadata": "正在获取元数据", "LabelFile": "文件", "LabelFileBirthtime": "文件创建时间", - "LabelFileBornDate": "生于 {0}", + "LabelFileBornDate": "添加于 {0}", "LabelFileModified": "文件修改时间", "LabelFileModifiedDate": "已修改 {0}", "LabelFilename": "文件名", From 27e40d16fd43299142b3ec37d857ec4cd4843788 Mon Sep 17 00:00:00 2001 From: SunSpring Date: Mon, 7 Jul 2025 06:16:51 +0200 Subject: [PATCH 160/171] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index b4acc925..cb10cc43 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -346,7 +346,7 @@ "LabelExample": "示例", "LabelExpandSeries": "展开系列", "LabelExpandSubSeries": "展开子系列", - "LabelExplicit": "信息明确", + "LabelExplicit": "信息准确", "LabelExplicitChecked": "明确(已选中)", "LabelExplicitUnchecked": "不明确 (未选中)", "LabelExportOPML": "导出 OPML", From fba70c98314bc7c317c742520392075c6bca458c Mon Sep 17 00:00:00 2001 From: thehijacker Date: Wed, 9 Jul 2025 11:22:27 +0200 Subject: [PATCH 161/171] Translated using Weblate (Slovenian) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/sl.json b/client/strings/sl.json index 81e80003..0dd5a6ce 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -757,6 +757,7 @@ "MessageConfirmRemoveAuthor": "Ali ste prepričani, da želite odstraniti avtorja \"{0}\"?", "MessageConfirmRemoveCollection": "Ali ste prepričani, da želite odstraniti zbirko \"{0}\"?", "MessageConfirmRemoveEpisode": "Ali ste prepričani, da želite odstraniti epizodo \"{0}\"?", + "MessageConfirmRemoveEpisodeNote": "Opomba: S tem se zvočna datoteka ne izbriše, razen če vklopite možnost \"Trdo brisanje datoteke\".", "MessageConfirmRemoveEpisodes": "Ali ste prepričani, da želite odstraniti {0} epizod?", "MessageConfirmRemoveListeningSessions": "Ali ste prepričani, da želite odstraniti {0} sej poslušanja?", "MessageConfirmRemoveMetadataFiles": "Ali ste prepričani, da želite odstraniti vse metapodatke.{0} v mapah elementov knjižnice?", From 3c8876a37d9ccc17706538caf9907d43183052bf Mon Sep 17 00:00:00 2001 From: thehijacker Date: Wed, 9 Jul 2025 11:22:35 +0200 Subject: [PATCH 162/171] Translated using Weblate (Slovenian) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/sl/ --- client/strings/sl.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/sl.json b/client/strings/sl.json index 0dd5a6ce..5c3c7d4f 100644 --- a/client/strings/sl.json +++ b/client/strings/sl.json @@ -757,7 +757,7 @@ "MessageConfirmRemoveAuthor": "Ali ste prepričani, da želite odstraniti avtorja \"{0}\"?", "MessageConfirmRemoveCollection": "Ali ste prepričani, da želite odstraniti zbirko \"{0}\"?", "MessageConfirmRemoveEpisode": "Ali ste prepričani, da želite odstraniti epizodo \"{0}\"?", - "MessageConfirmRemoveEpisodeNote": "Opomba: S tem se zvočna datoteka ne izbriše, razen če vklopite možnost \"Trdo brisanje datoteke\".", + "MessageConfirmRemoveEpisodeNote": "Opomba: S tem se zvočna datoteka ne izbriše, razen če vklopite možnost \"Trdo brisanje datoteke\"", "MessageConfirmRemoveEpisodes": "Ali ste prepričani, da želite odstraniti {0} epizod?", "MessageConfirmRemoveListeningSessions": "Ali ste prepričani, da želite odstraniti {0} sej poslušanja?", "MessageConfirmRemoveMetadataFiles": "Ali ste prepričani, da želite odstraniti vse metapodatke.{0} v mapah elementov knjižnice?", From 25fe4dee3a0aa2ab7cb46f018505bbddd3695b56 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 9 Jul 2025 17:03:10 -0500 Subject: [PATCH 163/171] Update epub reader to use axios for handling refresh tokens --- client/components/readers/EpubReader.vue | 66 ++++++++++++++---------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue index 350d8596..795fcd2b 100644 --- a/client/components/readers/EpubReader.vue +++ b/client/components/readers/EpubReader.vue @@ -97,9 +97,9 @@ export default { }, ebookUrl() { if (this.fileId) { - return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook/${this.fileId}` + return `/api/items/${this.libraryItemId}/ebook/${this.fileId}` } - return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook` + return `/api/items/${this.libraryItemId}/ebook` }, themeRules() { const isDark = this.ereaderSettings.theme === 'dark' @@ -309,14 +309,24 @@ export default { /** @type {EpubReader} */ const reader = this + // Use axios to make request because we have token refresh logic in interceptor + const customRequest = async (url) => { + try { + return this.$axios.$get(url, { + responseType: 'arraybuffer' + }) + } catch (error) { + console.error('EpubReader.initEpub customRequest failed:', error) + throw error + } + } + /** @type {ePub.Book} */ reader.book = new ePub(reader.ebookUrl, { width: this.readerWidth, height: this.readerHeight - 50, openAs: 'epub', - requestHeaders: { - Authorization: `Bearer ${this.userToken}` - } + requestMethod: customRequest }) /** @type {ePub.Rendition} */ @@ -337,29 +347,33 @@ export default { this.applyTheme() }) - reader.book.ready.then(() => { - // set up event listeners - reader.rendition.on('relocated', reader.relocated) - reader.rendition.on('keydown', reader.keyUp) + reader.book.ready + .then(() => { + // set up event listeners + reader.rendition.on('relocated', reader.relocated) + reader.rendition.on('keydown', reader.keyUp) - reader.rendition.on('touchstart', (event) => { - this.$emit('touchstart', event) - }) - reader.rendition.on('touchend', (event) => { - this.$emit('touchend', event) - }) - - // load ebook cfi locations - const savedLocations = this.loadLocations() - if (savedLocations) { - reader.book.locations.load(savedLocations) - } else { - reader.book.locations.generate().then(() => { - this.checkSaveLocations(reader.book.locations.save()) + reader.rendition.on('touchstart', (event) => { + this.$emit('touchstart', event) }) - } - this.getChapters() - }) + reader.rendition.on('touchend', (event) => { + this.$emit('touchend', event) + }) + + // load ebook cfi locations + const savedLocations = this.loadLocations() + if (savedLocations) { + reader.book.locations.load(savedLocations) + } else { + reader.book.locations.generate().then(() => { + this.checkSaveLocations(reader.book.locations.save()) + }) + } + this.getChapters() + }) + .catch((error) => { + console.error('EpubReader.initEpub failed:', error) + }) }, getChapters() { // Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759 From d3402e30c252a9acacee63fc3530805d127e354c Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 10 Jul 2025 16:54:28 -0500 Subject: [PATCH 164/171] Update ereaders to handle refreshing, epubjs to use custom request method, separate accessToken in store --- client/components/modals/AccountModal.vue | 2 +- client/components/readers/ComicReader.vue | 8 +----- client/components/readers/EpubReader.vue | 3 -- client/components/readers/MobiReader.vue | 10 ++----- client/components/readers/PdfReader.vue | 32 +++++++++++++++++++-- client/pages/login.vue | 2 +- client/plugins/axios.js | 20 +++---------- client/store/user.js | 34 ++++++++++++++++++----- server/utils/rateLimiterFactory.js | 2 +- 9 files changed, 67 insertions(+), 46 deletions(-) diff --git a/client/components/modals/AccountModal.vue b/client/components/modals/AccountModal.vue index 9293a6d1..6f4b7b67 100644 --- a/client/components/modals/AccountModal.vue +++ b/client/components/modals/AccountModal.vue @@ -311,7 +311,7 @@ export default { if (data.user.id === this.user.id && data.user.accessToken !== this.user.accessToken) { console.log('Current user access token was updated') - this.$store.commit('user/setUserToken', data.user.accessToken) + this.$store.commit('user/setAccessToken', data.user.accessToken) } this.$toast.success(this.$strings.ToastAccountUpdateSuccess) diff --git a/client/components/readers/ComicReader.vue b/client/components/readers/ComicReader.vue index 28d79bf2..fce26939 100644 --- a/client/components/readers/ComicReader.vue +++ b/client/components/readers/ComicReader.vue @@ -104,9 +104,6 @@ export default { } }, computed: { - userToken() { - return this.$store.getters['user/getToken'] - }, libraryItemId() { return this.libraryItem?.id }, @@ -234,10 +231,7 @@ export default { async extract() { this.loading = true var buff = await this.$axios.$get(this.ebookUrl, { - responseType: 'blob', - headers: { - Authorization: `Bearer ${this.userToken}` - } + responseType: 'blob' }) const archive = await Archive.open(buff) const originalFilesObject = await archive.getFilesObject() diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue index 795fcd2b..ac8e3397 100644 --- a/client/components/readers/EpubReader.vue +++ b/client/components/readers/EpubReader.vue @@ -57,9 +57,6 @@ export default { } }, computed: { - userToken() { - return this.$store.getters['user/getToken'] - }, /** @returns {string} */ libraryItemId() { return this.libraryItem?.id diff --git a/client/components/readers/MobiReader.vue b/client/components/readers/MobiReader.vue index 3e784f77..459ae55b 100644 --- a/client/components/readers/MobiReader.vue +++ b/client/components/readers/MobiReader.vue @@ -26,9 +26,6 @@ export default { return {} }, computed: { - userToken() { - return this.$store.getters['user/getToken'] - }, libraryItemId() { return this.libraryItem?.id }, @@ -96,11 +93,8 @@ export default { }, async initMobi() { // Fetch mobi file as blob - var buff = await this.$axios.$get(this.ebookUrl, { - responseType: 'blob', - headers: { - Authorization: `Bearer ${this.userToken}` - } + const buff = await this.$axios.$get(this.ebookUrl, { + responseType: 'blob' }) var reader = new FileReader() reader.onload = async (event) => { diff --git a/client/components/readers/PdfReader.vue b/client/components/readers/PdfReader.vue index c05f459c..d9459d76 100644 --- a/client/components/readers/PdfReader.vue +++ b/client/components/readers/PdfReader.vue @@ -55,7 +55,8 @@ export default { loadedRatio: 0, page: 1, numPages: 0, - pdfDocInitParams: null + pdfDocInitParams: null, + isRefreshing: false } }, computed: { @@ -152,7 +153,34 @@ export default { this.page++ this.updateProgress() }, - error(err) { + async refreshToken() { + if (this.isRefreshing) return + this.isRefreshing = true + const newAccessToken = await this.$store.dispatch('user/refreshToken').catch((error) => { + console.error('Failed to refresh token', error) + return null + }) + if (!newAccessToken) { + // Redirect to login on failed refresh + this.$router.push('/login') + return + } + + // Force Vue to re-render the PDF component by creating a new object + this.pdfDocInitParams = { + url: this.ebookUrl, + httpHeaders: { + Authorization: `Bearer ${newAccessToken}` + } + } + this.isRefreshing = false + }, + async error(err) { + if (err && err.status === 401) { + console.log('Received 401 error, refreshing token') + await this.refreshToken() + return + } console.error(err) }, resize() { diff --git a/client/pages/login.vue b/client/pages/login.vue index 51f60600..242eb93a 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -189,7 +189,7 @@ export default { this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId) this.$store.commit('user/setUser', user) - this.$store.commit('user/setUserToken', user.accessToken) + this.$store.commit('user/setAccessToken', user.accessToken) this.$store.dispatch('user/loadUserSettings') }, diff --git a/client/plugins/axios.js b/client/plugins/axios.js index 87eedca2..66a9fa85 100644 --- a/client/plugins/axios.js +++ b/client/plugins/axios.js @@ -45,7 +45,7 @@ export default function ({ $axios, store, $root, app }) { if (originalRequest.url === '/auth/refresh' || originalRequest.url === '/login') { // Refresh failed or login failed, redirect to login store.commit('user/setUser', null) - store.commit('user/setUserToken', null) + store.commit('user/setAccessToken', null) app.router.push('/login') return Promise.reject(error) } @@ -72,23 +72,13 @@ export default function ({ $axios, store, $root, app }) { try { // Attempt to refresh the token - const response = await $axios.$post('/auth/refresh') - const newAccessToken = response.user.accessToken - + // Updates store if successful, otherwise clears store and throw error + const newAccessToken = await store.dispatch('user/refreshToken') if (!newAccessToken) { console.error('No new access token received') return Promise.reject(error) } - // Update the token in store and localStorage - store.commit('user/setUser', response.user) - store.commit('user/setUserToken', newAccessToken) - - // Emit event used to re-authenticate socket in default.vue since $root is not available here - if (app.$eventBus) { - app.$eventBus.$emit('token_refreshed', newAccessToken) - } - // Update the original request with new token if (!originalRequest.headers) { originalRequest.headers = {} @@ -106,9 +96,7 @@ export default function ({ $axios, store, $root, app }) { // Process queued requests with error processQueue(refreshError, null) - // Clear user data and redirect to login - store.commit('user/setUser', null) - store.commit('user/setUserToken', null) + // Redirect to login app.router.push('/login') return Promise.reject(refreshError) diff --git a/client/store/user.js b/client/store/user.js index 04dc8447..a67eae34 100644 --- a/client/store/user.js +++ b/client/store/user.js @@ -1,5 +1,6 @@ export const state = () => ({ user: null, + accessToken: null, settings: { orderBy: 'media.metadata.title', orderDesc: false, @@ -25,7 +26,7 @@ export const getters = { getIsRoot: (state) => state.user && state.user.type === 'root', getIsAdminOrUp: (state) => state.user && (state.user.type === 'admin' || state.user.type === 'root'), getToken: (state) => { - return state.user?.accessToken || null + return state.accessToken || null }, getUserMediaProgress: (state) => @@ -145,6 +146,27 @@ export const actions = { } catch (error) { console.error('Failed to load userSettings from local storage', error) } + }, + refreshToken({ state, commit }) { + return this.$axios + .$post('/auth/refresh') + .then(async (response) => { + const newAccessToken = response.user.accessToken + commit('setUser', response.user) + commit('setAccessToken', newAccessToken) + // Emit event used to re-authenticate socket in default.vue since $root is not available here + if (this.$eventBus) { + this.$eventBus.$emit('token_refreshed', newAccessToken) + } + return newAccessToken + }) + .catch((error) => { + console.error('Failed to refresh token', error) + commit('setUser', null) + commit('setAccessToken', null) + // Calling function handles redirect to login + throw error + }) } } @@ -152,14 +174,12 @@ export const mutations = { setUser(state, user) { state.user = user }, - setUserToken(state, token) { + setAccessToken(state, token) { if (!token) { localStorage.removeItem('token') - if (state.user) { - state.user.accessToken = null - } - } else if (state.user) { - state.user.accessToken = token + state.accessToken = null + } else { + state.accessToken = token localStorage.setItem('token', token) } }, diff --git a/server/utils/rateLimiterFactory.js b/server/utils/rateLimiterFactory.js index 6f04d5ac..b5199662 100644 --- a/server/utils/rateLimiterFactory.js +++ b/server/utils/rateLimiterFactory.js @@ -23,7 +23,7 @@ class RateLimiterFactory { windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) } - let max = 20 // 20 attempts default + let max = 40 // 40 attempts default if (parseInt(process.env.RATE_LIMIT_AUTH_MAX) > 0) { max = parseInt(process.env.RATE_LIMIT_AUTH_MAX) } From ad07ed7e25a455b27b2e18e524a27a6f1e8e344e Mon Sep 17 00:00:00 2001 From: FiendFEARing Date: Fri, 11 Jul 2025 03:00:09 +0200 Subject: [PATCH 165/171] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (1108 of 1108 strings) Translation: Audiobookshelf/Abs Web Client Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/zh_Hans/ --- client/strings/zh-cn.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index cb10cc43..76f4e64a 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -212,7 +212,7 @@ "HeaderUsers": "用户", "HeaderYearReview": "{0} 年回顾", "HeaderYourStats": "你的统计数据", - "LabelAbridged": "概要", + "LabelAbridged": "删节版", "LabelAbridgedChecked": "删节版 (已勾选)", "LabelAbridgedUnchecked": "未删节版 (未勾选)", "LabelAccessibleBy": "可访问", @@ -320,7 +320,7 @@ "LabelEmailSettingsRejectUnauthorized": "拒绝未经授权的证书", "LabelEmailSettingsRejectUnauthorizedHelp": "禁用SSL证书验证可能会使你的连接面临安全风险, 例如中间人攻击. 只有当你了解其中的含义并信任所连接的邮件服务器时, 才能禁用此选项.", "LabelEmailSettingsSecure": "安全", - "LabelEmailSettingsSecureHelp": "如果选是, 则连接将在连接到服务器时使用TLS. 如果选否, 则若服务器支持STARTTLS扩展, 则使用TLS. 在大多数情况下, 如果连接到端口465, 请将该值设置为是. 对于端口587或25, 请保持为否. (来自nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsSecureHelp": "开启此选项时,将始终通过TLS连接服务器。关闭此选项时,仅在服务器支持STARTTLS扩展时使用TLS。在大多数情况下,如果连接到端口465,请将此项设为开启。如果连接到端口587或25,请将此设置保持为关闭。(来自nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "测试地址", "LabelEmbeddedCover": "嵌入封面", "LabelEnable": "启用", @@ -346,9 +346,9 @@ "LabelExample": "示例", "LabelExpandSeries": "展开系列", "LabelExpandSubSeries": "展开子系列", - "LabelExplicit": "信息准确", - "LabelExplicitChecked": "明确(已选中)", - "LabelExplicitUnchecked": "不明确 (未选中)", + "LabelExplicit": "含成人内容", + "LabelExplicitChecked": "成人内容(已核实)", + "LabelExplicitUnchecked": "无成人内容 (未核实)", "LabelExportOPML": "导出 OPML", "LabelFeedURL": "源 URL", "LabelFetchingMetadata": "正在获取元数据", @@ -482,7 +482,7 @@ "LabelPermanent": "永久的", "LabelPermissionsAccessAllLibraries": "可以访问所有媒体库", "LabelPermissionsAccessAllTags": "可以访问所有标签", - "LabelPermissionsAccessExplicitContent": "可以访问显式内容", + "LabelPermissionsAccessExplicitContent": "可以访问成人内容", "LabelPermissionsCreateEreader": "可以创建电子阅读器", "LabelPermissionsDelete": "可以删除", "LabelPermissionsDownload": "可以下载", From 7d6d3e668788a2e37a5d6a27a939516d3e24d165 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 11 Jul 2025 14:43:07 -0500 Subject: [PATCH 166/171] Move invalidate refresh token to TokenManager --- server/Auth.js | 10 +--------- server/auth/TokenManager.js | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 55eb334a..571472a7 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,5 +1,4 @@ const { Request, Response, NextFunction } = require('express') -const { rateLimit } = require('express-rate-limit') const passport = require('passport') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt @@ -466,14 +465,7 @@ class Auth { // Invalidate the session in database using refresh token if (refreshToken) { - try { - Logger.info(`[Auth] logout: Invalidating session for refresh token: ${refreshToken}`) - await Database.sessionModel.destroy({ - where: { refreshToken } - }) - } catch (error) { - Logger.error(`[Auth] Error destroying session: ${error.message}`) - } + await this.tokenManager.invalidateRefreshToken(refreshToken) } else { Logger.info(`[Auth] logout: No refresh token on request`) } diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index 3f5cc836..65ae32b1 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -379,6 +379,28 @@ class TokenManager { await Database.sessionModel.destroy({ where: { userId: user.id } }) return null } + + /** + * Invalidate a refresh token - used for logout + * + * @param {string} refreshToken + * @returns {Promise} + */ + async invalidateRefreshToken(refreshToken) { + if (!refreshToken) { + Logger.error(`[TokenManager] No refresh token provided to invalidate`) + return false + } + + try { + const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } }) + Logger.info(`[TokenManager] Refresh token ${refreshToken} invalidated, ${numDeleted} sessions deleted`) + return true + } catch (error) { + Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`) + return false + } + } } module.exports = TokenManager From 806c0a2991d236a219d8df7aa75d3d751b0efc51 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 11 Jul 2025 16:01:45 -0500 Subject: [PATCH 167/171] Remove return_tokens query param for login --- server/Auth.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/Auth.js b/server/Auth.js index 571472a7..9c0e2fdb 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -305,7 +305,7 @@ class Auth { // Local strategy login route (takes username and password) router.post('/login', this.authRateLimiter, passport.authenticate('local'), async (req, res) => { // Check if mobile app wants refresh token in response - const returnTokens = req.query.return_tokens === 'true' || req.headers['x-return-tokens'] === 'true' + const returnTokens = req.headers['x-return-tokens'] === 'true' const userResponse = await this.handleLoginSuccess(req, res, returnTokens) res.json(userResponse) From f081a7fdc1dfd47795b5d838b2d4ec64f7bc4bd9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 12 Jul 2025 10:32:35 -0500 Subject: [PATCH 168/171] Update rate limiter to use requestIp as key, pass in configurable error message --- server/utils/rateLimiterFactory.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/server/utils/rateLimiterFactory.js b/server/utils/rateLimiterFactory.js index b5199662..0ad77406 100644 --- a/server/utils/rateLimiterFactory.js +++ b/server/utils/rateLimiterFactory.js @@ -1,5 +1,6 @@ const { rateLimit, RateLimitRequestHandler } = require('express-rate-limit') const Logger = require('../Logger') +const requestIp = require('../libs/requestIp') /** * Factory for creating authentication rate limiters @@ -28,7 +29,7 @@ class RateLimiterFactory { max = parseInt(process.env.RATE_LIMIT_AUTH_MAX) } - let message = 'Too many requests, please try again later.' + let message = 'Too many authentication requests' if (process.env.RATE_LIMIT_AUTH_MESSAGE) { message = process.env.RATE_LIMIT_AUTH_MESSAGE } @@ -36,18 +37,22 @@ class RateLimiterFactory { this.authRateLimiter = rateLimit({ windowMs, max, - message, standardHeaders: true, legacyHeaders: false, + keyGenerator: (req) => { + // Override keyGenerator to handle proxy IPs + return requestIp.getClientIp(req) || req.ip + }, handler: (req, res) => { const userAgent = req.get('User-Agent') || 'Unknown' const endpoint = req.path const method = req.method + const ip = requestIp.getClientIp(req) || req.ip - Logger.warn(`[RateLimiter] Rate limit exceeded - IP: ${req.ip}, Endpoint: ${method} ${endpoint}, User-Agent: ${userAgent}`) + Logger.warn(`[RateLimiter] Rate limit exceeded - IP: ${ip}, Endpoint: ${method} ${endpoint}, User-Agent: ${userAgent}`) res.status(429).json({ - error: 'Too many authentication attempts, please try again later.' + error: message }) } }) From 030e43f38218f5fe9f013940fba9a63d27ed57ce Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 12 Jul 2025 10:51:07 -0500 Subject: [PATCH 169/171] Support disabled rate limiter by setting max to 0, add logs when rate limit is changed from default --- server/utils/rateLimiterFactory.js | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/server/utils/rateLimiterFactory.js b/server/utils/rateLimiterFactory.js index 0ad77406..e639c51c 100644 --- a/server/utils/rateLimiterFactory.js +++ b/server/utils/rateLimiterFactory.js @@ -6,6 +6,9 @@ const requestIp = require('../libs/requestIp') * Factory for creating authentication rate limiters */ class RateLimiterFactory { + static DEFAULT_WINDOW_MS = 10 * 60 * 1000 // 10 minutes + static DEFAULT_MAX = 40 // 40 attempts + constructor() { this.authRateLimiter = null } @@ -19,14 +22,27 @@ class RateLimiterFactory { return this.authRateLimiter } - let windowMs = 10 * 60 * 1000 // 10 minutes default - if (parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) > 0) { - windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) + // Disable by setting max to 0 + if (process.env.RATE_LIMIT_AUTH_MAX === '0') { + this.authRateLimiter = (req, res, next) => next() + Logger.info(`[RateLimiterFactory] Authentication rate limiting disabled by ENV variable`) + return this.authRateLimiter } - let max = 40 // 40 attempts default + let windowMs = RateLimiterFactory.DEFAULT_WINDOW_MS + if (parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) > 0) { + windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) + if (windowMs !== RateLimiterFactory.DEFAULT_WINDOW_MS) { + Logger.info(`[RateLimiterFactory] Authentication rate limiting window set to ${windowMs}ms by ENV variable`) + } + } + + let max = RateLimiterFactory.DEFAULT_MAX if (parseInt(process.env.RATE_LIMIT_AUTH_MAX) > 0) { max = parseInt(process.env.RATE_LIMIT_AUTH_MAX) + if (max !== RateLimiterFactory.DEFAULT_MAX) { + Logger.info(`[RateLimiterFactory] Authentication rate limiting max set to ${max} by ENV variable`) + } } let message = 'Too many authentication requests' From d09db19cd5fd6133a9f49c3d8a8f709fdff26a49 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 12 Jul 2025 11:21:52 -0500 Subject: [PATCH 170/171] Update re-login message to show for users without github discussion link, add message to i18n strings --- client/pages/login.vue | 21 ++++++++++----------- client/strings/en-us.json | 1 + 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index 242eb93a..01adadcd 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -40,11 +40,11 @@

{{ error }}

-
+
-

Authentication has been improved for security. All users will be required to re-login.

- More info +

{{ $strings.MessageAuthenticationSecurityMessage }}

+ {{ $strings.LabelMoreInfo }}
@@ -95,6 +95,8 @@ export default { login_local: true, login_openid: false, authFormData: null, + // New JWT auth system re-login flags + showNewAuthSystemMessage: false, showNewAuthSystemAdminMessage: false } }, @@ -195,6 +197,7 @@ export default { }, async submitForm() { this.error = null + this.showNewAuthSystemMessage = false this.showNewAuthSystemAdminMessage = false this.processing = true @@ -231,14 +234,10 @@ export default { .then((res) => { // Force re-login if user is using an old token with no expiration if (res.user.isOldToken) { - if (res.user.type === 'admin' || res.user.type === 'root') { - this.username = res.user.username - // Show message to admin users about new auth system - this.showNewAuthSystemAdminMessage = true - } else { - // Regular users just shown login - this.username = res.user.username - } + this.username = res.user.username + this.showNewAuthSystemMessage = true + // Admin user sees link to github discussion + this.showNewAuthSystemAdminMessage = res.user.type === 'admin' || res.user.type === 'root' return false } this.setUser(res) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index a25f02cf..84127708 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -724,6 +724,7 @@ "MessageAppriseDescription": "To use this feature you will need to have an instance of Apprise API running or an api that will handle those same requests.
The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at http://192.168.1.1:8337 then you would put http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Ensure you are using the ASIN from the correct Audible region, not Amazon.", "MessageAuthenticationOIDCChangesRestart": "Restart your server after saving to apply OIDC changes.", + "MessageAuthenticationSecurityMessage": "Authentication has been improved for security. All users will be required to re-login.", "MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in /metadata/items & /metadata/authors. Backups do not include any files stored in your library folders.", "MessageBackupsLocationEditNote": "Note: Updating the backup location will not move or modify existing backups", "MessageBackupsLocationNoEditNote": "Note: The backup location is set through an environment variable and cannot be changed here.", From 4f7831611f9a4a99158ca638a1ce3f1361200ca3 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 12 Jul 2025 11:23:08 -0500 Subject: [PATCH 171/171] Update auth re-login i18n string --- client/strings/en-us.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 84127708..56c29ec1 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -724,7 +724,7 @@ "MessageAppriseDescription": "To use this feature you will need to have an instance of Apprise API running or an api that will handle those same requests.
The Apprise API Url should be the full URL path to send the notification, e.g., if your API instance is served at http://192.168.1.1:8337 then you would put http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Ensure you are using the ASIN from the correct Audible region, not Amazon.", "MessageAuthenticationOIDCChangesRestart": "Restart your server after saving to apply OIDC changes.", - "MessageAuthenticationSecurityMessage": "Authentication has been improved for security. All users will be required to re-login.", + "MessageAuthenticationSecurityMessage": "Authentication has been improved for security. All users are required to re-login.", "MessageBackupsDescription": "Backups include users, user progress, library item details, server settings, and images stored in /metadata/items & /metadata/authors. Backups do not include any files stored in your library folders.", "MessageBackupsLocationEditNote": "Note: Updating the backup location will not move or modify existing backups", "MessageBackupsLocationNoEditNote": "Note: The backup location is set through an environment variable and cannot be changed here.",