mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-04 18:00:45 +02:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aacdcc47ec | |||
| 499b52b4dd | |||
| 1bad2d9072 | |||
| c009db9f28 | |||
| 325469c5a5 | |||
| c97b36e11c | |||
| e944b2a2f5 | |||
| 2d0a5462d2 | |||
| 72dc75482f | |||
| cac74f3477 | |||
| 1ad11b2b9e | |||
| 50eeca2e0f | |||
| 4f21fc023c | |||
| 52a485d135 | |||
| 3b025076e8 | |||
| 6d5d89429d | |||
| c010f0e1eb | |||
| eee377e081 | |||
| b0aaa24660 | |||
| 47ea6b5092 | |||
| 4b060febc2 | |||
| 40869bcf39 | |||
| 3942805129 | |||
| dc446862c1 | |||
| 379f6c716a | |||
| cfeb6bd502 | |||
| 077b523bd6 | |||
| b8a2d113f0 | |||
| e1ae4f2d31 | |||
| 7aa2f84daa | |||
| da0a64daed |
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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": "الاستماع مجدداً",
|
||||||
|
|||||||
@@ -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
@@ -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+клик за диапазон)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
@@ -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",
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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}`)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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
@@ -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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 })) || [],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user