mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-05-23 11:51:33 +00:00
Compare commits
28 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
18 changed files with 398 additions and 35 deletions
4
client/package-lock.json
generated
4
client/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.34.0",
|
"version": "2.35.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.34.0",
|
"version": "2.35.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.34.0",
|
"version": "2.35.0",
|
||||||
"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": "Згарнуць падсерыі",
|
||||||
|
|
|
||||||
|
|
@ -752,7 +752,7 @@
|
||||||
"MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове",
|
"MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове",
|
||||||
"MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"",
|
"MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"",
|
||||||
"MessageBookshelfNoResultsForQuery": "Няма резултати от заявката",
|
"MessageBookshelfNoResultsForQuery": "Няма резултати от заявката",
|
||||||
"MessageBookshelfNoSeries": "Нямаш сеЗЙ",
|
"MessageBookshelfNoSeries": "Нямате поредица",
|
||||||
"MessageBulkChapterPattern": "Колко глави искате да добавите, използвайки тази схема за номериране?",
|
"MessageBulkChapterPattern": "Колко глави искате да добавите, използвайки тази схема за номериране?",
|
||||||
"MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига",
|
"MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига",
|
||||||
"MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0",
|
"MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0",
|
||||||
|
|
@ -1018,18 +1018,50 @@
|
||||||
"ToastChapterStartTimeAdjusted": "Начално време на главате е настоено с {0} секунди",
|
"ToastChapterStartTimeAdjusted": "Начално време на главате е настоено с {0} секунди",
|
||||||
"ToastChaptersAllLocked": "Всички глави са заключени. Оключете някой глави за да преместите техните времена.",
|
"ToastChaptersAllLocked": "Всички глави са заключени. Оключете някой глави за да преместите техните времена.",
|
||||||
"ToastChaptersHaveErrors": "Главите имат грешки",
|
"ToastChaptersHaveErrors": "Главите имат грешки",
|
||||||
|
"ToastChaptersInvalidShiftAmountLast": "Невалидно време за преместване. Началният час на последната глава ще превиши общата продължителност на аудиокнигата.",
|
||||||
|
"ToastChaptersInvalidShiftAmountStart": "Невалидно време за преместване. Първата глава ще има нулева или отрицателна дължина и ще бъде презаписана от втората глава. Увеличете началното време на втората глава.",
|
||||||
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
|
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
|
||||||
|
"ToastChaptersRemoved": "Главите са премахнати",
|
||||||
|
"ToastChaptersUpdated": "Главите са актуализирани",
|
||||||
|
"ToastCollectionItemsAddFailed": "Неуспешно добавяне на елемент(и) към колекцията",
|
||||||
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
|
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
|
||||||
"ToastCollectionUpdateSuccess": "Колекцията е обновена",
|
"ToastCollectionUpdateSuccess": "Колекцията е обновена",
|
||||||
|
"ToastConnectionNotAvailable": "Няма връзка. Моля, опитайте отново по-късно",
|
||||||
|
"ToastCoverSearchFailed": "Търсенето на корица е неуспешно",
|
||||||
|
"ToastCoverUpdateFailed": "Обновяването на корицата е неуспешно",
|
||||||
|
"ToastDateTimeInvalidOrIncomplete": "Датата и часът са невалидни или непълни",
|
||||||
"ToastDeleteFileFailed": "Неуспешно изтриване на файла",
|
"ToastDeleteFileFailed": "Неуспешно изтриване на файла",
|
||||||
"ToastDeleteFileSuccess": "Успешно изтриване на файла",
|
"ToastDeleteFileSuccess": "Успешно изтриване на файла",
|
||||||
|
"ToastDeviceAddFailed": "Неуспешно добавяне на устройство",
|
||||||
|
"ToastDeviceNameAlreadyExists": "Вече съществува четец с това име",
|
||||||
|
"ToastDeviceTestEmailFailed": "Неуспешно изпращане на тестов имейл",
|
||||||
|
"ToastDeviceTestEmailSuccess": "Тестовият имейл е изпратен",
|
||||||
|
"ToastEmailSettingsUpdateSuccess": "Имейл настройките са актуализирани",
|
||||||
|
"ToastEncodeCancelFailed": "Неуспешно отменяне на кодирането",
|
||||||
|
"ToastEncodeCancelSucces": "Кодирането е отменено",
|
||||||
|
"ToastEpisodeDownloadQueueClearFailed": "Неуспешно изчистване на опашката",
|
||||||
|
"ToastEpisodeDownloadQueueClearSuccess": "Опашката за изтегляне на епизоди е изчистена",
|
||||||
|
"ToastEpisodeUpdateSuccess": "{0} епизода са актуализирани",
|
||||||
|
"ToastErrorCannotShare": "Не може да се споделя директно от това устройство",
|
||||||
|
"ToastFailedToCreate": "Неуспешно създаване",
|
||||||
|
"ToastFailedToDelete": "Неуспешно изтриване",
|
||||||
"ToastFailedToLoadData": "Неуспешно зареждане на данни",
|
"ToastFailedToLoadData": "Неуспешно зареждане на данни",
|
||||||
|
"ToastFailedToMatch": "Неуспешно съвпадение",
|
||||||
|
"ToastFailedToShare": "Неуспешно споделяне",
|
||||||
|
"ToastFailedToUpdate": "Неуспешно актуализиране",
|
||||||
|
"ToastInvalidImageUrl": "Невалиден URL адрес на изображение",
|
||||||
|
"ToastInvalidMaxEpisodesToDownload": "Невалиден максимален брой епизоди за изтегляне",
|
||||||
|
"ToastInvalidUrl": "Невалиден URL адрес",
|
||||||
|
"ToastInvalidUrls": "Един или повече URL адреси са невалидни",
|
||||||
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",
|
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",
|
||||||
|
"ToastItemDeletedFailed": "Неуспешно изтриване на елемента",
|
||||||
|
"ToastItemDeletedSuccess": "Елементът е изтрит",
|
||||||
"ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени",
|
"ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени",
|
||||||
"ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като Завършено",
|
"ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като Завършено",
|
||||||
"ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен",
|
"ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен",
|
||||||
"ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като Незавършено",
|
"ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като Незавършено",
|
||||||
"ToastItemMarkedAsNotFinishedSuccess": "Елементът е маркиран като незавършен",
|
"ToastItemMarkedAsNotFinishedSuccess": "Елементът е маркиран като незавършен",
|
||||||
|
"ToastItemUpdateSuccess": "Елементът е актуализиран",
|
||||||
"ToastLibraryCreateFailed": "Неуспешно създаване на библиотека",
|
"ToastLibraryCreateFailed": "Неуспешно създаване на библиотека",
|
||||||
"ToastLibraryCreateSuccess": "Библиотеката \"{0}\" е създадена",
|
"ToastLibraryCreateSuccess": "Библиотеката \"{0}\" е създадена",
|
||||||
"ToastLibraryDeleteFailed": "Неуспешно изтриване на библиотека",
|
"ToastLibraryDeleteFailed": "Неуспешно изтриване на библиотека",
|
||||||
|
|
@ -1037,28 +1069,97 @@
|
||||||
"ToastLibraryScanFailedToStart": "Неуспешно стартиране на сканиране",
|
"ToastLibraryScanFailedToStart": "Неуспешно стартиране на сканиране",
|
||||||
"ToastLibraryScanStarted": "Сканирането на библиотеката е стартирано",
|
"ToastLibraryScanStarted": "Сканирането на библиотеката е стартирано",
|
||||||
"ToastLibraryUpdateSuccess": "Библиотеката \"{0}\" е обновена",
|
"ToastLibraryUpdateSuccess": "Библиотеката \"{0}\" е обновена",
|
||||||
|
"ToastMatchAllAuthorsFailed": "Неуспешно съвпадение на всички автори",
|
||||||
|
"ToastMetadataFilesRemovedError": "Грешка при премахване на metadata.{0} файлове",
|
||||||
|
"ToastMetadataFilesRemovedNoneFound": "Не са намерени metadata.{0} файлове в библиотеката",
|
||||||
|
"ToastMetadataFilesRemovedNoneRemoved": "Не са премахнати metadata.{0} файлове",
|
||||||
|
"ToastMetadataFilesRemovedSuccess": "Премахнати са {0} файла metadata.{1}",
|
||||||
|
"ToastMustHaveAtLeastOnePath": "Трябва да има поне един път",
|
||||||
|
"ToastNameEmailRequired": "Изискват се име и имейл",
|
||||||
|
"ToastNameRequired": "Изисква се име",
|
||||||
|
"ToastNewApiKeyUserError": "Трябва да изберете потребител",
|
||||||
|
"ToastNewEpisodesFound": "Намерени са {0} нови епизода",
|
||||||
|
"ToastNewUserCreatedFailed": "Неуспешно създаване на акаунт: „{0}“",
|
||||||
|
"ToastNewUserCreatedSuccess": "Създаден е нов акаунт",
|
||||||
|
"ToastNewUserLibraryError": "Трябва да изберете поне една библиотека",
|
||||||
|
"ToastNewUserPasswordError": "Трябва да има парола; само root потребителят може да бъде с празна парола",
|
||||||
|
"ToastNewUserTagError": "Трябва да изберете поне един етикет",
|
||||||
|
"ToastNewUserUsernameError": "Въведете потребителско име",
|
||||||
|
"ToastNoNewEpisodesFound": "Не са намерени нови епизоди",
|
||||||
|
"ToastNoRSSFeed": "Подкастът няма RSS емисия",
|
||||||
|
"ToastNoUpdatesNecessary": "Не са необходими актуализации",
|
||||||
|
"ToastNotificationCreateFailed": "Неуспешно създаване на известие",
|
||||||
|
"ToastNotificationDeleteFailed": "Неуспешно изтриване на известието",
|
||||||
|
"ToastNotificationFailedMaximum": "Максималният брой неуспешни опити трябва да бъде >= 0",
|
||||||
|
"ToastNotificationQueueMaximum": "Максималната опашка за известия трябва да бъде >= 0",
|
||||||
|
"ToastNotificationSettingsUpdateSuccess": "Настройките за известия са актуализирани",
|
||||||
|
"ToastNotificationTestTriggerFailed": "Неуспешно задействане на тестово известие",
|
||||||
|
"ToastNotificationTestTriggerSuccess": "Тестовото известие е задействано",
|
||||||
|
"ToastNotificationUpdateSuccess": "Известието е актуализирано",
|
||||||
"ToastPlaylistCreateFailed": "Неуспешно създаване на плейлист",
|
"ToastPlaylistCreateFailed": "Неуспешно създаване на плейлист",
|
||||||
"ToastPlaylistCreateSuccess": "Плейлистът е създаден",
|
"ToastPlaylistCreateSuccess": "Плейлистът е създаден",
|
||||||
"ToastPlaylistRemoveSuccess": "Плейлистът е премахнат",
|
"ToastPlaylistRemoveSuccess": "Плейлистът е премахнат",
|
||||||
"ToastPlaylistUpdateSuccess": "Плейлистът е обновен",
|
"ToastPlaylistUpdateSuccess": "Плейлистът е обновен",
|
||||||
"ToastPodcastCreateFailed": "Неуспешно създаване на подкаст",
|
"ToastPodcastCreateFailed": "Неуспешно създаване на подкаст",
|
||||||
"ToastPodcastCreateSuccess": "Подкаст успешно създаден",
|
"ToastPodcastCreateSuccess": "Подкаст успешно създаден",
|
||||||
|
"ToastPodcastEpisodeUpdated": "Епизодът е актуализиран",
|
||||||
|
"ToastPodcastGetFeedFailed": "Неуспешно извличане на емисията на подкаста",
|
||||||
|
"ToastPodcastNoEpisodesInFeed": "Не са намерени епизоди в RSS емисията",
|
||||||
|
"ToastPodcastNoRssFeed": "Подкастът няма RSS емисия",
|
||||||
|
"ToastProgressIsNotBeingSynced": "Напредъкът не се синхронизира, рестартирайте възпроизвеждането",
|
||||||
|
"ToastProviderCreatedFailed": "Неуспешно добавяне на доставчик",
|
||||||
|
"ToastProviderCreatedSuccess": "Добавен е нов доставчик",
|
||||||
|
"ToastProviderNameAndUrlRequired": "Изискват се име и URL адрес",
|
||||||
|
"ToastProviderRemoveSuccess": "Доставчикът е премахнат",
|
||||||
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията",
|
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията",
|
||||||
"ToastRSSFeedCloseSuccess": "RSS емисията е затворена",
|
"ToastRSSFeedCloseSuccess": "RSS емисията е затворена",
|
||||||
|
"ToastRemoveFailed": "Неуспешно премахване",
|
||||||
"ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция",
|
"ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция",
|
||||||
"ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция",
|
"ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция",
|
||||||
|
"ToastRemoveItemsWithIssuesFailed": "Неуспешно премахване на елементите от библиотеката с проблеми",
|
||||||
|
"ToastRemoveItemsWithIssuesSuccess": "Елементите от библиотеката с проблеми са премахнати",
|
||||||
|
"ToastRenameFailed": "Неуспешно преименуване",
|
||||||
|
"ToastRescanFailed": "Повторното сканиране е неуспешно за {0}",
|
||||||
|
"ToastRescanRemoved": "Повторното сканиране завърши: елементът е премахнат",
|
||||||
|
"ToastRescanUpToDate": "Повторното сканиране завърши: елементът вече е актуален",
|
||||||
|
"ToastRescanUpdated": "Повторното сканиране завърши: елементът е актуализиран",
|
||||||
|
"ToastScanFailed": "Неуспешно сканиране на елемент от библиотеката",
|
||||||
|
"ToastSelectAtLeastOneUser": "Изберете поне един потребител",
|
||||||
"ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство",
|
"ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство",
|
||||||
"ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"",
|
"ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"",
|
||||||
|
"ToastSeriesSubmitFailedSameName": "Не могат да бъдат добавени два сериала с едно и също име",
|
||||||
"ToastSeriesUpdateFailed": "Неуспешно обновяване на серия",
|
"ToastSeriesUpdateFailed": "Неуспешно обновяване на серия",
|
||||||
"ToastSeriesUpdateSuccess": "Серията е обновена",
|
"ToastSeriesUpdateSuccess": "Серията е обновена",
|
||||||
"ToastServerSettingsUpdateSuccess": "Настройките на сървъра са актуализирани",
|
"ToastServerSettingsUpdateSuccess": "Настройките на сървъра са актуализирани",
|
||||||
|
"ToastSessionCloseFailed": "Неуспешно затваряне на сесията",
|
||||||
"ToastSessionDeleteFailed": "Неуспешно изтриване на сесия",
|
"ToastSessionDeleteFailed": "Неуспешно изтриване на сесия",
|
||||||
"ToastSessionDeleteSuccess": "Сесията е изтрита",
|
"ToastSessionDeleteSuccess": "Сесията е изтрита",
|
||||||
|
"ToastSleepTimerDone": "Таймерът за заспиване приключи... zZzzZz",
|
||||||
|
"ToastSlugMustChange": "Краткият URL (slug) съдържа невалидни символи",
|
||||||
|
"ToastSlugRequired": "Изисква се кратък URL (slug)",
|
||||||
"ToastSocketConnected": "Свързан сокет",
|
"ToastSocketConnected": "Свързан сокет",
|
||||||
"ToastSocketDisconnected": "Сокетът е прекъснат",
|
"ToastSocketDisconnected": "Сокетът е прекъснат",
|
||||||
"ToastSocketFailedToConnect": "Неуспешно свързване на сокет",
|
"ToastSocketFailedToConnect": "Неуспешно свързване на сокет",
|
||||||
"ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране",
|
"ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране",
|
||||||
"ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)",
|
"ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)",
|
||||||
|
"ToastTitleRequired": "Изисква се заглавие",
|
||||||
|
"ToastUnknownError": "Неизвестна грешка",
|
||||||
|
"ToastUnlinkOpenIdFailed": "Неуспешно прекъсване на връзката на потребителя с OpenID",
|
||||||
|
"ToastUnlinkOpenIdSuccess": "Връзката на потребителя с OpenID е прекъсната",
|
||||||
|
"ToastUploaderFilepathExistsError": "Файловият път „{0}“ вече съществува на сървъра",
|
||||||
|
"ToastUploaderItemExistsInSubdirectoryError": "Елементът „{0}“ използва поддиректория на пътя за качване.",
|
||||||
"ToastUserDeleteFailed": "Неуспешно изтриване на потребител",
|
"ToastUserDeleteFailed": "Неуспешно изтриване на потребител",
|
||||||
"ToastUserDeleteSuccess": "Потребителят е изтрит"
|
"ToastUserDeleteSuccess": "Потребителят е изтрит",
|
||||||
|
"ToastUserPasswordChangeSuccess": "Паролата е променена успешно",
|
||||||
|
"ToastUserPasswordMismatch": "Паролите не съвпадат",
|
||||||
|
"ToastUserPasswordMustChange": "Новата парола не може да бъде същата като старата",
|
||||||
|
"ToastUserRootRequireName": "Трябва да въведете root потребителско име",
|
||||||
|
"TooltipAddChapters": "Добавяне на глава(и)",
|
||||||
|
"TooltipAddOneSecond": "Добавяне на 1 секунда",
|
||||||
|
"TooltipAdjustChapterStart": "Кликнете за коригиране на началния час",
|
||||||
|
"TooltipLockAllChapters": "Заключване на всички глави",
|
||||||
|
"TooltipLockChapter": "Заключване на глава (Shift+клик за диапазон)",
|
||||||
|
"TooltipSubtractOneSecond": "Изваждане на 1 секунда",
|
||||||
|
"TooltipUnlockAllChapters": "Отключване на всички глави",
|
||||||
|
"TooltipUnlockChapter": "Отключване на глава (Shift+клик за диапазон)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
client/strings/lv.json
Normal file
1
client/strings/lv.json
Normal file
|
|
@ -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",
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.34.0",
|
"version": "2.35.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.34.0",
|
"version": "2.35.0",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.34.0",
|
"version": "2.35.0",
|
||||||
"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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
84
server/migrations/v2.35.0-add-last-refresh-token.js
Normal file
84
server/migrations/v2.35.0-add-last-refresh-token.js
Normal file
|
|
@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue