Compare commits

...

31 Commits

Author SHA1 Message Date
advplyr aacdcc47ec Version bump v2.35.1 2026-05-28 15:22:55 -05:00
advplyr 499b52b4dd Update Sequelize where query for User username/email case insensitive 2026-05-28 14:49:49 -05:00
advplyr 1bad2d9072 Cleanup abmetadata file parsing & fix server crash #5268 #4287 #5142 2026-05-27 17:33:14 -05:00
advplyr c009db9f28 Merge pull request #5256 from nichwall/fix-bookauthor-collision-on-rename
Fix duplicate bookAuthor creation when renaming authors
2026-05-22 15:43:13 -05:00
advplyr 325469c5a5 Merge pull request #5255 from nichwall/refresh-token-uniqueness
Add unique UUID to access and refresh tokens
2026-05-22 15:39:01 -05:00
Nicholas Wallace c97b36e11c Add ignoreDuplicates for bookAuthor when renaming to respect unique index 2026-05-21 21:06:17 -07:00
Nicholas Wallace e944b2a2f5 Add unique UUID to access and refresh tokens 2026-05-21 17:08:39 -07:00
advplyr 2d0a5462d2 Merge branch 'master' of https://github.com/advplyr/audiobookshelf 2026-05-17 14:31:45 -05:00
advplyr 72dc75482f Version bump v2.35.0 2026-05-17 14:31:41 -05:00
advplyr cac74f3477 Merge pull request #5004 from nichwall/token_refresh_race_condition
Access token refresh grace period
2026-05-17 14:16:58 -05:00
advplyr 1ad11b2b9e Merge pull request #5216 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2026-05-17 14:16:24 -05:00
Pavel Miniutka 50eeca2e0f Translated using Weblate (Belarusian)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/be/
2026-05-15 18:13:27 +00:00
EteranlK 4f21fc023c Translated using Weblate (Arabic)
Currently translated at 96.3% (1120 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/
2026-05-15 18:13:26 +00:00
advplyr 52a485d135 Added translation using Weblate (Latvian) 2026-05-15 18:13:25 +00:00
d0nizam 3b025076e8 Translated using Weblate (Bulgarian)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bg/
2026-05-15 18:13:23 +00:00
Mateusz Lesiak 6d5d89429d Translated using Weblate (Polish)
Currently translated at 99.8% (1161 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pl/
2026-05-15 18:13:22 +00:00
advplyr c010f0e1eb Fix android device sdkVersion not handling it using number type, causing android session device names to show as iOS 2026-05-15 13:13:14 -05:00
advplyr eee377e081 Cleanup TokenManager logs 2026-05-13 16:23:26 -05:00
advplyr b0aaa24660 Update socket events to check client is admin & validate log level 2026-05-12 16:57:28 -05:00
advplyr 47ea6b5092 Update book/podcast scanner to sanitize description pulled from metadata 2026-05-05 17:18:49 -05:00
advplyr 4b060febc2 Merge pull request #5221 from brandonfhall/fix/rss-feed-m4b-content-type
Fix: RSS feed serves m4b files with correct Content-Type: audio/mp4
2026-05-03 14:40:43 -05:00
Brandon 40869bcf39 fix: set correct Content-Type for RSS feed audio files
Express's mime package does not recognize .m4b, causing it to fall back
to application/octet-stream. This reuses the existing
getAudioMimeTypeFromExtname utility (already applied to the download
endpoint) to set the correct audio/mp4 header before sendFile.

Fixes #5041
2026-05-02 22:13:35 -04:00
advplyr 3942805129 Cleanup rotateTokensForSession 2026-04-30 16:25:43 -05:00
advplyr dc446862c1 Rename migration to v2.35.0 & merge master 2026-04-30 16:08:24 -05:00
advplyr 379f6c716a Merge branch 'master' into token_refresh_race_condition 2026-04-30 15:59:22 -05:00
Nicholas Wallace cfeb6bd502 Fix: grace period enable statement 2026-01-24 18:57:40 -07:00
Nicholas Wallace 077b523bd6 Fix JS Doc deletion 2026-01-24 18:42:50 -07:00
Nicholas Wallace b8a2d113f0 Allow rotation without grace period for invalidating all user sessions 2026-01-24 18:26:11 -07:00
Nicholas Wallace e1ae4f2d31 Fix: race condition in rotation 2026-01-24 18:10:38 -07:00
Nicholas Wallace 7aa2f84daa Revert default token expiry 2026-01-24 17:00:07 -07:00
Nicholas Wallace da0a64daed Add: 10 second grace period to access token cycle 2026-01-24 16:57:25 -07:00
21 changed files with 545 additions and 78 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.34.0", "version": "2.35.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.34.0", "version": "2.35.1",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.34.0", "version": "2.35.1",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
+6
View File
@@ -244,6 +244,8 @@
"LabelAlreadyInYourLibrary": "موجود بالفعل في مكتبتك", "LabelAlreadyInYourLibrary": "موجود بالفعل في مكتبتك",
"LabelApiKeyCreated": "تم إنشاء مفتاح API \"{0}\" بنجاح.", "LabelApiKeyCreated": "تم إنشاء مفتاح API \"{0}\" بنجاح.",
"LabelApiKeyCreatedDescription": "تأكد من نسخ مفتاح API الآن، لن تتمكن من رؤيته مرة أخرى.", "LabelApiKeyCreatedDescription": "تأكد من نسخ مفتاح API الآن، لن تتمكن من رؤيته مرة أخرى.",
"LabelApiKeyUser": "التصرف بالنيابة عن مستخدم",
"LabelApiKeyUserDescription": "مفتاح API سيمتلك نفس صلاحيات المستخدم الذي ينوب عنه ، سيظهر بالسجلات وكأن المستخدم قام بالطلب.",
"LabelApiToken": "رمز API", "LabelApiToken": "رمز API",
"LabelAppend": "إلحاق", "LabelAppend": "إلحاق",
"LabelAudioBitrate": "معدل بت الصوت (على سبيل المثال 128 كيلو بايت)", "LabelAudioBitrate": "معدل بت الصوت (على سبيل المثال 128 كيلو بايت)",
@@ -293,6 +295,7 @@
"LabelContinueListening": "استمرار الاستماع", "LabelContinueListening": "استمرار الاستماع",
"LabelContinueReading": "استمرار القراءة", "LabelContinueReading": "استمرار القراءة",
"LabelContinueSeries": "استمرار المسلسلات", "LabelContinueSeries": "استمرار المسلسلات",
"LabelCorsAllowed": "CORS Origins مسموح",
"LabelCover": "الغلاف", "LabelCover": "الغلاف",
"LabelCoverImageURL": "رابط صورة الغلاف", "LabelCoverImageURL": "رابط صورة الغلاف",
"LabelCoverProvider": "مزود الغلاف", "LabelCoverProvider": "مزود الغلاف",
@@ -426,6 +429,9 @@
"LabelLibraryFilterSublistEmpty": "لا يوجد {0}", "LabelLibraryFilterSublistEmpty": "لا يوجد {0}",
"LabelLibraryItem": "عنصر المكتبة", "LabelLibraryItem": "عنصر المكتبة",
"LabelLibraryName": "اسم المكتبة", "LabelLibraryName": "اسم المكتبة",
"LabelLibrarySortByProgress": "المرحلة: الأحدث",
"LabelLibrarySortByProgressFinished": "المرحلة: تم الانتهاء",
"LabelLibrarySortByProgressStarted": "المرحلة: تم البدء",
"LabelLimit": "حد", "LabelLimit": "حد",
"LabelLineSpacing": "تباعد الأسطر", "LabelLineSpacing": "تباعد الأسطر",
"LabelListenAgain": "الاستماع مجدداً", "LabelListenAgain": "الاستماع مجدداً",
+2 -2
View File
@@ -88,7 +88,7 @@
"ButtonResetToDefault": "Скінуць да прадвызначаных", "ButtonResetToDefault": "Скінуць да прадвызначаных",
"ButtonRestore": "Аднавіць", "ButtonRestore": "Аднавіць",
"ButtonSave": "Захаваць", "ButtonSave": "Захаваць",
"ButtonSaveAndClose": "Захаваць і зачыніць", "ButtonSaveAndClose": "Захаваць і закрыць",
"ButtonSaveTracklist": "Захаваць спіс трэкаў", "ButtonSaveTracklist": "Захаваць спіс трэкаў",
"ButtonScan": "Сканаваць", "ButtonScan": "Сканаваць",
"ButtonScanLibrary": "Сканіраваць бібліятэку", "ButtonScanLibrary": "Сканіраваць бібліятэку",
@@ -284,7 +284,7 @@
"LabelChaptersFound": "раздзелаў знойдзена", "LabelChaptersFound": "раздзелаў знойдзена",
"LabelClickForMoreInfo": "Націсніце для больш падрабязнай інфармацыі", "LabelClickForMoreInfo": "Націсніце для больш падрабязнай інфармацыі",
"LabelClickToUseCurrentValue": "Націсніце, каб выкарыстоўваць бягучае значэнне", "LabelClickToUseCurrentValue": "Націсніце, каб выкарыстоўваць бягучае значэнне",
"LabelClosePlayer": "Зачыніць прайгравальнік", "LabelClosePlayer": "Закрыць прайгравальнік",
"LabelCodec": "Кодэк", "LabelCodec": "Кодэк",
"LabelCollapseSeries": "Згарнуць серыі", "LabelCollapseSeries": "Згарнуць серыі",
"LabelCollapseSubSeries": "Згарнуць падсерыі", "LabelCollapseSubSeries": "Згарнуць падсерыі",
+103 -2
View File
@@ -752,7 +752,7 @@
"MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове", "MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове",
"MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"",
"MessageBookshelfNoResultsForQuery": "Няма резултати от заявката", "MessageBookshelfNoResultsForQuery": "Няма резултати от заявката",
"MessageBookshelfNoSeries": "Нямаш сеЗЙ", "MessageBookshelfNoSeries": "Нямате поредица",
"MessageBulkChapterPattern": "Колко глави искате да добавите, използвайки тази схема за номериране?", "MessageBulkChapterPattern": "Колко глави искате да добавите, използвайки тази схема за номериране?",
"MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига", "MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига",
"MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0", "MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0",
@@ -1018,18 +1018,50 @@
"ToastChapterStartTimeAdjusted": "Начално време на главате е настоено с {0} секунди", "ToastChapterStartTimeAdjusted": "Начално време на главате е настоено с {0} секунди",
"ToastChaptersAllLocked": "Всички глави са заключени. Оключете някой глави за да преместите техните времена.", "ToastChaptersAllLocked": "Всички глави са заключени. Оключете някой глави за да преместите техните времена.",
"ToastChaptersHaveErrors": "Главите имат грешки", "ToastChaptersHaveErrors": "Главите имат грешки",
"ToastChaptersInvalidShiftAmountLast": "Невалидно време за преместване. Началният час на последната глава ще превиши общата продължителност на аудиокнигата.",
"ToastChaptersInvalidShiftAmountStart": "Невалидно време за преместване. Първата глава ще има нулева или отрицателна дължина и ще бъде презаписана от втората глава. Увеличете началното време на втората глава.",
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия", "ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
"ToastChaptersRemoved": "Главите са премахнати",
"ToastChaptersUpdated": "Главите са актуализирани",
"ToastCollectionItemsAddFailed": "Неуспешно добавяне на елемент(и) към колекцията",
"ToastCollectionRemoveSuccess": "Колекцията е премахната", "ToastCollectionRemoveSuccess": "Колекцията е премахната",
"ToastCollectionUpdateSuccess": "Колекцията е обновена", "ToastCollectionUpdateSuccess": "Колекцията е обновена",
"ToastConnectionNotAvailable": "Няма връзка. Моля, опитайте отново по-късно",
"ToastCoverSearchFailed": "Търсенето на корица е неуспешно",
"ToastCoverUpdateFailed": "Обновяването на корицата е неуспешно",
"ToastDateTimeInvalidOrIncomplete": "Датата и часът са невалидни или непълни",
"ToastDeleteFileFailed": "Неуспешно изтриване на файла", "ToastDeleteFileFailed": "Неуспешно изтриване на файла",
"ToastDeleteFileSuccess": "Успешно изтриване на файла", "ToastDeleteFileSuccess": "Успешно изтриване на файла",
"ToastDeviceAddFailed": "Неуспешно добавяне на устройство",
"ToastDeviceNameAlreadyExists": "Вече съществува четец с това име",
"ToastDeviceTestEmailFailed": "Неуспешно изпращане на тестов имейл",
"ToastDeviceTestEmailSuccess": "Тестовият имейл е изпратен",
"ToastEmailSettingsUpdateSuccess": "Имейл настройките са актуализирани",
"ToastEncodeCancelFailed": "Неуспешно отменяне на кодирането",
"ToastEncodeCancelSucces": "Кодирането е отменено",
"ToastEpisodeDownloadQueueClearFailed": "Неуспешно изчистване на опашката",
"ToastEpisodeDownloadQueueClearSuccess": "Опашката за изтегляне на епизоди е изчистена",
"ToastEpisodeUpdateSuccess": "{0} епизода са актуализирани",
"ToastErrorCannotShare": "Не може да се споделя директно от това устройство",
"ToastFailedToCreate": "Неуспешно създаване",
"ToastFailedToDelete": "Неуспешно изтриване",
"ToastFailedToLoadData": "Неуспешно зареждане на данни", "ToastFailedToLoadData": "Неуспешно зареждане на данни",
"ToastFailedToMatch": "Неуспешно съвпадение",
"ToastFailedToShare": "Неуспешно споделяне",
"ToastFailedToUpdate": "Неуспешно актуализиране",
"ToastInvalidImageUrl": "Невалиден URL адрес на изображение",
"ToastInvalidMaxEpisodesToDownload": "Невалиден максимален брой епизоди за изтегляне",
"ToastInvalidUrl": "Невалиден URL адрес",
"ToastInvalidUrls": "Един или повече URL адреси са невалидни",
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена", "ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",
"ToastItemDeletedFailed": "Неуспешно изтриване на елемента",
"ToastItemDeletedSuccess": "Елементът е изтрит",
"ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени", "ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени",
"ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като Завършено", "ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като Завършено",
"ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен", "ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен",
"ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като Незавършено", "ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като Незавършено",
"ToastItemMarkedAsNotFinishedSuccess": "Елементът е маркиран като незавършен", "ToastItemMarkedAsNotFinishedSuccess": "Елементът е маркиран като незавършен",
"ToastItemUpdateSuccess": "Елементът е актуализиран",
"ToastLibraryCreateFailed": "Неуспешно създаване на библиотека", "ToastLibraryCreateFailed": "Неуспешно създаване на библиотека",
"ToastLibraryCreateSuccess": "Библиотеката \"{0}\" е създадена", "ToastLibraryCreateSuccess": "Библиотеката \"{0}\" е създадена",
"ToastLibraryDeleteFailed": "Неуспешно изтриване на библиотека", "ToastLibraryDeleteFailed": "Неуспешно изтриване на библиотека",
@@ -1037,28 +1069,97 @@
"ToastLibraryScanFailedToStart": "Неуспешно стартиране на сканиране", "ToastLibraryScanFailedToStart": "Неуспешно стартиране на сканиране",
"ToastLibraryScanStarted": "Сканирането на библиотеката е стартирано", "ToastLibraryScanStarted": "Сканирането на библиотеката е стартирано",
"ToastLibraryUpdateSuccess": "Библиотеката \"{0}\" е обновена", "ToastLibraryUpdateSuccess": "Библиотеката \"{0}\" е обновена",
"ToastMatchAllAuthorsFailed": "Неуспешно съвпадение на всички автори",
"ToastMetadataFilesRemovedError": "Грешка при премахване на metadata.{0} файлове",
"ToastMetadataFilesRemovedNoneFound": "Не са намерени metadata.{0} файлове в библиотеката",
"ToastMetadataFilesRemovedNoneRemoved": "Не са премахнати metadata.{0} файлове",
"ToastMetadataFilesRemovedSuccess": "Премахнати са {0} файла metadata.{1}",
"ToastMustHaveAtLeastOnePath": "Трябва да има поне един път",
"ToastNameEmailRequired": "Изискват се име и имейл",
"ToastNameRequired": "Изисква се име",
"ToastNewApiKeyUserError": "Трябва да изберете потребител",
"ToastNewEpisodesFound": "Намерени са {0} нови епизода",
"ToastNewUserCreatedFailed": "Неуспешно създаване на акаунт: „{0}“",
"ToastNewUserCreatedSuccess": "Създаден е нов акаунт",
"ToastNewUserLibraryError": "Трябва да изберете поне една библиотека",
"ToastNewUserPasswordError": "Трябва да има парола; само root потребителят може да бъде с празна парола",
"ToastNewUserTagError": "Трябва да изберете поне един етикет",
"ToastNewUserUsernameError": "Въведете потребителско име",
"ToastNoNewEpisodesFound": "Не са намерени нови епизоди",
"ToastNoRSSFeed": "Подкастът няма RSS емисия",
"ToastNoUpdatesNecessary": "Не са необходими актуализации",
"ToastNotificationCreateFailed": "Неуспешно създаване на известие",
"ToastNotificationDeleteFailed": "Неуспешно изтриване на известието",
"ToastNotificationFailedMaximum": "Максималният брой неуспешни опити трябва да бъде >= 0",
"ToastNotificationQueueMaximum": "Максималната опашка за известия трябва да бъде >= 0",
"ToastNotificationSettingsUpdateSuccess": "Настройките за известия са актуализирани",
"ToastNotificationTestTriggerFailed": "Неуспешно задействане на тестово известие",
"ToastNotificationTestTriggerSuccess": "Тестовото известие е задействано",
"ToastNotificationUpdateSuccess": "Известието е актуализирано",
"ToastPlaylistCreateFailed": "Неуспешно създаване на плейлист", "ToastPlaylistCreateFailed": "Неуспешно създаване на плейлист",
"ToastPlaylistCreateSuccess": "Плейлистът е създаден", "ToastPlaylistCreateSuccess": "Плейлистът е създаден",
"ToastPlaylistRemoveSuccess": "Плейлистът е премахнат", "ToastPlaylistRemoveSuccess": "Плейлистът е премахнат",
"ToastPlaylistUpdateSuccess": "Плейлистът е обновен", "ToastPlaylistUpdateSuccess": "Плейлистът е обновен",
"ToastPodcastCreateFailed": "Неуспешно създаване на подкаст", "ToastPodcastCreateFailed": "Неуспешно създаване на подкаст",
"ToastPodcastCreateSuccess": "Подкаст успешно създаден", "ToastPodcastCreateSuccess": "Подкаст успешно създаден",
"ToastPodcastEpisodeUpdated": "Епизодът е актуализиран",
"ToastPodcastGetFeedFailed": "Неуспешно извличане на емисията на подкаста",
"ToastPodcastNoEpisodesInFeed": "Не са намерени епизоди в RSS емисията",
"ToastPodcastNoRssFeed": "Подкастът няма RSS емисия",
"ToastProgressIsNotBeingSynced": "Напредъкът не се синхронизира, рестартирайте възпроизвеждането",
"ToastProviderCreatedFailed": "Неуспешно добавяне на доставчик",
"ToastProviderCreatedSuccess": "Добавен е нов доставчик",
"ToastProviderNameAndUrlRequired": "Изискват се име и URL адрес",
"ToastProviderRemoveSuccess": "Доставчикът е премахнат",
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията", "ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията",
"ToastRSSFeedCloseSuccess": "RSS емисията е затворена", "ToastRSSFeedCloseSuccess": "RSS емисията е затворена",
"ToastRemoveFailed": "Неуспешно премахване",
"ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция", "ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция",
"ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция", "ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция",
"ToastRemoveItemsWithIssuesFailed": "Неуспешно премахване на елементите от библиотеката с проблеми",
"ToastRemoveItemsWithIssuesSuccess": "Елементите от библиотеката с проблеми са премахнати",
"ToastRenameFailed": "Неуспешно преименуване",
"ToastRescanFailed": "Повторното сканиране е неуспешно за {0}",
"ToastRescanRemoved": "Повторното сканиране завърши: елементът е премахнат",
"ToastRescanUpToDate": "Повторното сканиране завърши: елементът вече е актуален",
"ToastRescanUpdated": "Повторното сканиране завърши: елементът е актуализиран",
"ToastScanFailed": "Неуспешно сканиране на елемент от библиотеката",
"ToastSelectAtLeastOneUser": "Изберете поне един потребител",
"ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство", "ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство",
"ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"", "ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"",
"ToastSeriesSubmitFailedSameName": "Не могат да бъдат добавени два сериала с едно и също име",
"ToastSeriesUpdateFailed": "Неуспешно обновяване на серия", "ToastSeriesUpdateFailed": "Неуспешно обновяване на серия",
"ToastSeriesUpdateSuccess": "Серията е обновена", "ToastSeriesUpdateSuccess": "Серията е обновена",
"ToastServerSettingsUpdateSuccess": "Настройките на сървъра са актуализирани", "ToastServerSettingsUpdateSuccess": "Настройките на сървъра са актуализирани",
"ToastSessionCloseFailed": "Неуспешно затваряне на сесията",
"ToastSessionDeleteFailed": "Неуспешно изтриване на сесия", "ToastSessionDeleteFailed": "Неуспешно изтриване на сесия",
"ToastSessionDeleteSuccess": "Сесията е изтрита", "ToastSessionDeleteSuccess": "Сесията е изтрита",
"ToastSleepTimerDone": "Таймерът за заспиване приключи... zZzzZz",
"ToastSlugMustChange": "Краткият URL (slug) съдържа невалидни символи",
"ToastSlugRequired": "Изисква се кратък URL (slug)",
"ToastSocketConnected": "Свързан сокет", "ToastSocketConnected": "Свързан сокет",
"ToastSocketDisconnected": "Сокетът е прекъснат", "ToastSocketDisconnected": "Сокетът е прекъснат",
"ToastSocketFailedToConnect": "Неуспешно свързване на сокет", "ToastSocketFailedToConnect": "Неуспешно свързване на сокет",
"ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране", "ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране",
"ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)", "ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)",
"ToastTitleRequired": "Изисква се заглавие",
"ToastUnknownError": "Неизвестна грешка",
"ToastUnlinkOpenIdFailed": "Неуспешно прекъсване на връзката на потребителя с OpenID",
"ToastUnlinkOpenIdSuccess": "Връзката на потребителя с OpenID е прекъсната",
"ToastUploaderFilepathExistsError": "Файловият път „{0}“ вече съществува на сървъра",
"ToastUploaderItemExistsInSubdirectoryError": "Елементът „{0}“ използва поддиректория на пътя за качване.",
"ToastUserDeleteFailed": "Неуспешно изтриване на потребител", "ToastUserDeleteFailed": "Неуспешно изтриване на потребител",
"ToastUserDeleteSuccess": "Потребителят е изтрит" "ToastUserDeleteSuccess": "Потребителят е изтрит",
"ToastUserPasswordChangeSuccess": "Паролата е променена успешно",
"ToastUserPasswordMismatch": "Паролите не съвпадат",
"ToastUserPasswordMustChange": "Новата парола не може да бъде същата като старата",
"ToastUserRootRequireName": "Трябва да въведете root потребителско име",
"TooltipAddChapters": "Добавяне на глава(и)",
"TooltipAddOneSecond": "Добавяне на 1 секунда",
"TooltipAdjustChapterStart": "Кликнете за коригиране на началния час",
"TooltipLockAllChapters": "Заключване на всички глави",
"TooltipLockChapter": "Заключване на глава (Shift+клик за диапазон)",
"TooltipSubtractOneSecond": "Изваждане на 1 секунда",
"TooltipUnlockAllChapters": "Отключване на всички глави",
"TooltipUnlockChapter": "Отключване на глава (Shift+клик за диапазон)"
} }
+1
View File
@@ -0,0 +1 @@
{}
+48
View File
@@ -951,6 +951,11 @@
"NoteUploaderFoldersWithMediaFiles": "Foldery z plikami multimedialnymi będą traktowane jako osobne elementy w bibliotece.", "NoteUploaderFoldersWithMediaFiles": "Foldery z plikami multimedialnymi będą traktowane jako osobne elementy w bibliotece.",
"NoteUploaderOnlyAudioFiles": "Jeśli przesyłasz tylko pliki audio, każdy plik audio będzie traktowany jako osobny audiobook.", "NoteUploaderOnlyAudioFiles": "Jeśli przesyłasz tylko pliki audio, każdy plik audio będzie traktowany jako osobny audiobook.",
"NoteUploaderUnsupportedFiles": "Nieobsługiwane pliki są ignorowane. Podczas dodawania folderu, inne pliki, które nie znajdują się w folderze elementu, są ignorowane.", "NoteUploaderUnsupportedFiles": "Nieobsługiwane pliki są ignorowane. Podczas dodawania folderu, inne pliki, które nie znajdują się w folderze elementu, są ignorowane.",
"NotificationOnBackupCompletedDescription": "Wyzwalane po zakończeniu tworzenia kopii zapasowej",
"NotificationOnBackupFailedDescription": "Wyzwalane w przypadku gdy stworzenie kopii zapasowej rzuci błąd",
"NotificationOnEpisodeDownloadedDescription": "Wyzwalane, gdy odcinek podcastu zostanie automatycznie pobrany",
"NotificationOnRSSFeedDisabledDescription": "Wyzwalane, gdy automatyczne pobieranie odcinków jest wyłączone z powodu zbyt wielu nieudanych prób",
"NotificationOnRSSFeedFailedDescription": "Wyzwalane, gdy żądanie kanału RSS dotyczące automatycznego pobrania odcinka nie powiedzie się",
"NotificationOnTestDescription": "Zdarzenie używane do testowania systemu powiadomień", "NotificationOnTestDescription": "Zdarzenie używane do testowania systemu powiadomień",
"PlaceholderBulkChapterInput": "Wpisz tytuł rozdziału lub użyj numeracji (np. „Odcinek 1”, „Rozdział 10”, „1.”)", "PlaceholderBulkChapterInput": "Wpisz tytuł rozdziału lub użyj numeracji (np. „Odcinek 1”, „Rozdział 10”, „1.”)",
"PlaceholderNewCollection": "Nowa nazwa kolekcji", "PlaceholderNewCollection": "Nowa nazwa kolekcji",
@@ -960,6 +965,7 @@
"PlaceholderSearchEpisode": "Szukanie odcinka..", "PlaceholderSearchEpisode": "Szukanie odcinka..",
"StatsAuthorsAdded": "dodano autorów", "StatsAuthorsAdded": "dodano autorów",
"StatsBooksAdded": "dodano książki", "StatsBooksAdded": "dodano książki",
"StatsBooksAdditional": "Niektóre dodatki obejmują…",
"StatsBooksFinished": "ukończone książki", "StatsBooksFinished": "ukończone książki",
"StatsBooksFinishedThisYear": "Wybrane książki ukończone w tym roku…", "StatsBooksFinishedThisYear": "Wybrane książki ukończone w tym roku…",
"StatsBooksListenedTo": "książki wysłuchane", "StatsBooksListenedTo": "książki wysłuchane",
@@ -976,6 +982,7 @@
"StatsTotalDuration": "O sumarycznej długości…", "StatsTotalDuration": "O sumarycznej długości…",
"StatsYearInReview": "PRZEGLĄD ROKU", "StatsYearInReview": "PRZEGLĄD ROKU",
"ToastAccountUpdateSuccess": "Zaktualizowano konto", "ToastAccountUpdateSuccess": "Zaktualizowano konto",
"ToastAppriseUrlRequired": "Należy wprowadzić adres URL Apprise",
"ToastAsinRequired": "ASIN jest wymagany", "ToastAsinRequired": "ASIN jest wymagany",
"ToastAuthorImageRemoveSuccess": "Zdjęcie autora usunięte", "ToastAuthorImageRemoveSuccess": "Zdjęcie autora usunięte",
"ToastAuthorNotFound": "Autor \"{0}\" nie został znaleziony", "ToastAuthorNotFound": "Autor \"{0}\" nie został znaleziony",
@@ -994,8 +1001,11 @@
"ToastBackupRestoreFailed": "Nie udało się przywrócić kopii zapasowej", "ToastBackupRestoreFailed": "Nie udało się przywrócić kopii zapasowej",
"ToastBackupUploadFailed": "Nie udało się przesłać kopii zapasowej", "ToastBackupUploadFailed": "Nie udało się przesłać kopii zapasowej",
"ToastBackupUploadSuccess": "Kopia zapasowa została przesłana", "ToastBackupUploadSuccess": "Kopia zapasowa została przesłana",
"ToastBatchApplyDetailsToItemsSuccess": "Szczegóły zastosowane do elementów",
"ToastBatchDeleteFailed": "Usuwanie zbiorcze nie powiodło się", "ToastBatchDeleteFailed": "Usuwanie zbiorcze nie powiodło się",
"ToastBatchDeleteSuccess": "Usuwanie zbiorcze powiodło się", "ToastBatchDeleteSuccess": "Usuwanie zbiorcze powiodło się",
"ToastBatchQuickMatchFailed": "Szybkie dopasowanie partii nie powiodło się!",
"ToastBatchQuickMatchStarted": "Rozpoczęto partię szybkiego dopasowania {0} książek!",
"ToastBatchUpdateFailed": "Aktualizacja zbiorcza nie powiodła się", "ToastBatchUpdateFailed": "Aktualizacja zbiorcza nie powiodła się",
"ToastBatchUpdateSuccess": "Aktualizacja zbiorcza powiodła się", "ToastBatchUpdateSuccess": "Aktualizacja zbiorcza powiodła się",
"ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki", "ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki",
@@ -1033,7 +1043,14 @@
"ToastEpisodeDownloadQueueClearSuccess": "Wyczyszczono kolejkę epizodów do ściągnięcia", "ToastEpisodeDownloadQueueClearSuccess": "Wyczyszczono kolejkę epizodów do ściągnięcia",
"ToastEpisodeUpdateSuccess": "Zaktualizowano {0} odcinków", "ToastEpisodeUpdateSuccess": "Zaktualizowano {0} odcinków",
"ToastErrorCannotShare": "Nie można udostępniać natywnie na tym urządzeniu.", "ToastErrorCannotShare": "Nie można udostępniać natywnie na tym urządzeniu.",
"ToastFailedToCreate": "Nie udało się utworzyć",
"ToastFailedToDelete": "Nie udało się usunąć",
"ToastFailedToLoadData": "Nie udało się załadować danych",
"ToastFailedToMatch": "Nie udało się dopasować",
"ToastFailedToShare": "Nie udało się udostępnić",
"ToastFailedToUpdate": "Nie udało się zaktualizować",
"ToastInvalidImageUrl": "Nieprawidłowy URL obrazu", "ToastInvalidImageUrl": "Nieprawidłowy URL obrazu",
"ToastInvalidMaxEpisodesToDownload": "Nieprawidłowa maksymalna liczba odcinków do pobrania",
"ToastInvalidUrl": "Nieprawidłowy URL", "ToastInvalidUrl": "Nieprawidłowy URL",
"ToastInvalidUrls": "Jeden lub więcej URL-i są nieprawidłowe", "ToastInvalidUrls": "Jeden lub więcej URL-i są nieprawidłowe",
"ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę", "ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę",
@@ -1044,6 +1061,7 @@
"ToastItemMarkedAsFinishedSuccess": "Pozycja oznaczona jako ukończona", "ToastItemMarkedAsFinishedSuccess": "Pozycja oznaczona jako ukończona",
"ToastItemMarkedAsNotFinishedFailed": "Oznaczenie pozycji jako ukończonej nie powiodło się", "ToastItemMarkedAsNotFinishedFailed": "Oznaczenie pozycji jako ukończonej nie powiodło się",
"ToastItemMarkedAsNotFinishedSuccess": "Pozycja oznaczona jako nieukończona", "ToastItemMarkedAsNotFinishedSuccess": "Pozycja oznaczona jako nieukończona",
"ToastItemUpdateSuccess": "Element zaktualizowany",
"ToastLibraryCreateFailed": "Nie udało się utworzyć biblioteki", "ToastLibraryCreateFailed": "Nie udało się utworzyć biblioteki",
"ToastLibraryCreateSuccess": "Biblioteka \"{0}\" stworzona", "ToastLibraryCreateSuccess": "Biblioteka \"{0}\" stworzona",
"ToastLibraryDeleteFailed": "Nie udało się usunąć biblioteki", "ToastLibraryDeleteFailed": "Nie udało się usunąć biblioteki",
@@ -1052,6 +1070,10 @@
"ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki", "ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki",
"ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji", "ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji",
"ToastMatchAllAuthorsFailed": "Nie udało się dopasować wszystkich autorów", "ToastMatchAllAuthorsFailed": "Nie udało się dopasować wszystkich autorów",
"ToastMetadataFilesRemovedError": "Błąd podczas usuwania metadata.{0} plików",
"ToastMetadataFilesRemovedNoneFound": "Nie znaleziono metadata.{0} plików w bibliotece",
"ToastMetadataFilesRemovedNoneRemoved": "Nie usunięto żadnego metadata.{0} pliku",
"ToastMetadataFilesRemovedSuccess": "{0} metadata.{0} plików usunięto",
"ToastMustHaveAtLeastOnePath": "Musi mieć przynajmniej jedną ścieżkę", "ToastMustHaveAtLeastOnePath": "Musi mieć przynajmniej jedną ścieżkę",
"ToastNameEmailRequired": "Nazwa i email są wymagane", "ToastNameEmailRequired": "Nazwa i email są wymagane",
"ToastNameRequired": "Imię jest wymagane", "ToastNameRequired": "Imię jest wymagane",
@@ -1065,7 +1087,15 @@
"ToastNewUserUsernameError": "Wprowadź nazwę użytkownika", "ToastNewUserUsernameError": "Wprowadź nazwę użytkownika",
"ToastNoNewEpisodesFound": "Nie znaleziono nowych odcinków", "ToastNoNewEpisodesFound": "Nie znaleziono nowych odcinków",
"ToastNoRSSFeed": "Podcast nie posiada RSS Feed", "ToastNoRSSFeed": "Podcast nie posiada RSS Feed",
"ToastNoUpdatesNecessary": "Brak konieczności aktualizacji",
"ToastNotificationCreateFailed": "Nie udało się utworzyć powiadomienia",
"ToastNotificationDeleteFailed": "Nie udało się usunąć powiadomienia",
"ToastNotificationFailedMaximum": "Maks. ilość nieudanych prób musi być >= 0", "ToastNotificationFailedMaximum": "Maks. ilość nieudanych prób musi być >= 0",
"ToastNotificationQueueMaximum": "Maksymalna liczba powiadomień w kolejce musi być >= 0",
"ToastNotificationSettingsUpdateSuccess": "Zaktualizowano ustawienia powiadomień",
"ToastNotificationTestTriggerFailed": "Nie udało się wywołać powiadomienia testowego",
"ToastNotificationTestTriggerSuccess": "Wyzwolono powiadomienie testowe",
"ToastNotificationUpdateSuccess": "Powiadomienie zaktualizowane",
"ToastPlaylistCreateFailed": "Nie udało się utworzyć playlisty", "ToastPlaylistCreateFailed": "Nie udało się utworzyć playlisty",
"ToastPlaylistCreateSuccess": "Playlista utworzona", "ToastPlaylistCreateSuccess": "Playlista utworzona",
"ToastPlaylistRemoveSuccess": "Playlista usunięta", "ToastPlaylistRemoveSuccess": "Playlista usunięta",
@@ -1073,8 +1103,17 @@
"ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu", "ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu",
"ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony", "ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony",
"ToastPodcastEpisodeUpdated": "Zaktualizowano odcinki", "ToastPodcastEpisodeUpdated": "Zaktualizowano odcinki",
"ToastPodcastGetFeedFailed": "Nie udało się pobrać kanału podcastu",
"ToastPodcastNoEpisodesInFeed": "Nie znaleziono żadnych odcinków w kanale RSS",
"ToastPodcastNoRssFeed": "Podcast nie ma kanału RSS",
"ToastProgressIsNotBeingSynced": "Postęp nie jest synchronizowany, uruchom ponownie odtwarzanie",
"ToastProviderCreatedFailed": "Nie udało się dodać dostawcy",
"ToastProviderCreatedSuccess": "Dodano nowego dostawcę",
"ToastProviderNameAndUrlRequired": "Wymagane jest podanie nazwy i adresu URL",
"ToastProviderRemoveSuccess": "Dostawca usunięty",
"ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się", "ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się",
"ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS powiodło się", "ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS powiodło się",
"ToastRemoveFailed": "Nie udało się usunąć",
"ToastRemoveItemFromCollectionFailed": "Nie udało się usunąć elementu z kolekcji", "ToastRemoveItemFromCollectionFailed": "Nie udało się usunąć elementu z kolekcji",
"ToastRemoveItemFromCollectionSuccess": "Pozycja usunięta z kolekcji", "ToastRemoveItemFromCollectionSuccess": "Pozycja usunięta z kolekcji",
"ToastRemoveItemsWithIssuesFailed": "Nie udało się usunąć wadliwych elementów z biblioteki", "ToastRemoveItemsWithIssuesFailed": "Nie udało się usunąć wadliwych elementów z biblioteki",
@@ -1096,16 +1135,25 @@
"ToastSessionDeleteFailed": "Nie udało się usunąć sesji", "ToastSessionDeleteFailed": "Nie udało się usunąć sesji",
"ToastSessionDeleteSuccess": "Sesja usunięta", "ToastSessionDeleteSuccess": "Sesja usunięta",
"ToastSleepTimerDone": "Słodkich snów... zZzzZz", "ToastSleepTimerDone": "Słodkich snów... zZzzZz",
"ToastSlugMustChange": "Slug zawiera nieprawidłowe znaki",
"ToastSlugRequired": "Slug jest wymagany",
"ToastSocketConnected": "Nawiązano połączenie z serwerem", "ToastSocketConnected": "Nawiązano połączenie z serwerem",
"ToastSocketDisconnected": "Połączenie z serwerem zostało zamknięte", "ToastSocketDisconnected": "Połączenie z serwerem zostało zamknięte",
"ToastSocketFailedToConnect": "Poączenie z serwerem nie powiodło się", "ToastSocketFailedToConnect": "Poączenie z serwerem nie powiodło się",
"ToastSortingPrefixesEmptyError": "Musi mieć co najmniej 1 prefiks sortowania",
"ToastSortingPrefixesUpdateSuccess": "Zaktualizowano prefiksy sortowania ({0} elementów)",
"ToastTitleRequired": "Tytuł jest wymagany", "ToastTitleRequired": "Tytuł jest wymagany",
"ToastUnknownError": "Nieznany błąd", "ToastUnknownError": "Nieznany błąd",
"ToastUnlinkOpenIdFailed": "Nie udało się odpiąć użytkownika z OpenID", "ToastUnlinkOpenIdFailed": "Nie udało się odpiąć użytkownika z OpenID",
"ToastUnlinkOpenIdSuccess": "Użytkownik odpięty z OpenID", "ToastUnlinkOpenIdSuccess": "Użytkownik odpięty z OpenID",
"ToastUploaderFilepathExistsError": "Ścieżka \"{0}\" już istnieje na serwerze", "ToastUploaderFilepathExistsError": "Ścieżka \"{0}\" już istnieje na serwerze",
"ToastUploaderItemExistsInSubdirectoryError": "Element \"{0}\" używa podkatalogu ścieżki przesyłania.",
"ToastUserDeleteFailed": "Nie udało się usunąć użytkownika", "ToastUserDeleteFailed": "Nie udało się usunąć użytkownika",
"ToastUserDeleteSuccess": "Użytkownik usunięty", "ToastUserDeleteSuccess": "Użytkownik usunięty",
"ToastUserPasswordChangeSuccess": "Hasło zostało pomyślnie zmienione",
"ToastUserPasswordMismatch": "Hasła nie są zgodne",
"ToastUserPasswordMustChange": "Nowe hasło nie może być takie samo jak stare hasło",
"ToastUserRootRequireName": "Należy wprowadzić nazwę użytkownika root",
"TooltipAddChapters": "Dodaj rozdział(y)", "TooltipAddChapters": "Dodaj rozdział(y)",
"TooltipAddOneSecond": "Dodaj sekundę", "TooltipAddOneSecond": "Dodaj sekundę",
"TooltipAdjustChapterStart": "Kliknij, aby skorygować czas początkowy", "TooltipAdjustChapterStart": "Kliknij, aby skorygować czas początkowy",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.34.0", "version": "2.35.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.34.0", "version": "2.35.1",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.34.0", "version": "2.35.1",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "index.js", "main": "index.js",
+22 -2
View File
@@ -3,6 +3,7 @@ const Logger = require('./Logger')
const Database = require('./Database') const Database = require('./Database')
const TokenManager = require('./auth/TokenManager') const TokenManager = require('./auth/TokenManager')
const CoverSearchManager = require('./managers/CoverSearchManager') const CoverSearchManager = require('./managers/CoverSearchManager')
const { LogLevel } = require('./utils/constants')
/** /**
* @typedef SocketClient * @typedef SocketClient
@@ -85,6 +86,14 @@ class SocketAuthority {
} }
} }
requireAdminSocket(socket, eventName) {
const client = this.clients[socket.id]
if (client?.user?.isAdminOrUp) return true
Logger.warn(`[SocketAuthority] Unauthorized ${eventName} socket event from socket ${socket.id}`)
return false
}
/** /**
* Emits event with library item to all clients that can access the library item * Emits event with library item to all clients that can access the library item
* Note: Emits toOldJSONExpanded() * Note: Emits toOldJSONExpanded()
@@ -179,14 +188,25 @@ class SocketAuthority {
socket.on('auth', (token) => this.authenticateSocket(socket, token)) socket.on('auth', (token) => this.authenticateSocket(socket, token))
// Scanning // Scanning
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId)) socket.on('cancel_scan', (libraryId) => {
if (!this.requireAdminSocket(socket, 'cancel_scan')) return
this.cancelScan(libraryId)
})
// Cover search streaming // Cover search streaming
socket.on('search_covers', (payload) => this.handleCoverSearch(socket, payload)) socket.on('search_covers', (payload) => this.handleCoverSearch(socket, payload))
socket.on('cancel_cover_search', (requestId) => this.handleCancelCoverSearch(socket, requestId)) socket.on('cancel_cover_search', (requestId) => this.handleCancelCoverSearch(socket, requestId))
// Logs // Logs
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level)) socket.on('set_log_listener', (level) => {
if (!this.requireAdminSocket(socket, 'set_log_listener')) return
if (!Number.isInteger(level) || !Object.values(LogLevel).includes(level)) {
Logger.warn(`[SocketAuthority] Invalid set_log_listener level from socket ${socket.id}`)
return
}
Logger.addSocketListener(socket, level)
})
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id)) socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
// Sent automatically from socket.io clients // Sent automatically from socket.io clients
+91 -21
View File
@@ -1,4 +1,5 @@
const { Op } = require('sequelize') const { Op } = require('sequelize')
const uuid = require('uuid')
const Database = require('../Database') const Database = require('../Database')
const Logger = require('../Logger') const Logger = require('../Logger')
@@ -115,6 +116,7 @@ class TokenManager {
const payload = { const payload = {
userId: user.id, userId: user.id,
username: user.username, username: user.username,
jti: uuid.v4(),
type: 'access' type: 'access'
} }
const options = { const options = {
@@ -138,6 +140,7 @@ class TokenManager {
const payload = { const payload = {
userId: user.id, userId: user.id,
username: user.username, username: user.username,
jti: uuid.v4(),
type: 'refresh' type: 'refresh'
} }
const options = { const options = {
@@ -183,20 +186,56 @@ class TokenManager {
* @param {import('../models/User')} user * @param {import('../models/User')} user
* @param {import('express').Request} req * @param {import('express').Request} req
* @param {import('express').Response} res * @param {import('express').Response} res
* @param {boolean} gracePeriod - whether to use the grace period
* @returns {Promise<{ accessToken:string, refreshToken:string }>} * @returns {Promise<{ accessToken:string, refreshToken:string }>}
*/ */
async rotateTokensForSession(session, user, req, res) { async rotateTokensForSession(session, user, req, res, gracePeriod = true) {
// Generate new tokens const previousRefreshToken = session.refreshToken
const newAccessToken = this.generateTempAccessToken(user) const newAccessToken = this.generateTempAccessToken(user)
const newRefreshToken = this.generateRefreshToken(user) let newRefreshToken = this.generateRefreshToken(user)
// Calculate new expiration time
const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
// Update the session with the new refresh token and expiration let lastRefreshToken = null
session.refreshToken = newRefreshToken let lastRefreshTokenExpiresAt = null
session.expiresAt = newExpiresAt if (gracePeriod) {
await session.save() // Set grace period of old refresh token in case of race condition in token rotation.
// This grace period may need to be longer if fetching the user data takes longer due to large progress objects
lastRefreshToken = previousRefreshToken
lastRefreshTokenExpiresAt = new Date(Date.now() + 60 * 1000) // 1 minute grace period
}
// Only update if this session row still has the refresh token we read
const [numUpdated] = await Database.sessionModel.update(
{
refreshToken: newRefreshToken,
expiresAt: newExpiresAt,
lastRefreshToken,
lastRefreshTokenExpiresAt
},
{
where: {
id: session.id,
refreshToken: previousRefreshToken
}
}
)
if (numUpdated === 0) {
Logger.debug(`[TokenManager] Race condition in rotateTokensForSession for user ${user.id}, getting new token`)
const updatedSession = await Database.sessionModel.findOne({ where: { id: session.id } })
newRefreshToken = updatedSession.refreshToken
session.refreshToken = updatedSession.refreshToken
session.expiresAt = updatedSession.expiresAt
session.lastRefreshToken = updatedSession.lastRefreshToken
session.lastRefreshTokenExpiresAt = updatedSession.lastRefreshTokenExpiresAt
} else {
session.refreshToken = newRefreshToken
session.expiresAt = newExpiresAt
session.lastRefreshToken = lastRefreshToken
session.lastRefreshTokenExpiresAt = lastRefreshTokenExpiresAt
}
// Set new refresh token cookie // Set new refresh token cookie
this.setRefreshTokenCookie(req, res, newRefreshToken) this.setRefreshTokenCookie(req, res, newRefreshToken)
@@ -294,23 +333,40 @@ class TokenManager {
} }
} }
const session = await Database.sessionModel.findOne({ let session = await Database.sessionModel.findOne({
where: { refreshToken: refreshToken } where: {
[Op.or]: [{ refreshToken: refreshToken }, { lastRefreshToken: refreshToken }]
}
}) })
if (!session) { if (!session) {
Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`) Logger.error(`[TokenManager] Failed to refresh token. Session not found`)
return { return {
error: 'Invalid refresh token' error: 'Invalid refresh token'
} }
} }
// Check if session is expired in database let isGracePeriod = false
if (session.expiresAt < new Date()) { if (session.refreshToken !== refreshToken) {
Logger.info(`[TokenManager] Session expired in database, cleaning up`) // Token matched lastRefreshToken
await session.destroy() if (session.lastRefreshTokenExpiresAt && session.lastRefreshTokenExpiresAt > new Date()) {
return { isGracePeriod = true
error: 'Refresh token expired' Logger.debug(`[TokenManager] Grace period hit for user ${session.userId}`)
} else {
Logger.debug(`[TokenManager] Grace period expired for user ${session.userId}`)
return {
error: 'Invalid refresh token'
}
}
} else {
// Token matched current refreshToken
// 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'
}
} }
} }
@@ -322,6 +378,20 @@ class TokenManager {
} }
} }
if (isGracePeriod) {
// Return the already rotated refresh token store in the database,
// and generate a new access token without changing the refresh token
// again
const accessToken = this.generateTempAccessToken(user)
this.setRefreshTokenCookie(req, res, session.refreshToken)
return {
accessToken,
refreshToken: session.refreshToken,
user
}
}
const newTokens = await this.rotateTokensForSession(session, user, req, res) const newTokens = await this.rotateTokensForSession(session, user, req, res)
return { return {
accessToken: newTokens.accessToken, accessToken: newTokens.accessToken,
@@ -375,7 +445,7 @@ class TokenManager {
// So rotate token for current session // So rotate token for current session
const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } }) const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } })
if (currentSession) { if (currentSession) {
const newTokens = await this.rotateTokensForSession(currentSession, user, req, res) const newTokens = await this.rotateTokensForSession(currentSession, user, req, res, false)
// Invalidate all sessions for the user except the current one // Invalidate all sessions for the user except the current one
await Database.sessionModel.destroy({ await Database.sessionModel.destroy({
@@ -389,7 +459,7 @@ class TokenManager {
return newTokens.accessToken return newTokens.accessToken
} else { } else {
Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`) Logger.error(`[TokenManager] No session found to rotate tokens`)
} }
} }
@@ -413,7 +483,7 @@ class TokenManager {
try { try {
const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } }) const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } })
Logger.info(`[TokenManager] Refresh token ${refreshToken} invalidated, ${numDeleted} sessions deleted`) Logger.info(`[TokenManager] Refresh token invalidated, ${numDeleted} sessions deleted`)
return true return true
} catch (error) { } catch (error) {
Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`) Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`)
+1 -1
View File
@@ -149,7 +149,7 @@ class AuthorController {
}) })
if (libraryItems.length) { if (libraryItems.length) {
await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor
await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate) // Create all new BookAuthor await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate, { ignoreDuplicates: true }) // Create all new unique BookAuthor
for (const libraryItem of libraryItems) { for (const libraryItem of libraryItems) {
await libraryItem.saveMetadataFile() await libraryItem.saveMetadataFile()
} }
+6
View File
@@ -2,6 +2,7 @@ const { Request, Response } = require('express')
const Path = require('path') const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
@@ -216,6 +217,11 @@ class RssFeedManager {
res.sendStatus(404) res.sendStatus(404)
return return
} }
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(episodePath))
if (audioMimeType) {
res.setHeader('Content-Type', audioMimeType)
}
res.sendFile(episodePath) res.sendFile(episodePath)
} }
@@ -0,0 +1,84 @@
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
const migrationVersion = '2.35.0'
const migrationName = `${migrationVersion}-add-last-refresh-token`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This migration script adds lastRefreshToken and lastRefreshTokenExpiresAt columns to the sessions table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
if (await queryInterface.tableExists('sessions')) {
const tableDescription = await queryInterface.describeTable('sessions')
if (!tableDescription.lastRefreshToken) {
logger.info(`${loggerPrefix} Adding lastRefreshToken column to sessions table`)
await queryInterface.addColumn('sessions', 'lastRefreshToken', {
type: queryInterface.sequelize.Sequelize.DataTypes.STRING,
allowNull: true
})
} else {
logger.info(`${loggerPrefix} lastRefreshToken column already exists in sessions table`)
}
if (!tableDescription.lastRefreshTokenExpiresAt) {
logger.info(`${loggerPrefix} Adding lastRefreshTokenExpiresAt column to sessions table`)
await queryInterface.addColumn('sessions', 'lastRefreshTokenExpiresAt', {
type: queryInterface.sequelize.Sequelize.DataTypes.DATE,
allowNull: true
})
} else {
logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column already exists in sessions table`)
}
} else {
logger.info(`${loggerPrefix} sessions table does not exist`)
}
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This migration script removes the lastRefreshToken and lastRefreshTokenExpiresAt columns from the sessions table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
if (await queryInterface.tableExists('sessions')) {
const tableDescription = await queryInterface.describeTable('sessions')
if (tableDescription.lastRefreshToken) {
logger.info(`${loggerPrefix} Removing lastRefreshToken column from sessions table`)
await queryInterface.removeColumn('sessions', 'lastRefreshToken')
} else {
logger.info(`${loggerPrefix} lastRefreshToken column does not exist in sessions table`)
}
if (tableDescription.lastRefreshTokenExpiresAt) {
logger.info(`${loggerPrefix} Removing lastRefreshTokenExpiresAt column from sessions table`)
await queryInterface.removeColumn('sessions', 'lastRefreshTokenExpiresAt')
} else {
logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column does not exist in sessions table`)
}
} else {
logger.info(`${loggerPrefix} sessions table does not exist`)
}
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
module.exports = { up, down }
+12
View File
@@ -18,6 +18,10 @@ class Session extends Model {
this.userId this.userId
/** @type {Date} */ /** @type {Date} */
this.expiresAt this.expiresAt
/** @type {string} */
this.lastRefreshToken
/** @type {Date} */
this.lastRefreshTokenExpiresAt
// Expanded properties // Expanded properties
@@ -66,6 +70,14 @@ class Session extends Model {
expiresAt: { expiresAt: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false allowNull: false
},
lastRefreshToken: {
type: DataTypes.STRING,
allowNull: true
},
lastRefreshTokenExpiresAt: {
type: DataTypes.DATE,
allowNull: true
} }
}, },
{ {
+2 -10
View File
@@ -352,11 +352,7 @@ class User extends Model {
if (cachedUser) return cachedUser if (cachedUser) return cachedUser
const user = await this.findOne({ const user = await this.findOne({
where: { where: sequelize.where(sequelize.fn('lower', sequelize.col('username')), username.toLowerCase()),
username: {
[sequelize.Op.like]: username
}
},
include: this.sequelize.models.mediaProgress include: this.sequelize.models.mediaProgress
}) })
@@ -377,11 +373,7 @@ class User extends Model {
if (cachedUser) return cachedUser if (cachedUser) return cachedUser
const user = await this.findOne({ const user = await this.findOne({
where: { where: sequelize.where(sequelize.fn('lower', sequelize.col('email')), email.toLowerCase()),
email: {
[sequelize.Op.like]: email
}
},
include: this.sequelize.models.mediaProgress include: this.sequelize.models.mediaProgress
}) })
+6 -1
View File
@@ -96,7 +96,12 @@ class DeviceInfo {
this.clientVersion = stripAllTags(clientDeviceInfo?.clientVersion) || serverVersion this.clientVersion = stripAllTags(clientDeviceInfo?.clientVersion) || serverVersion
this.manufacturer = stripAllTags(clientDeviceInfo?.manufacturer) || null this.manufacturer = stripAllTags(clientDeviceInfo?.manufacturer) || null
this.model = stripAllTags(clientDeviceInfo?.model) || null this.model = stripAllTags(clientDeviceInfo?.model) || null
this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || null
if (typeof clientDeviceInfo?.sdkVersion === 'number') {
this.sdkVersion = clientDeviceInfo.sdkVersion.toString()
} else {
this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || null
}
this.clientName = stripAllTags(clientDeviceInfo?.clientName) || null this.clientName = stripAllTags(clientDeviceInfo?.clientName) || null
if (this.sdkVersion) { if (this.sdkVersion) {
+15 -14
View File
@@ -5,15 +5,15 @@ const { LogLevel } = require('../utils/constants')
const abmetadataGenerator = require('../utils/generators/abmetadataGenerator') const abmetadataGenerator = require('../utils/generators/abmetadataGenerator')
class AbsMetadataFileScanner { class AbsMetadataFileScanner {
constructor() { } constructor() {}
/** /**
* Check for metadata.json file and set book metadata * Check for metadata.json file and set book metadata
* *
* @param {import('./LibraryScan')} libraryScan * @param {import('./LibraryScan')} libraryScan
* @param {import('./LibraryItemScanData')} libraryItemData * @param {import('./LibraryItemScanData')} libraryItemData
* @param {Object} bookMetadata * @param {Object} bookMetadata
* @param {string} [existingLibraryItemId] * @param {string} [existingLibraryItemId]
*/ */
async scanBookMetadataFile(libraryScan, libraryItemData, bookMetadata, existingLibraryItemId = null) { async scanBookMetadataFile(libraryScan, libraryItemData, bookMetadata, existingLibraryItemId = null) {
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile
@@ -32,7 +32,8 @@ class AbsMetadataFileScanner {
if (metadataText) { if (metadataText) {
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`) libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`)
const abMetadata = abmetadataGenerator.parseJson(metadataText) || {} const abMetadata = abmetadataGenerator.parseJson(metadataText, 'book') || {}
for (const key in abMetadata) { for (const key in abMetadata) {
// TODO: When to override with null or empty arrays? // TODO: When to override with null or empty arrays?
if (abMetadata[key] === undefined || abMetadata[key] === null) continue if (abMetadata[key] === undefined || abMetadata[key] === null) continue
@@ -48,11 +49,11 @@ class AbsMetadataFileScanner {
/** /**
* Check for metadata.json file and set podcast metadata * Check for metadata.json file and set podcast metadata
* *
* @param {import('./LibraryScan')} libraryScan * @param {import('./LibraryScan')} libraryScan
* @param {import('./LibraryItemScanData')} libraryItemData * @param {import('./LibraryItemScanData')} libraryItemData
* @param {Object} podcastMetadata * @param {Object} podcastMetadata
* @param {string} [existingLibraryItemId] * @param {string} [existingLibraryItemId]
*/ */
async scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId = null) { async scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId = null) {
const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile
@@ -71,7 +72,7 @@ class AbsMetadataFileScanner {
if (metadataText) { if (metadataText) {
libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`) libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`)
const abMetadata = abmetadataGenerator.parseJson(metadataText) || {} const abMetadata = abmetadataGenerator.parseJson(metadataText, 'podcast') || {}
for (const key in abMetadata) { for (const key in abMetadata) {
if (abMetadata[key] === undefined || abMetadata[key] === null) continue if (abMetadata[key] === undefined || abMetadata[key] === null) continue
if (key === 'tags' && !abMetadata.tags?.length) continue if (key === 'tags' && !abMetadata.tags?.length) continue
@@ -81,4 +82,4 @@ class AbsMetadataFileScanner {
} }
} }
} }
module.exports = new AbsMetadataFileScanner() module.exports = new AbsMetadataFileScanner()
+8
View File
@@ -7,6 +7,7 @@ const parseNameString = require('../utils/parsers/parseNameString')
const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata') const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
const globals = require('../utils/globals') const globals = require('../utils/globals')
const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils') const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
const htmlSanitizer = require('../utils/htmlSanitizer')
const AudioFileScanner = require('./AudioFileScanner') const AudioFileScanner = require('./AudioFileScanner')
const Database = require('../Database') const Database = require('../Database')
@@ -688,6 +689,10 @@ class BookScanner {
bookMetadata.titleIgnorePrefix = getTitleIgnorePrefix(bookMetadata.title) bookMetadata.titleIgnorePrefix = getTitleIgnorePrefix(bookMetadata.title)
if (typeof bookMetadata.description === 'string' && bookMetadata.description) {
bookMetadata.description = htmlSanitizer.sanitize(bookMetadata.description)
}
return bookMetadata return bookMetadata
} }
@@ -820,6 +825,9 @@ class BookScanner {
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
/**
* Keys must match abmetadataGenerator.js
*/
const jsonObject = { const jsonObject = {
tags: libraryItem.media.tags || [], tags: libraryItem.media.tags || [],
chapters: libraryItem.media.chapters?.map((c) => ({ ...c })) || [], chapters: libraryItem.media.chapters?.map((c) => ({ ...c })) || [],
+8
View File
@@ -11,6 +11,7 @@ const LibraryFile = require('../objects/files/LibraryFile')
const fsExtra = require('../libs/fsExtra') const fsExtra = require('../libs/fsExtra')
const PodcastEpisode = require('../models/PodcastEpisode') const PodcastEpisode = require('../models/PodcastEpisode')
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner') const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
const htmlSanitizer = require('../utils/htmlSanitizer')
/** /**
* Metadata for podcasts pulled from files * Metadata for podcasts pulled from files
@@ -398,6 +399,10 @@ class PodcastScanner {
podcastMetadata.titleIgnorePrefix = getTitleIgnorePrefix(podcastMetadata.title) podcastMetadata.titleIgnorePrefix = getTitleIgnorePrefix(podcastMetadata.title)
if (typeof podcastMetadata.description === 'string' && podcastMetadata.description) {
podcastMetadata.description = htmlSanitizer.sanitize(podcastMetadata.description)
}
return podcastMetadata return podcastMetadata
} }
@@ -420,6 +425,9 @@ class PodcastScanner {
const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
/**
* Keys must match abmetadataGenerator.js
*/
const jsonObject = { const jsonObject = {
tags: libraryItem.media.tags || [], tags: libraryItem.media.tags || [],
title: libraryItem.media.title, title: libraryItem.media.title,
+124 -19
View File
@@ -1,7 +1,51 @@
const Logger = require('../../Logger') const Logger = require('../../Logger')
const parseSeriesString = require('../parsers/parseSeriesString') const parseSeriesString = require('../parsers/parseSeriesString')
function parseJsonMetadataText(text) { const mediaTypeKeys = {
book: {
tags: 'stringArray',
title: 'string',
subtitle: 'string',
authors: 'stringArray',
narrators: 'stringArray',
series: 'stringArray',
genres: 'stringArray',
publishedYear: 'string',
publishedDate: 'string',
publisher: 'string',
description: 'string',
isbn: 'string',
asin: 'string',
language: 'string',
explicit: 'boolean',
abridged: 'boolean'
},
podcast: {
tags: 'stringArray',
title: 'string',
author: 'string',
description: 'string',
releaseDate: 'string',
genres: 'stringArray',
feedURL: 'string',
imageURL: 'string',
itunesPageURL: 'string',
itunesId: 'string',
itunesArtistId: 'string',
asin: 'string',
language: 'string',
explicit: 'boolean',
podcastType: 'string'
}
}
/**
*
* @param {string} text
* @param {"book" | "podcast"} mediaType
* @returns {Object}
*/
function parseJsonMetadataText(text, mediaType) {
try { try {
const abmetadataData = JSON.parse(text) const abmetadataData = JSON.parse(text)
@@ -19,28 +63,41 @@ function parseJsonMetadataText(text) {
} }
delete abmetadataData.metadata delete abmetadataData.metadata
if (abmetadataData.series?.length) { const expectedKeys = mediaTypeKeys[mediaType]
abmetadataData.series = [...new Set(abmetadataData.series.map((t) => t?.trim()).filter((t) => t))] if (!expectedKeys) {
abmetadataData.series = abmetadataData.series.map((series) => parseSeriesString.parse(series)) Logger.error(`[abmetadataGenerator] Invalid media type "${mediaType}"`)
return null
} }
// clean tags & remove dupes
if (abmetadataData.tags?.length) { const validated = {}
abmetadataData.tags = [...new Set(abmetadataData.tags.map((t) => t?.trim()).filter((t) => t))] for (const key in expectedKeys) {
const expectedType = expectedKeys[key]
if (!(key in abmetadataData)) continue
const validatedValue = validateMetadataValue(key, abmetadataData[key], expectedType)
if (validatedValue !== undefined) {
validated[key] = validatedValue
}
} }
if (abmetadataData.chapters?.length) {
abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.title) if (validated.series?.length) {
validated.series = validated.series.map((series) => parseSeriesString.parse(series)).filter(Boolean)
} }
// clean remove dupes
if (abmetadataData.authors?.length) { if (mediaType === 'book' && 'chapters' in abmetadataData) {
abmetadataData.authors = [...new Set(abmetadataData.authors.map((t) => t?.trim()).filter((t) => t))] if (abmetadataData.chapters === null) {
validated.chapters = []
} else if (Array.isArray(abmetadataData.chapters)) {
const cleanedChapters = cleanChaptersArray(abmetadataData.chapters, validated.title ?? abmetadataData.title)
if (cleanedChapters) {
validated.chapters = cleanedChapters
}
} else {
Logger.warn(`[abmetadataGenerator] Invalid metadata key "chapters" expected array, got ${typeof abmetadataData.chapters}`)
}
} }
if (abmetadataData.narrators?.length) {
abmetadataData.narrators = [...new Set(abmetadataData.narrators.map((t) => t?.trim()).filter((t) => t))] return validated
}
if (abmetadataData.genres?.length) {
abmetadataData.genres = [...new Set(abmetadataData.genres.map((t) => t?.trim()).filter((t) => t))]
}
return abmetadataData
} catch (error) { } catch (error) {
Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error) Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error)
return null return null
@@ -48,6 +105,54 @@ function parseJsonMetadataText(text) {
} }
module.exports.parseJson = parseJsonMetadataText module.exports.parseJson = parseJsonMetadataText
/**
* @param {string} key
* @param {*} value
* @param {string} expectedType
* @returns {*|undefined} undefined excludes the key
*/
function validateMetadataValue(key, value, expectedType) {
if (expectedType === 'string') {
if (value === null) return null
if (typeof value === 'number') return String(value)
if (typeof value === 'string') return value
Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected string, got ${typeof value}`)
return undefined
}
if (expectedType === 'boolean') {
if (value === null) return null
if (typeof value === 'boolean') return value
if (typeof value === 'string') {
const lower = value.toLowerCase()
if (lower === 'true') return true
if (lower === 'false') return false
}
Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected boolean, got ${typeof value}`)
return undefined
}
// Filter empty strings and deduplicate
if (expectedType === 'stringArray') {
if (value === null) return []
if (!Array.isArray(value)) {
Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected string array, got ${typeof value}`)
return undefined
}
const cleanedArray = value.filter((t) => typeof t === 'string')
return [...new Set(cleanedArray.map((t) => t.trim()).filter((t) => t))]
}
Logger.warn(`[abmetadataGenerator] Unknown expected type "${expectedType}" for key "${key}"`)
return undefined
}
/**
* @param {Object[]} chaptersArray
* @param {string} mediaTitle
* @returns {Object[]}
*/
function cleanChaptersArray(chaptersArray, mediaTitle) { function cleanChaptersArray(chaptersArray, mediaTitle) {
const chapters = [] const chapters = []
let index = 0 let index = 0