diff --git a/client/package-lock.json b/client/package-lock.json index 79ac53250..ba071852b 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.34.0", + "version": "2.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.34.0", + "version": "2.35.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index dd0f3a0c9..71d64f60a 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.34.0", + "version": "2.35.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/client/strings/ar.json b/client/strings/ar.json index a176b9d8b..dc5ef1762 100644 --- a/client/strings/ar.json +++ b/client/strings/ar.json @@ -244,6 +244,8 @@ "LabelAlreadyInYourLibrary": "موجود بالفعل في مكتبتك", "LabelApiKeyCreated": "تم إنشاء مفتاح API \"{0}\" بنجاح.", "LabelApiKeyCreatedDescription": "تأكد من نسخ مفتاح API الآن، لن تتمكن من رؤيته مرة أخرى.", + "LabelApiKeyUser": "التصرف بالنيابة عن مستخدم", + "LabelApiKeyUserDescription": "مفتاح API سيمتلك نفس صلاحيات المستخدم الذي ينوب عنه ، سيظهر بالسجلات وكأن المستخدم قام بالطلب.", "LabelApiToken": "رمز API", "LabelAppend": "إلحاق", "LabelAudioBitrate": "معدل بت الصوت (على سبيل المثال 128 كيلو بايت)", @@ -293,6 +295,7 @@ "LabelContinueListening": "استمرار الاستماع", "LabelContinueReading": "استمرار القراءة", "LabelContinueSeries": "استمرار المسلسلات", + "LabelCorsAllowed": "CORS Origins مسموح", "LabelCover": "الغلاف", "LabelCoverImageURL": "رابط صورة الغلاف", "LabelCoverProvider": "مزود الغلاف", @@ -426,6 +429,9 @@ "LabelLibraryFilterSublistEmpty": "لا يوجد {0}", "LabelLibraryItem": "عنصر المكتبة", "LabelLibraryName": "اسم المكتبة", + "LabelLibrarySortByProgress": "المرحلة: الأحدث", + "LabelLibrarySortByProgressFinished": "المرحلة: تم الانتهاء", + "LabelLibrarySortByProgressStarted": "المرحلة: تم البدء", "LabelLimit": "حد", "LabelLineSpacing": "تباعد الأسطر", "LabelListenAgain": "الاستماع مجدداً", diff --git a/client/strings/be.json b/client/strings/be.json index b4e4df86a..d3aa42cf1 100644 --- a/client/strings/be.json +++ b/client/strings/be.json @@ -88,7 +88,7 @@ "ButtonResetToDefault": "Скінуць да прадвызначаных", "ButtonRestore": "Аднавіць", "ButtonSave": "Захаваць", - "ButtonSaveAndClose": "Захаваць і зачыніць", + "ButtonSaveAndClose": "Захаваць і закрыць", "ButtonSaveTracklist": "Захаваць спіс трэкаў", "ButtonScan": "Сканаваць", "ButtonScanLibrary": "Сканіраваць бібліятэку", @@ -284,7 +284,7 @@ "LabelChaptersFound": "раздзелаў знойдзена", "LabelClickForMoreInfo": "Націсніце для больш падрабязнай інфармацыі", "LabelClickToUseCurrentValue": "Націсніце, каб выкарыстоўваць бягучае значэнне", - "LabelClosePlayer": "Зачыніць прайгравальнік", + "LabelClosePlayer": "Закрыць прайгравальнік", "LabelCodec": "Кодэк", "LabelCollapseSeries": "Згарнуць серыі", "LabelCollapseSubSeries": "Згарнуць падсерыі", diff --git a/client/strings/bg.json b/client/strings/bg.json index 460f0ff83..c34188a54 100644 --- a/client/strings/bg.json +++ b/client/strings/bg.json @@ -752,7 +752,7 @@ "MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове", "MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"", "MessageBookshelfNoResultsForQuery": "Няма резултати от заявката", - "MessageBookshelfNoSeries": "Нямаш сеЗЙ", + "MessageBookshelfNoSeries": "Нямате поредица", "MessageBulkChapterPattern": "Колко глави искате да добавите, използвайки тази схема за номериране?", "MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига", "MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0", @@ -1018,18 +1018,50 @@ "ToastChapterStartTimeAdjusted": "Начално време на главате е настоено с {0} секунди", "ToastChaptersAllLocked": "Всички глави са заключени. Оключете някой глави за да преместите техните времена.", "ToastChaptersHaveErrors": "Главите имат грешки", + "ToastChaptersInvalidShiftAmountLast": "Невалидно време за преместване. Началният час на последната глава ще превиши общата продължителност на аудиокнигата.", + "ToastChaptersInvalidShiftAmountStart": "Невалидно време за преместване. Първата глава ще има нулева или отрицателна дължина и ще бъде презаписана от втората глава. Увеличете началното време на втората глава.", "ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия", + "ToastChaptersRemoved": "Главите са премахнати", + "ToastChaptersUpdated": "Главите са актуализирани", + "ToastCollectionItemsAddFailed": "Неуспешно добавяне на елемент(и) към колекцията", "ToastCollectionRemoveSuccess": "Колекцията е премахната", "ToastCollectionUpdateSuccess": "Колекцията е обновена", + "ToastConnectionNotAvailable": "Няма връзка. Моля, опитайте отново по-късно", + "ToastCoverSearchFailed": "Търсенето на корица е неуспешно", + "ToastCoverUpdateFailed": "Обновяването на корицата е неуспешно", + "ToastDateTimeInvalidOrIncomplete": "Датата и часът са невалидни или непълни", "ToastDeleteFileFailed": "Неуспешно изтриване на файла", "ToastDeleteFileSuccess": "Успешно изтриване на файла", + "ToastDeviceAddFailed": "Неуспешно добавяне на устройство", + "ToastDeviceNameAlreadyExists": "Вече съществува четец с това име", + "ToastDeviceTestEmailFailed": "Неуспешно изпращане на тестов имейл", + "ToastDeviceTestEmailSuccess": "Тестовият имейл е изпратен", + "ToastEmailSettingsUpdateSuccess": "Имейл настройките са актуализирани", + "ToastEncodeCancelFailed": "Неуспешно отменяне на кодирането", + "ToastEncodeCancelSucces": "Кодирането е отменено", + "ToastEpisodeDownloadQueueClearFailed": "Неуспешно изчистване на опашката", + "ToastEpisodeDownloadQueueClearSuccess": "Опашката за изтегляне на епизоди е изчистена", + "ToastEpisodeUpdateSuccess": "{0} епизода са актуализирани", + "ToastErrorCannotShare": "Не може да се споделя директно от това устройство", + "ToastFailedToCreate": "Неуспешно създаване", + "ToastFailedToDelete": "Неуспешно изтриване", "ToastFailedToLoadData": "Неуспешно зареждане на данни", + "ToastFailedToMatch": "Неуспешно съвпадение", + "ToastFailedToShare": "Неуспешно споделяне", + "ToastFailedToUpdate": "Неуспешно актуализиране", + "ToastInvalidImageUrl": "Невалиден URL адрес на изображение", + "ToastInvalidMaxEpisodesToDownload": "Невалиден максимален брой епизоди за изтегляне", + "ToastInvalidUrl": "Невалиден URL адрес", + "ToastInvalidUrls": "Един или повече URL адреси са невалидни", "ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена", + "ToastItemDeletedFailed": "Неуспешно изтриване на елемента", + "ToastItemDeletedSuccess": "Елементът е изтрит", "ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени", "ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като Завършено", "ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен", "ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като Незавършено", "ToastItemMarkedAsNotFinishedSuccess": "Елементът е маркиран като незавършен", + "ToastItemUpdateSuccess": "Елементът е актуализиран", "ToastLibraryCreateFailed": "Неуспешно създаване на библиотека", "ToastLibraryCreateSuccess": "Библиотеката \"{0}\" е създадена", "ToastLibraryDeleteFailed": "Неуспешно изтриване на библиотека", @@ -1037,28 +1069,97 @@ "ToastLibraryScanFailedToStart": "Неуспешно стартиране на сканиране", "ToastLibraryScanStarted": "Сканирането на библиотеката е стартирано", "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": "Неуспешно създаване на плейлист", "ToastPlaylistCreateSuccess": "Плейлистът е създаден", "ToastPlaylistRemoveSuccess": "Плейлистът е премахнат", "ToastPlaylistUpdateSuccess": "Плейлистът е обновен", "ToastPodcastCreateFailed": "Неуспешно създаване на подкаст", "ToastPodcastCreateSuccess": "Подкаст успешно създаден", + "ToastPodcastEpisodeUpdated": "Епизодът е актуализиран", + "ToastPodcastGetFeedFailed": "Неуспешно извличане на емисията на подкаста", + "ToastPodcastNoEpisodesInFeed": "Не са намерени епизоди в RSS емисията", + "ToastPodcastNoRssFeed": "Подкастът няма RSS емисия", + "ToastProgressIsNotBeingSynced": "Напредъкът не се синхронизира, рестартирайте възпроизвеждането", + "ToastProviderCreatedFailed": "Неуспешно добавяне на доставчик", + "ToastProviderCreatedSuccess": "Добавен е нов доставчик", + "ToastProviderNameAndUrlRequired": "Изискват се име и URL адрес", + "ToastProviderRemoveSuccess": "Доставчикът е премахнат", "ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията", "ToastRSSFeedCloseSuccess": "RSS емисията е затворена", + "ToastRemoveFailed": "Неуспешно премахване", "ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция", "ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция", + "ToastRemoveItemsWithIssuesFailed": "Неуспешно премахване на елементите от библиотеката с проблеми", + "ToastRemoveItemsWithIssuesSuccess": "Елементите от библиотеката с проблеми са премахнати", + "ToastRenameFailed": "Неуспешно преименуване", + "ToastRescanFailed": "Повторното сканиране е неуспешно за {0}", + "ToastRescanRemoved": "Повторното сканиране завърши: елементът е премахнат", + "ToastRescanUpToDate": "Повторното сканиране завърши: елементът вече е актуален", + "ToastRescanUpdated": "Повторното сканиране завърши: елементът е актуализиран", + "ToastScanFailed": "Неуспешно сканиране на елемент от библиотеката", + "ToastSelectAtLeastOneUser": "Изберете поне един потребител", "ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство", "ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"", + "ToastSeriesSubmitFailedSameName": "Не могат да бъдат добавени два сериала с едно и също име", "ToastSeriesUpdateFailed": "Неуспешно обновяване на серия", "ToastSeriesUpdateSuccess": "Серията е обновена", "ToastServerSettingsUpdateSuccess": "Настройките на сървъра са актуализирани", + "ToastSessionCloseFailed": "Неуспешно затваряне на сесията", "ToastSessionDeleteFailed": "Неуспешно изтриване на сесия", "ToastSessionDeleteSuccess": "Сесията е изтрита", + "ToastSleepTimerDone": "Таймерът за заспиване приключи... zZzzZz", + "ToastSlugMustChange": "Краткият URL (slug) съдържа невалидни символи", + "ToastSlugRequired": "Изисква се кратък URL (slug)", "ToastSocketConnected": "Свързан сокет", "ToastSocketDisconnected": "Сокетът е прекъснат", "ToastSocketFailedToConnect": "Неуспешно свързване на сокет", "ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране", "ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)", + "ToastTitleRequired": "Изисква се заглавие", + "ToastUnknownError": "Неизвестна грешка", + "ToastUnlinkOpenIdFailed": "Неуспешно прекъсване на връзката на потребителя с OpenID", + "ToastUnlinkOpenIdSuccess": "Връзката на потребителя с OpenID е прекъсната", + "ToastUploaderFilepathExistsError": "Файловият път „{0}“ вече съществува на сървъра", + "ToastUploaderItemExistsInSubdirectoryError": "Елементът „{0}“ използва поддиректория на пътя за качване.", "ToastUserDeleteFailed": "Неуспешно изтриване на потребител", - "ToastUserDeleteSuccess": "Потребителят е изтрит" + "ToastUserDeleteSuccess": "Потребителят е изтрит", + "ToastUserPasswordChangeSuccess": "Паролата е променена успешно", + "ToastUserPasswordMismatch": "Паролите не съвпадат", + "ToastUserPasswordMustChange": "Новата парола не може да бъде същата като старата", + "ToastUserRootRequireName": "Трябва да въведете root потребителско име", + "TooltipAddChapters": "Добавяне на глава(и)", + "TooltipAddOneSecond": "Добавяне на 1 секунда", + "TooltipAdjustChapterStart": "Кликнете за коригиране на началния час", + "TooltipLockAllChapters": "Заключване на всички глави", + "TooltipLockChapter": "Заключване на глава (Shift+клик за диапазон)", + "TooltipSubtractOneSecond": "Изваждане на 1 секунда", + "TooltipUnlockAllChapters": "Отключване на всички глави", + "TooltipUnlockChapter": "Отключване на глава (Shift+клик за диапазон)" } diff --git a/client/strings/lv.json b/client/strings/lv.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/client/strings/lv.json @@ -0,0 +1 @@ +{} diff --git a/client/strings/pl.json b/client/strings/pl.json index d1bc6c062..f04e61c46 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -951,6 +951,11 @@ "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.", "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ń", "PlaceholderBulkChapterInput": "Wpisz tytuł rozdziału lub użyj numeracji (np. „Odcinek 1”, „Rozdział 10”, „1.”)", "PlaceholderNewCollection": "Nowa nazwa kolekcji", @@ -960,6 +965,7 @@ "PlaceholderSearchEpisode": "Szukanie odcinka..", "StatsAuthorsAdded": "dodano autorów", "StatsBooksAdded": "dodano książki", + "StatsBooksAdditional": "Niektóre dodatki obejmują…", "StatsBooksFinished": "ukończone książki", "StatsBooksFinishedThisYear": "Wybrane książki ukończone w tym roku…", "StatsBooksListenedTo": "książki wysłuchane", @@ -976,6 +982,7 @@ "StatsTotalDuration": "O sumarycznej długości…", "StatsYearInReview": "PRZEGLĄD ROKU", "ToastAccountUpdateSuccess": "Zaktualizowano konto", + "ToastAppriseUrlRequired": "Należy wprowadzić adres URL Apprise", "ToastAsinRequired": "ASIN jest wymagany", "ToastAuthorImageRemoveSuccess": "Zdjęcie autora usunięte", "ToastAuthorNotFound": "Autor \"{0}\" nie został znaleziony", @@ -994,8 +1001,11 @@ "ToastBackupRestoreFailed": "Nie udało się przywrócić kopii zapasowej", "ToastBackupUploadFailed": "Nie udało się przesłać kopii zapasowej", "ToastBackupUploadSuccess": "Kopia zapasowa została przesłana", + "ToastBatchApplyDetailsToItemsSuccess": "Szczegóły zastosowane do elementów", "ToastBatchDeleteFailed": "Usuwanie zbiorcze nie 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ę", "ToastBatchUpdateSuccess": "Aktualizacja zbiorcza powiodła się", "ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki", @@ -1033,7 +1043,14 @@ "ToastEpisodeDownloadQueueClearSuccess": "Wyczyszczono kolejkę epizodów do ściągnięcia", "ToastEpisodeUpdateSuccess": "Zaktualizowano {0} odcinków", "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", + "ToastInvalidMaxEpisodesToDownload": "Nieprawidłowa maksymalna liczba odcinków do pobrania", "ToastInvalidUrl": "Nieprawidłowy URL", "ToastInvalidUrls": "Jeden lub więcej URL-i są nieprawidłowe", "ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę", @@ -1044,6 +1061,7 @@ "ToastItemMarkedAsFinishedSuccess": "Pozycja oznaczona jako ukończona", "ToastItemMarkedAsNotFinishedFailed": "Oznaczenie pozycji jako ukończonej nie powiodło się", "ToastItemMarkedAsNotFinishedSuccess": "Pozycja oznaczona jako nieukończona", + "ToastItemUpdateSuccess": "Element zaktualizowany", "ToastLibraryCreateFailed": "Nie udało się utworzyć biblioteki", "ToastLibraryCreateSuccess": "Biblioteka \"{0}\" stworzona", "ToastLibraryDeleteFailed": "Nie udało się usunąć biblioteki", @@ -1052,6 +1070,10 @@ "ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki", "ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji", "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ę", "ToastNameEmailRequired": "Nazwa i email są wymagane", "ToastNameRequired": "Imię jest wymagane", @@ -1065,7 +1087,15 @@ "ToastNewUserUsernameError": "Wprowadź nazwę użytkownika", "ToastNoNewEpisodesFound": "Nie znaleziono nowych odcinków", "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", + "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", "ToastPlaylistCreateSuccess": "Playlista utworzona", "ToastPlaylistRemoveSuccess": "Playlista usunięta", @@ -1073,8 +1103,17 @@ "ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu", "ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony", "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ę", "ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS powiodło się", + "ToastRemoveFailed": "Nie udało się usunąć", "ToastRemoveItemFromCollectionFailed": "Nie udało się usunąć elementu z kolekcji", "ToastRemoveItemFromCollectionSuccess": "Pozycja usunięta z kolekcji", "ToastRemoveItemsWithIssuesFailed": "Nie udało się usunąć wadliwych elementów z biblioteki", @@ -1096,16 +1135,25 @@ "ToastSessionDeleteFailed": "Nie udało się usunąć sesji", "ToastSessionDeleteSuccess": "Sesja usunięta", "ToastSleepTimerDone": "Słodkich snów... zZzzZz", + "ToastSlugMustChange": "Slug zawiera nieprawidłowe znaki", + "ToastSlugRequired": "Slug jest wymagany", "ToastSocketConnected": "Nawiązano połączenie z serwerem", "ToastSocketDisconnected": "Połączenie z serwerem zostało zamknięte", "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", "ToastUnknownError": "Nieznany błąd", "ToastUnlinkOpenIdFailed": "Nie udało się odpiąć użytkownika z OpenID", "ToastUnlinkOpenIdSuccess": "Użytkownik odpięty z OpenID", "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", "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)", "TooltipAddOneSecond": "Dodaj sekundę", "TooltipAdjustChapterStart": "Kliknij, aby skorygować czas początkowy", diff --git a/package-lock.json b/package-lock.json index 8950b4903..312675d71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.34.0", + "version": "2.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.34.0", + "version": "2.35.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 10ba26f65..a0b340244 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.34.0", + "version": "2.35.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index ad55f6605..a54231d75 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -3,6 +3,7 @@ const Logger = require('./Logger') const Database = require('./Database') const TokenManager = require('./auth/TokenManager') const CoverSearchManager = require('./managers/CoverSearchManager') +const { LogLevel } = require('./utils/constants') /** * @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 * Note: Emits toOldJSONExpanded() @@ -179,14 +188,25 @@ class SocketAuthority { socket.on('auth', (token) => this.authenticateSocket(socket, token)) // 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 socket.on('search_covers', (payload) => this.handleCoverSearch(socket, payload)) socket.on('cancel_cover_search', (requestId) => this.handleCancelCoverSearch(socket, requestId)) // 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)) // Sent automatically from socket.io clients diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index 5efeb7a64..5933209c7 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -1,4 +1,5 @@ const { Op } = require('sequelize') +const uuid = require('uuid') const Database = require('../Database') const Logger = require('../Logger') @@ -115,6 +116,7 @@ class TokenManager { const payload = { userId: user.id, username: user.username, + jti: uuid.v4(), type: 'access' } const options = { @@ -138,6 +140,7 @@ class TokenManager { const payload = { userId: user.id, username: user.username, + jti: uuid.v4(), type: 'refresh' } const options = { @@ -183,20 +186,56 @@ class TokenManager { * @param {import('../models/User')} user * @param {import('express').Request} req * @param {import('express').Response} res + * @param {boolean} gracePeriod - whether to use the grace period * @returns {Promise<{ accessToken:string, refreshToken:string }>} */ - async rotateTokensForSession(session, user, req, res) { - // Generate new tokens + async rotateTokensForSession(session, user, req, res, gracePeriod = true) { + const previousRefreshToken = session.refreshToken const newAccessToken = this.generateTempAccessToken(user) - const newRefreshToken = this.generateRefreshToken(user) - - // Calculate new expiration time + let newRefreshToken = this.generateRefreshToken(user) const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) - // Update the session with the new refresh token and expiration - session.refreshToken = newRefreshToken - session.expiresAt = newExpiresAt - await session.save() + let lastRefreshToken = null + let lastRefreshTokenExpiresAt = null + if (gracePeriod) { + // 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 this.setRefreshTokenCookie(req, res, newRefreshToken) @@ -294,23 +333,40 @@ class TokenManager { } } - const session = await Database.sessionModel.findOne({ - where: { refreshToken: refreshToken } + let session = await Database.sessionModel.findOne({ + where: { + [Op.or]: [{ refreshToken: refreshToken }, { lastRefreshToken: refreshToken }] + } }) 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 { error: 'Invalid refresh token' } } - // Check if session is expired in database - if (session.expiresAt < new Date()) { - Logger.info(`[TokenManager] Session expired in database, cleaning up`) - await session.destroy() - return { - error: 'Refresh token expired' + let isGracePeriod = false + if (session.refreshToken !== refreshToken) { + // Token matched lastRefreshToken + if (session.lastRefreshTokenExpiresAt && session.lastRefreshTokenExpiresAt > new Date()) { + isGracePeriod = true + 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) return { accessToken: newTokens.accessToken, @@ -375,7 +445,7 @@ class TokenManager { // So rotate token for current session const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } }) if (currentSession) { - const newTokens = await this.rotateTokensForSession(currentSession, user, req, res) + const newTokens = await this.rotateTokensForSession(currentSession, user, req, res, false) // Invalidate all sessions for the user except the current one await Database.sessionModel.destroy({ @@ -389,7 +459,7 @@ class TokenManager { return newTokens.accessToken } 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 { 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 } catch (error) { Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`) diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js index 80471ec47..8c2e80aec 100644 --- a/server/controllers/AuthorController.js +++ b/server/controllers/AuthorController.js @@ -149,7 +149,7 @@ class AuthorController { }) if (libraryItems.length) { 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) { await libraryItem.saveMetadataFile() } diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index c4681bdc2..a066a0d32 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -2,6 +2,7 @@ const { Request, Response } = require('express') const Path = require('path') const Logger = require('../Logger') +const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') @@ -216,6 +217,11 @@ class RssFeedManager { res.sendStatus(404) 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) } diff --git a/server/migrations/v2.35.0-add-last-refresh-token.js b/server/migrations/v2.35.0-add-last-refresh-token.js new file mode 100644 index 000000000..0ad190e9a --- /dev/null +++ b/server/migrations/v2.35.0-add-last-refresh-token.js @@ -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} - 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} - 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 } diff --git a/server/models/Session.js b/server/models/Session.js index fe9dd5425..3b85bd46a 100644 --- a/server/models/Session.js +++ b/server/models/Session.js @@ -18,6 +18,10 @@ class Session extends Model { this.userId /** @type {Date} */ this.expiresAt + /** @type {string} */ + this.lastRefreshToken + /** @type {Date} */ + this.lastRefreshTokenExpiresAt // Expanded properties @@ -66,6 +70,14 @@ class Session extends Model { expiresAt: { type: DataTypes.DATE, allowNull: false + }, + lastRefreshToken: { + type: DataTypes.STRING, + allowNull: true + }, + lastRefreshTokenExpiresAt: { + type: DataTypes.DATE, + allowNull: true } }, { diff --git a/server/objects/DeviceInfo.js b/server/objects/DeviceInfo.js index 22ebfbea4..68237f0fb 100644 --- a/server/objects/DeviceInfo.js +++ b/server/objects/DeviceInfo.js @@ -96,7 +96,12 @@ class DeviceInfo { this.clientVersion = stripAllTags(clientDeviceInfo?.clientVersion) || serverVersion this.manufacturer = stripAllTags(clientDeviceInfo?.manufacturer) || 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 if (this.sdkVersion) { diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index a1e7ff507..ac93c6379 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -7,6 +7,7 @@ const parseNameString = require('../utils/parsers/parseNameString') const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata') const globals = require('../utils/globals') const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils') +const htmlSanitizer = require('../utils/htmlSanitizer') const AudioFileScanner = require('./AudioFileScanner') const Database = require('../Database') @@ -688,6 +689,10 @@ class BookScanner { bookMetadata.titleIgnorePrefix = getTitleIgnorePrefix(bookMetadata.title) + if (typeof bookMetadata.description === 'string' && bookMetadata.description) { + bookMetadata.description = htmlSanitizer.sanitize(bookMetadata.description) + } + return bookMetadata } diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js index c9569c3ad..6ab2d332c 100644 --- a/server/scanner/PodcastScanner.js +++ b/server/scanner/PodcastScanner.js @@ -11,6 +11,7 @@ const LibraryFile = require('../objects/files/LibraryFile') const fsExtra = require('../libs/fsExtra') const PodcastEpisode = require('../models/PodcastEpisode') const AbsMetadataFileScanner = require('./AbsMetadataFileScanner') +const htmlSanitizer = require('../utils/htmlSanitizer') /** * Metadata for podcasts pulled from files @@ -398,6 +399,10 @@ class PodcastScanner { podcastMetadata.titleIgnorePrefix = getTitleIgnorePrefix(podcastMetadata.title) + if (typeof podcastMetadata.description === 'string' && podcastMetadata.description) { + podcastMetadata.description = htmlSanitizer.sanitize(podcastMetadata.description) + } + return podcastMetadata }