Compare commits

..

No commits in common. "master" and "v2.34.0" have entirely different histories.

18 changed files with 35 additions and 398 deletions

View file

@ -1,12 +1,12 @@
{
"name": "audiobookshelf-client",
"version": "2.35.0",
"version": "2.34.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf-client",
"version": "2.35.0",
"version": "2.34.0",
"license": "ISC",
"dependencies": {
"@nuxtjs/axios": "^5.13.6",

View file

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "2.35.0",
"version": "2.34.0",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast client",
"main": "index.js",

View file

@ -244,8 +244,6 @@
"LabelAlreadyInYourLibrary": "موجود بالفعل في مكتبتك",
"LabelApiKeyCreated": "تم إنشاء مفتاح API \"{0}\" بنجاح.",
"LabelApiKeyCreatedDescription": "تأكد من نسخ مفتاح API الآن، لن تتمكن من رؤيته مرة أخرى.",
"LabelApiKeyUser": "التصرف بالنيابة عن مستخدم",
"LabelApiKeyUserDescription": "مفتاح API سيمتلك نفس صلاحيات المستخدم الذي ينوب عنه ، سيظهر بالسجلات وكأن المستخدم قام بالطلب.",
"LabelApiToken": "رمز API",
"LabelAppend": "إلحاق",
"LabelAudioBitrate": "معدل بت الصوت (على سبيل المثال 128 كيلو بايت)",
@ -295,7 +293,6 @@
"LabelContinueListening": "استمرار الاستماع",
"LabelContinueReading": "استمرار القراءة",
"LabelContinueSeries": "استمرار المسلسلات",
"LabelCorsAllowed": "CORS Origins مسموح",
"LabelCover": "الغلاف",
"LabelCoverImageURL": "رابط صورة الغلاف",
"LabelCoverProvider": "مزود الغلاف",
@ -429,9 +426,6 @@
"LabelLibraryFilterSublistEmpty": "لا يوجد {0}",
"LabelLibraryItem": "عنصر المكتبة",
"LabelLibraryName": "اسم المكتبة",
"LabelLibrarySortByProgress": "المرحلة: الأحدث",
"LabelLibrarySortByProgressFinished": "المرحلة: تم الانتهاء",
"LabelLibrarySortByProgressStarted": "المرحلة: تم البدء",
"LabelLimit": "حد",
"LabelLineSpacing": "تباعد الأسطر",
"LabelListenAgain": "الاستماع مجدداً",

View file

@ -88,7 +88,7 @@
"ButtonResetToDefault": "Скінуць да прадвызначаных",
"ButtonRestore": "Аднавіць",
"ButtonSave": "Захаваць",
"ButtonSaveAndClose": "Захаваць і закрыць",
"ButtonSaveAndClose": "Захаваць і зачыніць",
"ButtonSaveTracklist": "Захаваць спіс трэкаў",
"ButtonScan": "Сканаваць",
"ButtonScanLibrary": "Сканіраваць бібліятэку",
@ -284,7 +284,7 @@
"LabelChaptersFound": "раздзелаў знойдзена",
"LabelClickForMoreInfo": "Націсніце для больш падрабязнай інфармацыі",
"LabelClickToUseCurrentValue": "Націсніце, каб выкарыстоўваць бягучае значэнне",
"LabelClosePlayer": "Закрыць прайгравальнік",
"LabelClosePlayer": "Зачыніць прайгравальнік",
"LabelCodec": "Кодэк",
"LabelCollapseSeries": "Згарнуць серыі",
"LabelCollapseSubSeries": "Згарнуць падсерыі",

View file

@ -752,7 +752,7 @@
"MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове",
"MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"",
"MessageBookshelfNoResultsForQuery": "Няма резултати от заявката",
"MessageBookshelfNoSeries": "Нямате поредица",
"MessageBookshelfNoSeries": "Нямаш сеЗЙ",
"MessageBulkChapterPattern": "Колко глави искате да добавите, използвайки тази схема за номериране?",
"MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига",
"MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0",
@ -1018,50 +1018,18 @@
"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": "Неуспешно изтриване на библиотека",
@ -1069,97 +1037,28 @@
"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": "Потребителят е изтрит",
"ToastUserPasswordChangeSuccess": "Паролата е променена успешно",
"ToastUserPasswordMismatch": "Паролите не съвпадат",
"ToastUserPasswordMustChange": "Новата парола не може да бъде същата като старата",
"ToastUserRootRequireName": "Трябва да въведете root потребителско име",
"TooltipAddChapters": "Добавяне на глава(и)",
"TooltipAddOneSecond": "Добавяне на 1 секунда",
"TooltipAdjustChapterStart": "Кликнете за коригиране на началния час",
"TooltipLockAllChapters": "Заключване на всички глави",
"TooltipLockChapter": "Заключване на глава (Shift+клик за диапазон)",
"TooltipSubtractOneSecond": "Изваждане на 1 секунда",
"TooltipUnlockAllChapters": "Отключване на всички глави",
"TooltipUnlockChapter": "Отключване на глава (Shift+клик за диапазон)"
"ToastUserDeleteSuccess": "Потребителят е изтрит"
}

View file

@ -1 +0,0 @@
{}

View file

@ -951,11 +951,6 @@
"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",
@ -965,7 +960,6 @@
"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",
@ -982,7 +976,6 @@
"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",
@ -1001,11 +994,8 @@
"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",
@ -1043,14 +1033,7 @@
"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ę",
@ -1061,7 +1044,6 @@
"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",
@ -1070,10 +1052,6 @@
"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",
@ -1087,15 +1065,7 @@
"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",
@ -1103,17 +1073,8 @@
"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",
@ -1135,25 +1096,16 @@
"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",

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "audiobookshelf",
"version": "2.35.0",
"version": "2.34.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "audiobookshelf",
"version": "2.35.0",
"version": "2.34.0",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",

View file

@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.35.0",
"version": "2.34.0",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",

View file

@ -3,7 +3,6 @@ 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
@ -86,14 +85,6 @@ 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()
@ -188,25 +179,14 @@ class SocketAuthority {
socket.on('auth', (token) => this.authenticateSocket(socket, token))
// Scanning
socket.on('cancel_scan', (libraryId) => {
if (!this.requireAdminSocket(socket, 'cancel_scan')) return
this.cancelScan(libraryId)
})
socket.on('cancel_scan', (libraryId) => 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) => {
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('set_log_listener', (level) => Logger.addSocketListener(socket, level))
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
// Sent automatically from socket.io clients

View file

@ -1,5 +1,4 @@
const { Op } = require('sequelize')
const uuid = require('uuid')
const Database = require('../Database')
const Logger = require('../Logger')
@ -116,7 +115,6 @@ class TokenManager {
const payload = {
userId: user.id,
username: user.username,
jti: uuid.v4(),
type: 'access'
}
const options = {
@ -140,7 +138,6 @@ class TokenManager {
const payload = {
userId: user.id,
username: user.username,
jti: uuid.v4(),
type: 'refresh'
}
const options = {
@ -186,56 +183,20 @@ 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, gracePeriod = true) {
const previousRefreshToken = session.refreshToken
async rotateTokensForSession(session, user, req, res) {
// Generate new tokens
const newAccessToken = this.generateTempAccessToken(user)
let newRefreshToken = this.generateRefreshToken(user)
const newRefreshToken = this.generateRefreshToken(user)
// Calculate new expiration time
const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
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
}
// Update the session with the new refresh token and expiration
session.refreshToken = newRefreshToken
session.expiresAt = newExpiresAt
await session.save()
// Set new refresh token cookie
this.setRefreshTokenCookie(req, res, newRefreshToken)
@ -333,40 +294,23 @@ class TokenManager {
}
}
let session = await Database.sessionModel.findOne({
where: {
[Op.or]: [{ refreshToken: refreshToken }, { lastRefreshToken: refreshToken }]
}
const session = await Database.sessionModel.findOne({
where: { refreshToken: refreshToken }
})
if (!session) {
Logger.error(`[TokenManager] Failed to refresh token. Session not found`)
Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`)
return {
error: 'Invalid refresh token'
}
}
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'
}
// 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'
}
}
@ -378,20 +322,6 @@ 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,
@ -445,7 +375,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, false)
const newTokens = await this.rotateTokensForSession(currentSession, user, req, res)
// Invalidate all sessions for the user except the current one
await Database.sessionModel.destroy({
@ -459,7 +389,7 @@ class TokenManager {
return newTokens.accessToken
} else {
Logger.error(`[TokenManager] No session found to rotate tokens`)
Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`)
}
}
@ -483,7 +413,7 @@ class TokenManager {
try {
const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } })
Logger.info(`[TokenManager] Refresh token invalidated, ${numDeleted} sessions deleted`)
Logger.info(`[TokenManager] Refresh token ${refreshToken} invalidated, ${numDeleted} sessions deleted`)
return true
} catch (error) {
Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`)

View file

@ -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, { ignoreDuplicates: true }) // Create all new unique BookAuthor
await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate) // Create all new BookAuthor
for (const libraryItem of libraryItems) {
await libraryItem.saveMetadataFile()
}

View file

@ -2,7 +2,6 @@ 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')
@ -217,11 +216,6 @@ 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)
}

View file

@ -1,84 +0,0 @@
/**
* @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 }

View file

@ -18,10 +18,6 @@ class Session extends Model {
this.userId
/** @type {Date} */
this.expiresAt
/** @type {string} */
this.lastRefreshToken
/** @type {Date} */
this.lastRefreshTokenExpiresAt
// Expanded properties
@ -70,14 +66,6 @@ class Session extends Model {
expiresAt: {
type: DataTypes.DATE,
allowNull: false
},
lastRefreshToken: {
type: DataTypes.STRING,
allowNull: true
},
lastRefreshTokenExpiresAt: {
type: DataTypes.DATE,
allowNull: true
}
},
{

View file

@ -96,12 +96,7 @@ class DeviceInfo {
this.clientVersion = stripAllTags(clientDeviceInfo?.clientVersion) || serverVersion
this.manufacturer = stripAllTags(clientDeviceInfo?.manufacturer) || null
this.model = stripAllTags(clientDeviceInfo?.model) || null
if (typeof clientDeviceInfo?.sdkVersion === 'number') {
this.sdkVersion = clientDeviceInfo.sdkVersion.toString()
} else {
this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || null
}
this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || null
this.clientName = stripAllTags(clientDeviceInfo?.clientName) || null
if (this.sdkVersion) {

View file

@ -7,7 +7,6 @@ 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')
@ -689,10 +688,6 @@ class BookScanner {
bookMetadata.titleIgnorePrefix = getTitleIgnorePrefix(bookMetadata.title)
if (typeof bookMetadata.description === 'string' && bookMetadata.description) {
bookMetadata.description = htmlSanitizer.sanitize(bookMetadata.description)
}
return bookMetadata
}

View file

@ -11,7 +11,6 @@ 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
@ -399,10 +398,6 @@ class PodcastScanner {
podcastMetadata.titleIgnorePrefix = getTitleIgnorePrefix(podcastMetadata.title)
if (typeof podcastMetadata.description === 'string' && podcastMetadata.description) {
podcastMetadata.description = htmlSanitizer.sanitize(podcastMetadata.description)
}
return podcastMetadata
}