diff --git a/client/components/modals/collections/AddCreateModal.vue b/client/components/modals/collections/AddCreateModal.vue index 24e8695d6..f0d43c14f 100644 --- a/client/components/modals/collections/AddCreateModal.vue +++ b/client/components/modals/collections/AddCreateModal.vue @@ -227,7 +227,7 @@ export default { .catch((error) => { console.error('Failed to create collection', error) var errMsg = error.response ? error.response.data || '' : '' - this.$toast.error(this.$strings.ToastCollectionCreateFailed + ': ' + errMsg) + this.$toast.error(errMsg) this.processing = false }) } diff --git a/client/components/modals/item/tabs/Schedule.vue b/client/components/modals/item/tabs/Schedule.vue index 2cd3af514..0faa29630 100644 --- a/client/components/modals/item/tabs/Schedule.vue +++ b/client/components/modals/item/tabs/Schedule.vue @@ -158,6 +158,8 @@ export default { this.isProcessing = true var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => { console.error('Failed to update', error) + const errorMessage = typeof error?.response?.data === 'string' ? error?.response?.data : null + this.$toast.error(errorMessage || this.$strings.ToastFailedToUpdate) return false }) this.isProcessing = false diff --git a/client/components/ui/TextInput.vue b/client/components/ui/TextInput.vue index dd0eaa738..cd3bc6d2d 100644 --- a/client/components/ui/TextInput.vue +++ b/client/components/ui/TextInput.vue @@ -1,6 +1,6 @@ @@ -26,7 +26,8 @@ export default { disabled: Boolean, inputClass: String, showCopy: Boolean, - trimWhitespace: Boolean + trimWhitespace: Boolean, + autocomplete: String }, data() { return {} diff --git a/client/package-lock.json b/client/package-lock.json index 299741bd5..502ba4b03 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.33.0", + "version": "2.35.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.33.0", + "version": "2.35.1", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index a1503a506..0ec070b32 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.33.0", + "version": "2.35.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/client/pages/login.vue b/client/pages/login.vue index 8e5cde098..cec150e71 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -17,9 +17,9 @@

Create Root User

- - - + + +

Directory Paths

@@ -51,10 +51,10 @@ - + - +
{{ processing ? 'Checking...' : $strings.ButtonSubmit }}
diff --git a/client/pages/share/_slug.vue b/client/pages/share/_slug.vue index 64c099632..fdcbbe500 100644 --- a/client/pages/share/_slug.vue +++ b/client/pages/share/_slug.vue @@ -364,6 +364,7 @@ export default { } const startTime = this.playbackSession.currentTime || 0 + this.localAudioPlayer.set(null, this.audioTracks, false, startTime, false) this.localAudioPlayer.on('stateChange', this.playerStateChange.bind(this)) this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this)) diff --git a/client/players/LocalAudioPlayer.js b/client/players/LocalAudioPlayer.js index 7fc17e7aa..a0384d54d 100644 --- a/client/players/LocalAudioPlayer.js +++ b/client/players/LocalAudioPlayer.js @@ -46,7 +46,20 @@ export default class LocalAudioPlayer extends EventEmitter { this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this)) this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this)) - var mimeTypes = ['audio/flac', 'audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/aac', 'audio/x-ms-wma', 'audio/x-aiff', 'audio/webm'] + var mimeTypes = [ + 'audio/flac', + 'audio/mpeg', + 'audio/mp4', + 'audio/ogg', + 'audio/aac', + 'audio/x-ms-wma', + 'audio/x-aiff', + 'audio/webm', + // `audio/matroska` is the correct mimetype, but the server still uses `audio/x-matroska` + // ref: https://www.iana.org/assignments/media-types/media-types.xhtml + 'audio/matroska', + 'audio/x-matroska' + ] var mimeTypeCanPlayMap = {} mimeTypes.forEach((mt) => { var canPlay = this.player.canPlayType(mt) diff --git a/client/plugins/i18n.js b/client/plugins/i18n.js index 0184ca9e5..a56fbd267 100644 --- a/client/plugins/i18n.js +++ b/client/plugins/i18n.js @@ -21,6 +21,7 @@ const languageCodeMap = { he: { label: 'עברית', dateFnsLocale: 'he' }, hr: { label: 'Hrvatski', dateFnsLocale: 'hr' }, it: { label: 'Italiano', dateFnsLocale: 'it' }, + ja: { label: '日本語', dateFnsLocale: 'ja' }, lt: { label: 'Lietuvių', dateFnsLocale: 'lt' }, hu: { label: 'Magyar', dateFnsLocale: 'hu' }, ko: { label: '한국어', dateFnsLocale: 'ko' }, @@ -60,6 +61,7 @@ const podcastSearchRegionMap = { hr: { label: 'Hrvatska' }, il: { label: 'ישראל / إسرائيل' }, it: { label: 'Italia' }, + jp: { label: '日本' }, lu: { label: 'Luxembourg / Luxemburg / Lëtezebuerg' }, hu: { label: 'Magyarország' }, nl: { label: 'Nederland' }, diff --git a/client/strings/ar.json b/client/strings/ar.json index a176b9d8b..dc5ef1762 100644 --- a/client/strings/ar.json +++ b/client/strings/ar.json @@ -244,6 +244,8 @@ "LabelAlreadyInYourLibrary": "موجود بالفعل في مكتبتك", "LabelApiKeyCreated": "تم إنشاء مفتاح API \"{0}\" بنجاح.", "LabelApiKeyCreatedDescription": "تأكد من نسخ مفتاح API الآن، لن تتمكن من رؤيته مرة أخرى.", + "LabelApiKeyUser": "التصرف بالنيابة عن مستخدم", + "LabelApiKeyUserDescription": "مفتاح API سيمتلك نفس صلاحيات المستخدم الذي ينوب عنه ، سيظهر بالسجلات وكأن المستخدم قام بالطلب.", "LabelApiToken": "رمز API", "LabelAppend": "إلحاق", "LabelAudioBitrate": "معدل بت الصوت (على سبيل المثال 128 كيلو بايت)", @@ -293,6 +295,7 @@ "LabelContinueListening": "استمرار الاستماع", "LabelContinueReading": "استمرار القراءة", "LabelContinueSeries": "استمرار المسلسلات", + "LabelCorsAllowed": "CORS Origins مسموح", "LabelCover": "الغلاف", "LabelCoverImageURL": "رابط صورة الغلاف", "LabelCoverProvider": "مزود الغلاف", @@ -426,6 +429,9 @@ "LabelLibraryFilterSublistEmpty": "لا يوجد {0}", "LabelLibraryItem": "عنصر المكتبة", "LabelLibraryName": "اسم المكتبة", + "LabelLibrarySortByProgress": "المرحلة: الأحدث", + "LabelLibrarySortByProgressFinished": "المرحلة: تم الانتهاء", + "LabelLibrarySortByProgressStarted": "المرحلة: تم البدء", "LabelLimit": "حد", "LabelLineSpacing": "تباعد الأسطر", "LabelListenAgain": "الاستماع مجدداً", diff --git a/client/strings/be.json b/client/strings/be.json index 6dbef9cb9..d3aa42cf1 100644 --- a/client/strings/be.json +++ b/client/strings/be.json @@ -1,6 +1,6 @@ { "ButtonAdd": "Дадаць", - "ButtonAddApiKey": "Дадаць API-ключ", + "ButtonAddApiKey": "Дадаць ключ API", "ButtonAddChapters": "Дадаць раздзелы", "ButtonAddDevice": "Дадаць прыладу", "ButtonAddLibrary": "Дадаць бібліятэку", @@ -16,7 +16,7 @@ "ButtonBrowseForFolder": "Агляд папак", "ButtonCancel": "Скасаваць", "ButtonCancelEncode": "Скасаваць кадзіраванне", - "ButtonChangeRootPassword": "Зменіце Root пароль", + "ButtonChangeRootPassword": "Змяніць пароль root", "ButtonCheckAndDownloadNewEpisodes": "Праверыць і спампаваць новыя выпускі", "ButtonChooseAFolder": "Выбраць папку", "ButtonChooseFiles": "Выбраць файлы", @@ -50,7 +50,7 @@ "ButtonManageTracks": "Кіраванне трэкамі", "ButtonMapChapterTitles": "Супаставіць загалоўкі раздзелаў", "ButtonMatchAllAuthors": "Супадзенне ўсіх аўтараў", - "ButtonMatchBooks": "Падбор кніг", + "ButtonMatchBooks": "Параўнаць кнігі", "ButtonNevermind": "Няважна", "ButtonNext": "Далей", "ButtonNextChapter": "Наступны раздзел", @@ -81,14 +81,14 @@ "ButtonRemove": "Выдаліць", "ButtonRemoveAll": "Выдаліць усе", "ButtonRemoveAllLibraryItems": "Выдаліць усе элементы бібліятэкі", - "ButtonRemoveFromContinueListening": "Выдаліць з Працягваць слухаць", + "ButtonRemoveFromContinueListening": "Выдаліць з Працяг праслухоўвання", "ButtonRemoveFromContinueReading": "Выдаліць з Працягваць чытанне", "ButtonRemoveSeriesFromContinueSeries": "Выдаліць серыю з Працягваць серыю", "ButtonReset": "Скінуць", "ButtonResetToDefault": "Скінуць да прадвызначаных", "ButtonRestore": "Аднавіць", "ButtonSave": "Захаваць", - "ButtonSaveAndClose": "Захаваць і зачыніць", + "ButtonSaveAndClose": "Захаваць і закрыць", "ButtonSaveTracklist": "Захаваць спіс трэкаў", "ButtonScan": "Сканаваць", "ButtonScanLibrary": "Сканіраваць бібліятэку", @@ -107,10 +107,10 @@ "ButtonSubmit": "Адправіць", "ButtonTest": "Тэст", "ButtonUnlinkOpenId": "Адвязаць OpenID", - "ButtonUpload": "Загрузіць", - "ButtonUploadBackup": "Загрузіць рэзервовую копію", - "ButtonUploadCover": "Загрузіць вокладку", - "ButtonUploadOPMLFile": "Загрузіць файл OPML", + "ButtonUpload": "Запампаваць", + "ButtonUploadBackup": "Запампаваць рэзервовую копію", + "ButtonUploadCover": "Запампаваць вокладку", + "ButtonUploadOPMLFile": "Запампаваць файл OPML", "ButtonUserDelete": "Выдаліць карыстальніка {0}", "ButtonUserEdit": "Рэдагаваць карыстальніка {0}", "ButtonViewAll": "Прагледзець усе", @@ -121,7 +121,7 @@ "HeaderAccount": "Уліковы запіс", "HeaderAddCustomMetadataProvider": "Дадаванне карыстальніцкага пастаўшчыка метаданых", "HeaderAdvanced": "Дадаткова", - "HeaderApiKeys": "API-ключы", + "HeaderApiKeys": "Ключы API", "HeaderAppriseNotificationSettings": "Налады апавяшчэнняў Apprise", "HeaderAudioTracks": "Аўдыятрэкі", "HeaderAudiobookTools": "Сродкі кіравання файламі аўдыякніг", @@ -149,7 +149,7 @@ "HeaderFindChapters": "Знайсці раздзелы", "HeaderIgnoredFiles": "Ігнараваныя файлы", "HeaderItemFiles": "Файлы элементаў", - "HeaderItemMetadataUtils": "Утыліты для метаданых элементаў", + "HeaderItemMetadataUtils": "Утыліты для метаданых", "HeaderLastListeningSession": "Апошні сеанс праслухоўвання", "HeaderLatestEpisodes": "Апошнія выпускі", "HeaderLibraries": "Бібліятэкі", @@ -166,7 +166,7 @@ "HeaderMetadataOrderOfPrecedence": "Парадак прыярытэту метаданых", "HeaderMetadataToEmbed": "Метаданыя для ўбудавання", "HeaderNewAccount": "Новы ўліковы запіс", - "HeaderNewApiKey": "Новы API-ключ", + "HeaderNewApiKey": "Новы ключ API", "HeaderNewLibrary": "Новая бібліятэка", "HeaderNotificationCreate": "Стварыць апавяшчэнне", "HeaderNotificationUpdate": "Абнавіць апавяшчэнне", @@ -196,7 +196,7 @@ "HeaderSession": "Сеанс", "HeaderSetBackupSchedule": "Наладзіць расклад рэзервовага капіравання", "HeaderSettings": "Налады", - "HeaderSettingsDisplay": "Дысплей", + "HeaderSettingsDisplay": "Выгляд", "HeaderSettingsExperimental": "Эксперыментальныя функцыі", "HeaderSettingsGeneral": "Агульныя", "HeaderSettingsScanner": "Сканер", @@ -207,12 +207,12 @@ "HeaderStatsLongestItems": "Найдаўжэйшыя элементы (гадзіны)", "HeaderStatsMinutesListeningChart": "Хвілін праслухоўвання (апошнія 7 дзён)", "HeaderStatsRecentSessions": "Апошнія сеансы", - "HeaderStatsTop10Authors": "10 лепшых аўтараў", - "HeaderStatsTop5Genres": "5 лепшых жанраў", + "HeaderStatsTop10Authors": "Топ 10 аўтараў", + "HeaderStatsTop5Genres": "Топ 5 жанраў", "HeaderTableOfContents": "Змест", "HeaderTools": "Інструменты", "HeaderUpdateAccount": "Абнавіць уліковы запіс", - "HeaderUpdateApiKey": "Абнавіць API-ключ", + "HeaderUpdateApiKey": "Абнавіць ключ API", "HeaderUpdateAuthor": "Абнавіць аўтара", "HeaderUpdateDetails": "Абнавіць падрабязнасці", "HeaderUpdateLibrary": "Абнавіць бібліятэку", @@ -239,21 +239,21 @@ "LabelAll": "Усе", "LabelAllEpisodesDownloaded": "Усе выпускі спампаваныя", "LabelAllUsers": "Усе карыстальнікі", - "LabelAllUsersExcludingGuests": "Усе карыстальнікі, акрамя гасцей", - "LabelAllUsersIncludingGuests": "Усе карыстальнікі, уключаючы гасцей", + "LabelAllUsersExcludingGuests": "Усіх карыстальнікаў, акрамя гасцей", + "LabelAllUsersIncludingGuests": "Усіх карыстальнікаў, уключаючы гасцей", "LabelAlreadyInYourLibrary": "Ужо ў вашай бібліятэцы", - "LabelApiKeyCreated": "API-ключ \"{0}\" паспяхова створаны.", - "LabelApiKeyCreatedDescription": "Пераканайцеся, што вы скапіявалі API-ключ зараз, бо паўторна яго ўбачыць не атрымаецца.", + "LabelApiKeyCreated": "Ключ API \"{0}\" паспяхова створаны.", + "LabelApiKeyCreatedDescription": "Абавязкова скапіюйце ключ API зараз, бо паўторна яго ўбачыць не атрымаецца.", "LabelApiKeyUser": "Дзейнічаць ад імя карыстальніка", - "LabelApiKeyUserDescription": "Гэты API-ключ будзе мець тыя ж правы, што і карыстальнік, ад імя якога ён дзейнічае. У журналах гэта будзе выглядаць так, быццам запыт робіць сам карыстальнік.", + "LabelApiKeyUserDescription": "Гэты ключ API будзе мець тыя ж правы, што і карыстальнік, ад імя якога ён дзейнічае. У журналах гэта будзе выглядаць так, быццам запыт робіць сам карыстальнік.", "LabelApiToken": "Токен API", "LabelAppend": "Дадаць", "LabelAudioBitrate": "Бітрэйт аўдыя (напрыклад, 128к)", "LabelAudioChannels": "Аўдыяканалы (1 або 2)", "LabelAudioCodec": "Аўдыякодэк", "LabelAuthor": "Аўтар", - "LabelAuthorFirstLast": "Аўтар (Імя Прозвішча)", - "LabelAuthorLastFirst": "Аўтар (Прозвішча, Імя)", + "LabelAuthorFirstLast": "Аўтар (імя, прозвішча)", + "LabelAuthorLastFirst": "Аўтар (прозвішча, імя)", "LabelAuthors": "Аўтары", "LabelAutoDownloadEpisodes": "Аўтаматычна спампоўваць выпускі", "LabelAutoFetchMetadata": "Аўтаматычнае атрыманне метаданых", @@ -264,7 +264,7 @@ "LabelAutoRegisterDescription": "Аўтаматычна ствараць новых карыстальнікаў пасля ўваходу ў сістэму", "LabelBackToUser": "Вярнуцца да карыстальніка", "LabelBackupAudioFiles": "Рэзервовае капіраванне аўдыяфайлаў", - "LabelBackupLocation": "Месцазнаходжанне рэзервовых копій", + "LabelBackupLocation": "Размяшчэнне рэзервовых копій", "LabelBackupsEnableAutomaticBackups": "Аўтаматычнае рэзервовае капіраванне", "LabelBackupsEnableAutomaticBackupsHelp": "Рэзервовыя копіі захаваныя ў /metadata/backups", "LabelBackupsMaxBackupSize": "Максімальны памер рэзервовай копіі (у ГБ) (0 — неабмежавана)", @@ -284,7 +284,7 @@ "LabelChaptersFound": "раздзелаў знойдзена", "LabelClickForMoreInfo": "Націсніце для больш падрабязнай інфармацыі", "LabelClickToUseCurrentValue": "Націсніце, каб выкарыстоўваць бягучае значэнне", - "LabelClosePlayer": "Зачыніць прайгравальнік", + "LabelClosePlayer": "Закрыць прайгравальнік", "LabelCodec": "Кодэк", "LabelCollapseSeries": "Згарнуць серыі", "LabelCollapseSubSeries": "Згарнуць падсерыі", @@ -292,7 +292,7 @@ "LabelCollections": "Калекцыі", "LabelComplete": "Завяршыць", "LabelConfirmPassword": "Пацвердзіце пароль", - "LabelContinueListening": "Працягваць слухаць", + "LabelContinueListening": "Працяг праслухоўвання", "LabelContinueReading": "Працягнуць чытанне", "LabelContinueSeries": "Працягнуць серыі", "LabelCorsAllowed": "Дазволеныя крыніцы CORS", @@ -316,7 +316,7 @@ "LabelDirectory": "Каталог", "LabelDiscFromFilename": "Дыск з файла", "LabelDiscFromMetadata": "Дыск з метаданых", - "LabelDiscover": "Знайсці", + "LabelDiscover": "Знаходкі", "LabelDownload": "Спампаваць", "LabelDownloadNEpisodes": "Спампавана {0} выпускаў", "LabelDownloadable": "Спампоўваецца", @@ -332,7 +332,7 @@ "LabelEmailSettingsFromAddress": "Адрас адпраўніка", "LabelEmailSettingsRejectUnauthorized": "Адхіляць неаўтарызаваныя сертыфікаты", "LabelEmailSettingsRejectUnauthorizedHelp": "Адключэнне праверкі SSL-сертыфіката можа зрабіць ваша злучэнне ўразлівым перад пагрозамі бяспекі, такімі як атакі \"чалавек пасярэдзіне\". Адключайце гэтую опцыю толькі калі цалкам разумееце наступствы і ўпэўнены ў надзейнасці паштовага сервера.", - "LabelEmailSettingsSecure": "Бяспечныя", + "LabelEmailSettingsSecure": "Бяспечна", "LabelEmailSettingsSecureHelp": "Калі ўключана, злучэнне будзе выкарыстоўваць TLS пры падключэнні да сервера. Калі выключана, TLS будзе выкарыстоўвацца толькі ў выпадку падтрымкі пашырэння STARTTLS на серверы. У большасці выпадкаў усталюйце значэнне true пры падключэнні да порта 465. Для партоў 587 або 25 не ўключайце яго. (інфармацыя з nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Тэставы адрас", "LabelEmbeddedCover": "Убудаваная вокладка", @@ -424,7 +424,7 @@ "LabelLastBookAdded": "Апошняя дададзеная кніга", "LabelLastBookUpdated": "Апошняя абноўленая кніга", "LabelLastProgressDate": "Апошні прагрэс: {0}", - "LabelLastSeen": "Апошні прагляд", + "LabelLastSeen": "Апошняя актыўнасць", "LabelLastTime": "Апошні раз", "LabelLastUpdate": "Апошняе абнаўленне", "LabelLayout": "Знешні выгляд", @@ -545,7 +545,7 @@ "LabelRSSFeedSlug": "Ідэнтыфікатар RSS-стужкі", "LabelRSSFeedURL": "URL RSS-стужкі", "LabelRandomly": "Выпадкова", - "LabelReAddSeriesToContinueListening": "Дадаць серыю зноў у Працягваць слухаць", + "LabelReAddSeriesToContinueListening": "Дадаць серыю зноў у Працяг праслухоўвання", "LabelRead": "Чытаць", "LabelReadAgain": "Чытаць зноў", "LabelReadEbookWithoutProgress": "Чытаць электронную кнігу без захавання прагрэсу", @@ -588,23 +588,23 @@ "LabelSettingsBookshelfViewHelp": "Рэалістычны дызайн з драўлянымі паліцамі", "LabelSettingsChromecastSupport": "Падтрымка Chromecast", "LabelSettingsDateFormat": "Фарматы даты", - "LabelSettingsEnableWatcher": "Аўтаматычна сачыць за зменамі ў бібліятэцках", + "LabelSettingsEnableWatcher": "Аўтаматычна сачыць за зменамі ў бібліятэках", "LabelSettingsEnableWatcherForLibrary": "Аўтаматычна сачыць за зменамі ў бібліятэцы", "LabelSettingsEnableWatcherHelp": "Адключае аўтаматычнае дадаванне/абнаўленне элементаў пры выяўленні змен у файлах. *Патрабуецца перазапуск сервера", "LabelSettingsEpubsAllowScriptedContent": "Дазваляць скрыпты ў файлах EPUB", "LabelSettingsEpubsAllowScriptedContentHelp": "Дазволіць файлам EPUB выконваць скрыпты. Рэкамендуецца пакінуць гэтую наладу выключанай, калі вы не давяраеце крыніцы файлаў EPUB.", "LabelSettingsExperimentalFeatures": "Эксперыментальныя функцыі", "LabelSettingsExperimentalFeaturesHelp": "Функцыі ў распрацоўцы, для якіх вашы водгукі і дапамога ў тэставанні будуць карыснымі. Націсніце, каб адкрыць абмеркаванне на GitHub.", - "LabelSettingsFindCovers": "Знайсці вокладкі", + "LabelSettingsFindCovers": "Шукаць вокладкі", "LabelSettingsFindCoversHelp": "Калі ў вашай аўдыякнізе няма ўбудаванай вокладкі або відарыса вокладкі ў папцы, сканер паспрабуе знайсці вокладку.
Заўвага: гэта павялічыць час сканіравання", "LabelSettingsHideSingleBookSeries": "Схаваць серыі з адной кнігай", "LabelSettingsHideSingleBookSeriesHelp": "Серыі, якія змяшчаюць толькі адну кнігу, будуць схаваны са старонкі серый і паліц на галоўнай старонцы.", - "LabelSettingsHomePageBookshelfView": "Галоўная старонка выкарыстоўвае выгляд кніжнай паліцы", - "LabelSettingsLibraryBookshelfView": "Бібліятэка выкарыстоўвае выгляд кніжнай паліцы", + "LabelSettingsHomePageBookshelfView": "Кніжныя паліцы на галоўнай старонцы", + "LabelSettingsLibraryBookshelfView": "Кніжныя паліцы ў бібліятэцы", "LabelSettingsLibraryMarkAsFinishedPercentComplete": "Працэнт завяршэння большы за", - "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Час, што застаўся, менш за (секунд)", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "Час, што застаўся, меншы за (секунд)", "LabelSettingsLibraryMarkAsFinishedWhen": "Пазначыць элемент медыя як завершаны, калі", - "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Пропусціць папярэднія кнігі ў \"Працягнуць серыю\"", + "LabelSettingsOnlyShowLaterBooksInContinueSeries": "Прапусціць папярэднія кнігі ў \"Працягнуць серыю\"", "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "Паліца \"Працягнуць серыю\" на галоўнай старонцы паказвае першую не пачатую кнігу ў серыях, дзе завершана хаця б адна кніга і няма кніг у працэсе чытання. Уключэнне гэтай налады дазволіць працягваць серыю з самай апошняй завершанай кнігі замест першай не пачатай.", "LabelSettingsParseSubtitles": "Аналізаваць падзагалоўкі", "LabelSettingsParseSubtitlesHelp": "Выдзяляць падзагаловак з назваў папак аўдыякніг.
Падзагаловак павінен быць аддзелены сімвалам \" - \".
Напрыклад, \"Назва кнігі - Падзагаловак тут\" мае падзагаловак \"Падзагаловак тут\"", @@ -618,7 +618,7 @@ "LabelSettingsSquareBookCoversHelp": "Аддаваць перавагу квадратным вокладкам замест стандартных вокладак з суадносінамі бакоў 1.6:1", "LabelSettingsStoreCoversWithItem": "Захоўваць вокладкі з элементам", "LabelSettingsStoreCoversWithItemHelp": "Прадвызначана вокладкі захоўваюцца ў /metadata/items, уключэнне гэтай опцыі забяспечыць захоўванне вокладак у папцы элемента бібліятэкі. Захоўвацца будзе толькі адзін файл з назвай \"cover\"", - "LabelSettingsStoreMetadataWithItem": "Захоўваць метаданыя разам з элементам", + "LabelSettingsStoreMetadataWithItem": "Захоўваць метаданыя з элементам", "LabelSettingsStoreMetadataWithItemHelp": "Прадвызначана метаданыя захоўваюцца ў /metadata/items. Пры ўключэнні гэтай опцыі файлаў метаданых будуць захоўвацца ў папках элементаў бібліятэкі", "LabelSettingsTimeFormat": "Фармат часу", "LabelShare": "Абагуліць", @@ -634,14 +634,14 @@ "LabelSortAscending": "Па ўзрастанні", "LabelSortDescending": "Па ўбыванні", "LabelSortPubDate": "Сартаваць па даце публікацыі", - "LabelStart": "Пачаць", + "LabelStart": "Пачатак", "LabelStartTime": "Час пачатку", "LabelStarted": "Пачата", "LabelStartedAt": "Пачата ў", "LabelStartedDate": "Пачата {0}", "LabelStatsAudioTracks": "Аўдыятрэкі", - "LabelStatsAuthors": "Аўтары", - "LabelStatsBestDay": "Лепшы дзень", + "LabelStatsAuthors": "Аўтараў", + "LabelStatsBestDay": "Найлепшы дзень", "LabelStatsDailyAverage": "У сярэднім за дзень", "LabelStatsDays": "Дзён", "LabelStatsDaysListened": "Дзён праслухана", @@ -655,7 +655,7 @@ "LabelStatsOverallHours": "Агульная колькасць гадзін", "LabelStatsWeekListening": "Праслухана за тыдзень", "LabelSubtitle": "Падзагаловак", - "LabelSupportedFileTypes": "Падтрымліваемыя тыпы файлаў", + "LabelSupportedFileTypes": "Падтрымліваюцца тыпы файлаў", "LabelTag": "Метка", "LabelTags": "Меткі", "LabelTagsAccessibleToUser": "Меткі, даступныя карыстальніку", @@ -742,7 +742,7 @@ "MessageAuthenticationSecurityMessage": "Дзеля бяспекі была палепшана аўтэнтыфікацыя. Усім карыстальнікам трэба паўторна ўвайсці ў сістэму.", "MessageBackupsDescription": "Рэзервовыя копіі ўключаюць карыстальнікаў, іх прагрэс, падрабязнасці элементаў бібліятэкі, налады сервера і відарысы, якія захоўваюцца ў /metadata/items і /metadata/authors. Рэзервовыя копіі не ўключаюць файлы, якія захоўваюцца ў папках бібліятэкі.", "MessageBackupsLocationEditNote": "Заўвага: Абнаўленне месцазнаходжання рэзервовых копій не перамяшчае і не змяняе існуючыя рэзервовыя копіі", - "MessageBackupsLocationNoEditNote": "Заўвага: Месцазнаходжанне рэзервовых копій задаецца праз зменную асяроддзя і не можа быць зменена тут.", + "MessageBackupsLocationNoEditNote": "Заўвага: Размяшчэнне рэзервовых копій задаецца праз зменную асяроддзя і не можа быць зменена тут.", "MessageBackupsLocationPathEmpty": "Шлях да месцазнаходжання рэзервовых копій не можа быць пустым", "MessageBatchEditPopulateMapDetailsAllHelp": "Запоўніце ўключаныя палі данымі з усіх элементаў. Палі з некалькімі значэннямі будуць аб'яднаны", "MessageBatchEditPopulateMapDetailsItemHelp": "Запоўніце ўключаныя палі падрабязнасцей карты данымі з гэтага элемента", @@ -884,7 +884,7 @@ "MessageRemoveEpisodes": "Выдаліць выпускі ({0})", "MessageRemoveFromPlayerQueue": "Выдаліць з чаргі прагравання", "MessageRemoveUserWarning": "Вы ўпэўнены, што хочаце назаўжды выдаліць карыстальніка \"{0}\"?", - "MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце новыя функцыі і ўдзельнічайце на", + "MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце функцыі і ўносьце свой уклад на", "MessageResetChaptersConfirm": "Вы ўпэўнены, што хочаце скінуць раздзелы і адрабіць зробленыя вамі змены?", "MessageRestoreBackupConfirm": "Вы ўпэўнены, што хочаце аднавіць рэзервовую копію, створаную", "MessageRestoreBackupWarning": "Аднаўленне рэзервовай копіі перазапіша ўсю базу даных, размешчаную ў /config, а таксама відарысы вокладкі ў /metadata/items і /metadata/authors.

Рэзервовыя копіі не змяняюць файлы ў папках бібліятэкі. Калі вы ўключылі налады сервера для захоўвання воклак і метаданых у папках бібліятэкі, гэтыя файлы не будуць захаваныя ў рэзервовых копіях і не зменяцца.

Усе кліенты, якія карыстаюцца вашым серверам, будуць аўтаматычна абноўлены.", @@ -968,7 +968,7 @@ "StatsBooksAdditional": "Некаторыя дапаўненні ўключаюць…", "StatsBooksFinished": "завершана кніг", "StatsBooksFinishedThisYear": "Некаторыя кнігі завершаны ў гэтым годзе…", - "StatsBooksListenedTo": "кнігі, якія былі праслуханы", + "StatsBooksListenedTo": "кніг праслухана", "StatsCollectionGrewTo": "Ваша калекцыя кніг павялічылася да…", "StatsSessions": "сеансаў", "StatsSpentListening": "праслухана", @@ -1147,7 +1147,7 @@ "ToastUnlinkOpenIdFailed": "Не ўдалося адвязаць карыстальніка ад OpenID", "ToastUnlinkOpenIdSuccess": "Карыстальнік адвязаны ад OpenID", "ToastUploaderFilepathExistsError": "Файл \"{0}\" ужо існуе на серверы", - "ToastUploaderItemExistsInSubdirectoryError": "Элемент \"{0}\" выкарыстоўвае падкаталог шляху загрузкі.", + "ToastUploaderItemExistsInSubdirectoryError": "Элемент \"{0}\" выкарыстоўвае падкаталог шляху запампоўкі.", "ToastUserDeleteFailed": "Не ўдалося выдаліць карыстальніка", "ToastUserDeleteSuccess": "Карыстальнік выдалены", "ToastUserPasswordChangeSuccess": "Пароль паспяхова зменены", diff --git a/client/strings/bg.json b/client/strings/bg.json index dacd62087..c34188a54 100644 --- a/client/strings/bg.json +++ b/client/strings/bg.json @@ -436,7 +436,7 @@ "LabelLibraryFilterSublistEmpty": "Не {0}", "LabelLibraryItem": "Елемент на Библиотека", "LabelLibraryName": "Име на Библиотека", - "LabelLibrarySortByProgress": "Прогрес: Последно Обновен", + "LabelLibrarySortByProgress": "Прогрес: Последно обновление", "LabelLibrarySortByProgressFinished": "Прогрес: Приключено", "LabelLibrarySortByProgressStarted": "Прогрес: Започнато", "LabelLimit": "Лимит", @@ -752,7 +752,7 @@ "MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове", "MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"", "MessageBookshelfNoResultsForQuery": "Няма резултати от заявката", - "MessageBookshelfNoSeries": "Нямаш сеЗЙ", + "MessageBookshelfNoSeries": "Нямате поредица", "MessageBulkChapterPattern": "Колко глави искате да добавите, използвайки тази схема за номериране?", "MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига", "MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0", @@ -892,7 +892,7 @@ "MessageScheduleRunEveryWeekdayAtTime": "Изпълни всеки {0} в {1}", "MessageSearchResultsFor": "Резултати от търсенето за", "MessageSelected": "{0} избрани", - "MessageSeriesSequenceCannotContainSpaces": "Подредбата в серия не може да съдържа шпации.", + "MessageSeriesSequenceCannotContainSpaces": "Подредбата в серия не може да съдържа шпации", "MessageServerCouldNotBeReached": "Сървърът не може да бъде достигнат", "MessageSetChaptersFromTracksDescription": "Задайте глави, като използвате всеки аудио файл като глава и заглавие на главата като име на аудио файла", "MessageShareExpirationWillBe": "Изтичането ще бъде на {0}", @@ -956,6 +956,8 @@ "NotificationOnEpisodeDownloadedDescription": "Изпълнява се при автоматично изтегляне на подкаст епизод", "NotificationOnRSSFeedDisabledDescription": "Изпълнява се, когато автоматичното изтегляне на епизодите е деактивирано, поради твърде много неуспешни опити", "NotificationOnRSSFeedFailedDescription": "Пуска се когато заявката за RSS фийд е неуспешна за автоматично сваляне на епизод", + "NotificationOnTestDescription": "Event за тестване на системата за нотификации", + "PlaceholderBulkChapterInput": "Въведете име на глава или използвайте номериране (прим. 'Епизод 1', 'Глава 10', '1.')", "PlaceholderNewCollection": "Ново име на колекцията", "PlaceholderNewFolderPath": "Нов път на папката", "PlaceholderNewPlaylist": "Ново име на плейлиста", @@ -963,39 +965,103 @@ "PlaceholderSearchEpisode": "Търсене на Епизоди...", "StatsAuthorsAdded": "добаврени автори", "StatsBooksAdded": "добавени книги", + "StatsBooksAdditional": "Някой от вкючените добавки…", "StatsBooksFinished": "завършени книги", + "StatsBooksFinishedThisYear": "Някой от книгите приключени тази година…", + "StatsBooksListenedTo": "слушани книги", + "StatsCollectionGrewTo": "Твоята книжна колекция израсна до…", + "StatsSessions": "сесии", + "StatsSpentListening": "прекарано в слушане", + "StatsTopAuthor": "ТОП АВТОР", + "StatsTopAuthors": "ТОП АВТОРИ", + "StatsTopGenre": "ТОП ЖАНР", + "StatsTopGenres": "ТОП ЖАНРА", + "StatsTopMonth": "ТОП МЕСЕЦ", + "StatsTopNarrator": "ТОП РАЗКАЗВАЧ", + "StatsTopNarrators": "ТОП РАЗКАЗВАЧИ", + "StatsTotalDuration": "С пълно времетраене…", + "StatsYearInReview": "ГОДИНАТА В ПРЕГЛЕД", "ToastAccountUpdateSuccess": "Успешно обновяване на акаунта", + "ToastAppriseUrlRequired": "Трябва да въведете Apprise URL", + "ToastAsinRequired": "ASIN-а е задължителен", "ToastAuthorImageRemoveSuccess": "Авторската снимка е премахната", + "ToastAuthorNotFound": "Автор \"{0}\" не е намерен", + "ToastAuthorRemoveSuccess": "Арторът е премахнат", + "ToastAuthorSearchNotFound": "Авторът не е намерен", "ToastAuthorUpdateMerged": "Обновяване на автора сливано", "ToastAuthorUpdateSuccess": "Автора обновен", "ToastAuthorUpdateSuccessNoImageFound": "Автор обновен (не е намерена снимка)", + "ToastBackupAppliedSuccess": "Архивът е приложен", "ToastBackupCreateFailed": "Неуспешно създаване на архив", "ToastBackupCreateSuccess": "Архивът е създаден", "ToastBackupDeleteFailed": "Неуспешно изтриване на архив", "ToastBackupDeleteSuccess": "Архивът е изтрит", + "ToastBackupInvalidMaxKeep": "Невалиден брой за архиви за запазване", + "ToastBackupInvalidMaxSize": "Невалиден максимален рамер на архив", "ToastBackupRestoreFailed": "Неуспешно възстановяване на архив", "ToastBackupUploadFailed": "Неуспешно качване на архив", "ToastBackupUploadSuccess": "Архивът е качен", + "ToastBatchApplyDetailsToItemsSuccess": "Детайли приложени на предмети", + "ToastBatchDeleteFailed": "Груповото изтриване се провали", + "ToastBatchDeleteSuccess": "Успешно групово изтриване", + "ToastBatchQuickMatchFailed": "Груповото Бързо Съвпадение се провали!", + "ToastBatchQuickMatchStarted": "Груповото Бързо Съвпадение на {0} книги започна!", "ToastBatchUpdateFailed": "Неуспешно групово актуализиране", "ToastBatchUpdateSuccess": "Успешно групово актуализиране", "ToastBookmarkCreateFailed": "Неуспешно създаване на отметка", "ToastBookmarkCreateSuccess": "Отметката е създадена", "ToastBookmarkRemoveSuccess": "Отметката е премахната", + "ToastBulkChapterInvalidCount": "Въведете число между 1 и 150", "ToastCachePurgeFailed": "Неуспешно изчистване на кеша", "ToastCachePurgeSuccess": "Успешно изчистване на кеша", + "ToastChapterLocked": "Главата е заключена.", + "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": "Неуспешно изтриване на библиотека", @@ -1003,28 +1069,97 @@ "ToastLibraryScanFailedToStart": "Неуспешно стартиране на сканиране", "ToastLibraryScanStarted": "Сканирането на библиотеката е стартирано", "ToastLibraryUpdateSuccess": "Библиотеката \"{0}\" е обновена", + "ToastMatchAllAuthorsFailed": "Неуспешно съвпадение на всички автори", + "ToastMetadataFilesRemovedError": "Грешка при премахване на metadata.{0} файлове", + "ToastMetadataFilesRemovedNoneFound": "Не са намерени metadata.{0} файлове в библиотеката", + "ToastMetadataFilesRemovedNoneRemoved": "Не са премахнати metadata.{0} файлове", + "ToastMetadataFilesRemovedSuccess": "Премахнати са {0} файла metadata.{1}", + "ToastMustHaveAtLeastOnePath": "Трябва да има поне един път", + "ToastNameEmailRequired": "Изискват се име и имейл", + "ToastNameRequired": "Изисква се име", + "ToastNewApiKeyUserError": "Трябва да изберете потребител", + "ToastNewEpisodesFound": "Намерени са {0} нови епизода", + "ToastNewUserCreatedFailed": "Неуспешно създаване на акаунт: „{0}“", + "ToastNewUserCreatedSuccess": "Създаден е нов акаунт", + "ToastNewUserLibraryError": "Трябва да изберете поне една библиотека", + "ToastNewUserPasswordError": "Трябва да има парола; само root потребителят може да бъде с празна парола", + "ToastNewUserTagError": "Трябва да изберете поне един етикет", + "ToastNewUserUsernameError": "Въведете потребителско име", + "ToastNoNewEpisodesFound": "Не са намерени нови епизоди", + "ToastNoRSSFeed": "Подкастът няма RSS емисия", + "ToastNoUpdatesNecessary": "Не са необходими актуализации", + "ToastNotificationCreateFailed": "Неуспешно създаване на известие", + "ToastNotificationDeleteFailed": "Неуспешно изтриване на известието", + "ToastNotificationFailedMaximum": "Максималният брой неуспешни опити трябва да бъде >= 0", + "ToastNotificationQueueMaximum": "Максималната опашка за известия трябва да бъде >= 0", + "ToastNotificationSettingsUpdateSuccess": "Настройките за известия са актуализирани", + "ToastNotificationTestTriggerFailed": "Неуспешно задействане на тестово известие", + "ToastNotificationTestTriggerSuccess": "Тестовото известие е задействано", + "ToastNotificationUpdateSuccess": "Известието е актуализирано", "ToastPlaylistCreateFailed": "Неуспешно създаване на плейлист", "ToastPlaylistCreateSuccess": "Плейлистът е създаден", "ToastPlaylistRemoveSuccess": "Плейлистът е премахнат", "ToastPlaylistUpdateSuccess": "Плейлистът е обновен", "ToastPodcastCreateFailed": "Неуспешно създаване на подкаст", "ToastPodcastCreateSuccess": "Подкаст успешно създаден", + "ToastPodcastEpisodeUpdated": "Епизодът е актуализиран", + "ToastPodcastGetFeedFailed": "Неуспешно извличане на емисията на подкаста", + "ToastPodcastNoEpisodesInFeed": "Не са намерени епизоди в RSS емисията", + "ToastPodcastNoRssFeed": "Подкастът няма RSS емисия", + "ToastProgressIsNotBeingSynced": "Напредъкът не се синхронизира, рестартирайте възпроизвеждането", + "ToastProviderCreatedFailed": "Неуспешно добавяне на доставчик", + "ToastProviderCreatedSuccess": "Добавен е нов доставчик", + "ToastProviderNameAndUrlRequired": "Изискват се име и URL адрес", + "ToastProviderRemoveSuccess": "Доставчикът е премахнат", "ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията", "ToastRSSFeedCloseSuccess": "RSS емисията е затворена", + "ToastRemoveFailed": "Неуспешно премахване", "ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция", "ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция", + "ToastRemoveItemsWithIssuesFailed": "Неуспешно премахване на елементите от библиотеката с проблеми", + "ToastRemoveItemsWithIssuesSuccess": "Елементите от библиотеката с проблеми са премахнати", + "ToastRenameFailed": "Неуспешно преименуване", + "ToastRescanFailed": "Повторното сканиране е неуспешно за {0}", + "ToastRescanRemoved": "Повторното сканиране завърши: елементът е премахнат", + "ToastRescanUpToDate": "Повторното сканиране завърши: елементът вече е актуален", + "ToastRescanUpdated": "Повторното сканиране завърши: елементът е актуализиран", + "ToastScanFailed": "Неуспешно сканиране на елемент от библиотеката", + "ToastSelectAtLeastOneUser": "Изберете поне един потребител", "ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство", "ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"", + "ToastSeriesSubmitFailedSameName": "Не могат да бъдат добавени два сериала с едно и също име", "ToastSeriesUpdateFailed": "Неуспешно обновяване на серия", "ToastSeriesUpdateSuccess": "Серията е обновена", "ToastServerSettingsUpdateSuccess": "Настройките на сървъра са актуализирани", + "ToastSessionCloseFailed": "Неуспешно затваряне на сесията", "ToastSessionDeleteFailed": "Неуспешно изтриване на сесия", "ToastSessionDeleteSuccess": "Сесията е изтрита", + "ToastSleepTimerDone": "Таймерът за заспиване приключи... zZzzZz", + "ToastSlugMustChange": "Краткият URL (slug) съдържа невалидни символи", + "ToastSlugRequired": "Изисква се кратък URL (slug)", "ToastSocketConnected": "Свързан сокет", "ToastSocketDisconnected": "Сокетът е прекъснат", "ToastSocketFailedToConnect": "Неуспешно свързване на сокет", "ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране", "ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)", + "ToastTitleRequired": "Изисква се заглавие", + "ToastUnknownError": "Неизвестна грешка", + "ToastUnlinkOpenIdFailed": "Неуспешно прекъсване на връзката на потребителя с OpenID", + "ToastUnlinkOpenIdSuccess": "Връзката на потребителя с OpenID е прекъсната", + "ToastUploaderFilepathExistsError": "Файловият път „{0}“ вече съществува на сървъра", + "ToastUploaderItemExistsInSubdirectoryError": "Елементът „{0}“ използва поддиректория на пътя за качване.", "ToastUserDeleteFailed": "Неуспешно изтриване на потребител", - "ToastUserDeleteSuccess": "Потребителят е изтрит" + "ToastUserDeleteSuccess": "Потребителят е изтрит", + "ToastUserPasswordChangeSuccess": "Паролата е променена успешно", + "ToastUserPasswordMismatch": "Паролите не съвпадат", + "ToastUserPasswordMustChange": "Новата парола не може да бъде същата като старата", + "ToastUserRootRequireName": "Трябва да въведете root потребителско име", + "TooltipAddChapters": "Добавяне на глава(и)", + "TooltipAddOneSecond": "Добавяне на 1 секунда", + "TooltipAdjustChapterStart": "Кликнете за коригиране на началния час", + "TooltipLockAllChapters": "Заключване на всички глави", + "TooltipLockChapter": "Заключване на глава (Shift+клик за диапазон)", + "TooltipSubtractOneSecond": "Изваждане на 1 секунда", + "TooltipUnlockAllChapters": "Отключване на всички глави", + "TooltipUnlockChapter": "Отключване на глава (Shift+клик за диапазон)" } diff --git a/client/strings/de.json b/client/strings/de.json index 276348656..4d9ecd412 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -116,7 +116,7 @@ "ButtonViewAll": "Alles anzeigen", "ButtonYes": "Ja", "ErrorUploadFetchMetadataAPI": "Fehler beim Abrufen der Metadaten", - "ErrorUploadFetchMetadataNoResults": "Metadaten konnten nicht abgerufen werden. Versuche, den Titel und/oder den Autor zu aktualisieren.", + "ErrorUploadFetchMetadataNoResults": "Metadaten konnten nicht abgerufen werden - versuche den Titel und/oder den Autor zu aktualisieren", "ErrorUploadLacksTitle": "Es muss ein Titel eingegeben werden", "HeaderAccount": "Konto", "HeaderAddCustomMetadataProvider": "Benutzerdefinierten Metadatenanbieter hinzufügen", @@ -331,7 +331,7 @@ "LabelEmail": "E-Mail", "LabelEmailSettingsFromAddress": "Sender", "LabelEmailSettingsRejectUnauthorized": "Nicht autorisierte Zertifikate ablehnen", - "LabelEmailSettingsRejectUnauthorizedHelp": "Durch das Deaktivieren der SSL-Zertifikatsüberprüfung kann deine Verbindung Sicherheitsrisiken wie Man-in-the-Middle-Angriffen ausgesetzt sein. Deaktiviere diese Option nur, wenn due die Auswirkungen verstehst und dem E-Mail-Server vertraust, mit dem eine Verbindung hergestellt wird.", + "LabelEmailSettingsRejectUnauthorizedHelp": "Durch das Deaktivieren der SSL-Zertifikatsüberprüfung kann deine Verbindung Sicherheitsrisiken wie Man-in-the-Middle-Angriffen ausgesetzt sein. Deaktiviere diese Option nur, wenn du die Auswirkungen verstehst und dem E-Mail-Server vertraust, mit dem eine Verbindung hergestellt wird.", "LabelEmailSettingsSecure": "Sicher", "LabelEmailSettingsSecureHelp": "Wenn an, verwendet die Verbindung TLS, wenn du eine Verbindung zum Server herstellst. Bei „aus“ wird TLS verwendet, wenn der Server die STARTTLS-Erweiterung unterstützt. In den meisten Fällen solltest du diesen Wert auf „an“ schalten, wenn du eine Verbindung zu Port 465 herstellst. Für Port 587 oder 25 behalte den Wert „aus“ bei. (von nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Test-Adresse", @@ -622,7 +622,7 @@ "LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet", "LabelSettingsTimeFormat": "Zeitformat", "LabelShare": "Freigeben", - "LabelShareDownloadableHelp": "Erlaubt es einem Nutzer, mit dem Link, die Dateien des Mediums als ZIP herunterzuladen.", + "LabelShareDownloadableHelp": "Erlaubt es einem Nutzer, mit dem Link die Dateien des Mediums als ZIP herunterzuladen.", "LabelShareOpen": "Freigeben", "LabelShareURL": "Freigabe URL", "LabelShowAll": "Alles anzeigen", @@ -737,7 +737,7 @@ "MessageAddToPlayerQueue": "Zur Abspielwarteliste hinzufügen", "MessageAppriseDescription": "Um diese Funktion nutzen zu können, musst du eine Instanz von Apprise API laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann.
Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter http://192.168.1.1:8337 läuft, würdest du http://192.168.1.1:8337/notify eingeben.", "MessageAsinCheck": "Stelle sicher, dass die ASIN aus der richtigen Audible Region verwendet wird, nicht Amazon.", - "MessageAuthenticationLegacyTokenWarning": "Alte API-Token werden in Zukunft entfernt. Benutze stattdessen API Keys.", + "MessageAuthenticationLegacyTokenWarning": "Nicht mehr unterstützte API tokens werden in der Zukunft entfernt. Nutze stattdessen API Schlüssel.", "MessageAuthenticationOIDCChangesRestart": "Nach dem Speichern muss der Server neugestartet werden um die OIDC Änderungen zu übernehmen.", "MessageAuthenticationSecurityMessage": "Die Anmeldung wurde abgesichert. Benutzersitzungen werden getrennt, alle Benutzer müssen sich erneut anmelden.", "MessageBackupsDescription": "In einer Sicherung werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder welche in /metadata/items & /metadata/authors gespeichert sind gespeichert. Sicherungen enthalten keine Dateien welche in den einzelnen Bibliotheksordnern (Medien-Ordnern) gespeichert sind.", @@ -816,7 +816,7 @@ "MessageFeedURLWillBe": "Feed-URL wird {0} sein", "MessageFetching": "Wird abgerufen …", "MessageForceReScanDescription": "Durchsucht alle Dateien erneut, wie bei einem frischen Scan. ID3-Tags von Audiodateien, OPF-Dateien und Textdateien werden neu durchsucht.", - "MessageHeatmapListeningTimeTooltip": "{0} auf {1} gehört", + "MessageHeatmapListeningTimeTooltip": "{0} gehört auf {1}", "MessageHeatmapNoListeningSessions": "Keine Hörsitzungen am {0}", "MessageImportantNotice": "Wichtiger Hinweis!", "MessageInsertChapterBelow": "Kapitel unten einfügen", @@ -1103,7 +1103,7 @@ "ToastPodcastCreateFailed": "Podcast konnte nicht erstellt werden", "ToastPodcastCreateSuccess": "Podcast erstellt", "ToastPodcastEpisodeUpdated": "Podcast-Folge aktualisiert", - "ToastPodcastGetFeedFailed": "Fehler beim abrufen des Podcast-Feeds", + "ToastPodcastGetFeedFailed": "Fehler beim Abrufen des Podcast Feeds", "ToastPodcastNoEpisodesInFeed": "Keine Episoden in RSS Feed gefunden", "ToastPodcastNoRssFeed": "Podcast enthält keinen RSS Feed", "ToastProgressIsNotBeingSynced": "Fortschritt wird nicht synchronisiert, Wiedergabe wird neu gestartet", diff --git a/client/strings/es.json b/client/strings/es.json index 73ec1cd2d..e9655563d 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -20,10 +20,10 @@ "ButtonCheckAndDownloadNewEpisodes": "Comprobar y descargar episodios nuevos", "ButtonChooseAFolder": "Elegir una carpeta", "ButtonChooseFiles": "Elegir archivos", - "ButtonClearFilter": "Quitar filtros", + "ButtonClearFilter": "Vaciar filtro", "ButtonClose": "Cerrar", "ButtonCloseFeed": "Cerrar suministro", - "ButtonCloseSession": "Cerrar la sesión abierta", + "ButtonCloseSession": "Cerrar sesión abierta", "ButtonCollections": "Colecciones", "ButtonConfigureScanner": "Configurar Escáner", "ButtonCreate": "Crear", @@ -33,27 +33,27 @@ "ButtonEdit": "Editar", "ButtonEditChapters": "Editar capítulos", "ButtonEditPodcast": "Editar pódcast", - "ButtonEnable": "Permitir", + "ButtonEnable": "Habilitar", "ButtonFireAndFail": "Ejecutado y fallido", "ButtonFireOnTest": "Activar evento de prueba", "ButtonForceReScan": "Forzar Re-Escaneo", "ButtonFullPath": "Ruta completa", "ButtonHide": "Ocultar", "ButtonHome": "Inicio", - "ButtonIssues": "Problemas", + "ButtonIssues": "Incidencias", "ButtonJumpBackward": "Retroceder", "ButtonJumpForward": "Adelantar", "ButtonLatest": "Más recientes", "ButtonLibrary": "Biblioteca", - "ButtonLogout": "Salir", - "ButtonLookup": "Buscar", + "ButtonLogout": "Cerrar Sesión", + "ButtonLookup": "Averiguar", "ButtonManageTracks": "Gestionar pistas", "ButtonMapChapterTitles": "Asignar Títulos a Capítulos", "ButtonMatchAllAuthors": "Encontrar Todos los Autores", - "ButtonMatchBooks": "Encontrar Libros", + "ButtonMatchBooks": "Cotejar Libros", "ButtonNevermind": "Olvidar", "ButtonNext": "Siguiente", - "ButtonNextChapter": "Siguiente Capítulo", + "ButtonNextChapter": "Siguiente capítulo", "ButtonNextItemInQueue": "El siguiente elemento en cola", "ButtonOk": "Aceptar", "ButtonOpenFeed": "Abrir suministro", @@ -64,26 +64,26 @@ "ButtonPlaying": "Reproduciendo", "ButtonPlaylists": "Listas de reproducción", "ButtonPrevious": "Anterior", - "ButtonPreviousChapter": "Capítulo Anterior", - "ButtonProbeAudioFile": "Examinar archivo de audio", - "ButtonPurgeAllCache": "Purgar toda la antememoria", - "ButtonPurgeItemsCache": "Purgar antememoria de elementos", - "ButtonQueueAddItem": "Añadir a la cola", - "ButtonQueueRemoveItem": "Quitar de la cola", + "ButtonPreviousChapter": "Capítulo anterior", + "ButtonProbeAudioFile": "Sonda del archivo de audio", + "ButtonPurgeAllCache": "Purgar toda la caché", + "ButtonPurgeItemsCache": "Purgar caché de elementos", + "ButtonQueueAddItem": "Añadir a cola", + "ButtonQueueRemoveItem": "Quitar de cola", "ButtonQuickEmbed": "Inserción rápida", - "ButtonQuickEmbedMetadata": "Agregue metadatos rápidamente", - "ButtonQuickMatch": "Encontrar Rápido", + "ButtonQuickEmbedMetadata": "Empotrar metadatos rápidamente", + "ButtonQuickMatch": "Cotejo Rápido", "ButtonReScan": "Re-Escanear", "ButtonRead": "Leer", "ButtonReadLess": "Leer menos", "ButtonReadMore": "Leer más", - "ButtonRefresh": "Actualizar", + "ButtonRefresh": "Recargar", "ButtonRemove": "Quitar", "ButtonRemoveAll": "Quitar todo", "ButtonRemoveAllLibraryItems": "Quitar todos los elementos de la biblioteca", - "ButtonRemoveFromContinueListening": "Quitar de Continuar escuchando", - "ButtonRemoveFromContinueReading": "Quitar de Continuar leyendo", - "ButtonRemoveSeriesFromContinueSeries": "Quitar serie de Continuar serie", + "ButtonRemoveFromContinueListening": "Quitar desde Escucha Continua", + "ButtonRemoveFromContinueReading": "Quitar desde Continuar Leyendo", + "ButtonRemoveSeriesFromContinueSeries": "Quitar Series desde Series Continuas", "ButtonReset": "Restablecer", "ButtonResetToDefault": "Restaurar valores predeterminados", "ButtonRestore": "Restaurar", @@ -92,47 +92,47 @@ "ButtonSaveTracklist": "Guardar lista de pistas", "ButtonScan": "Escanear", "ButtonScanLibrary": "Escanear biblioteca", - "ButtonScrollLeft": "Desplazarse hacia la izquierda", - "ButtonScrollRight": "Desplazarse hacia la derecha", + "ButtonScrollLeft": "Desplazarse a la izquierda", + "ButtonScrollRight": "Desplazarse a la derecha", "ButtonSearch": "Buscar", "ButtonSelectFolderPath": "Seleccionar ruta de carpeta", "ButtonSeries": "Series", - "ButtonSetChaptersFromTracks": "Seleccionar Capítulos Según las Pistas", + "ButtonSetChaptersFromTracks": "Establecer capítulos según las pistas", "ButtonShare": "Compartir", - "ButtonShiftTimes": "Desplazar Tiempos", + "ButtonShiftTimes": "Veces de Desplazo", "ButtonShow": "Mostrar", "ButtonStartM4BEncode": "Iniciar Codificación M4B", - "ButtonStartMetadataEmbed": "Iniciar la Inserción de Metadata", + "ButtonStartMetadataEmbed": "Iniciar Inserción de Metadatos", "ButtonStats": "Estadísticas", - "ButtonSubmit": "Enviar", + "ButtonSubmit": "Entregar", "ButtonTest": "Prueba", - "ButtonUnlinkOpenId": "Desvincular OpenID", - "ButtonUpload": "Cargar", - "ButtonUploadBackup": "Cargar respaldo", - "ButtonUploadCover": "Cargar cubierta", - "ButtonUploadOPMLFile": "Cargar archivo OPML", + "ButtonUnlinkOpenId": "Desenlazar OpenID", + "ButtonUpload": "Subir", + "ButtonUploadBackup": "Subir Respaldo", + "ButtonUploadCover": "Subir Cubierta", + "ButtonUploadOPMLFile": "Subir archivo OPML", "ButtonUserDelete": "Eliminar usuario {0}", "ButtonUserEdit": "Editar usuario {0}", "ButtonViewAll": "Ver todo", "ButtonYes": "Sí", "ErrorUploadFetchMetadataAPI": "Error al recuperar los metadatos", - "ErrorUploadFetchMetadataNoResults": "No se pudieron recuperar los metadatos; pruebe a actualizar el título o autor", - "ErrorUploadLacksTitle": "Debe tener título", + "ErrorUploadFetchMetadataNoResults": "No se pudieron recuperar los metadatos; pruebe a actualizar el título y/o autor", + "ErrorUploadLacksTitle": "Debe tener un título", "HeaderAccount": "Cuenta", "HeaderAddCustomMetadataProvider": "Añadir proveedor de metadatos personalizado", "HeaderAdvanced": "Avanzado", "HeaderApiKeys": "Claves API", - "HeaderAppriseNotificationSettings": "Configuración de notificaciones de Apprise", - "HeaderAudioTracks": "Pistas de audio", + "HeaderAppriseNotificationSettings": "Ajustes de notificaciones de Apprise", + "HeaderAudioTracks": "Pistas de Audio", "HeaderAudiobookTools": "Herramientas de Gestión de Archivos de Audiolibro", "HeaderAuthentication": "Autenticación", "HeaderBackups": "Respaldos", "HeaderBulkChapterModal": "Añadir Múltiples Capítulos", - "HeaderChangePassword": "Cambiar contraseña", + "HeaderChangePassword": "Cambiar Contraseña", "HeaderChapters": "Capítulos", "HeaderChooseAFolder": "Escoger una Carpeta", "HeaderCollection": "Colección", - "HeaderCollectionItems": "Elementos en la colección", + "HeaderCollectionItems": "Elementos de colección", "HeaderCover": "Cubierta", "HeaderCurrentDownloads": "Descargas actuales", "HeaderCustomMessageOnLogin": "Mensaje personalizado al acceder", @@ -140,49 +140,49 @@ "HeaderDetails": "Detalles", "HeaderDownloadQueue": "Cola de descargas", "HeaderEbookFiles": "Archivos de libros digitales", - "HeaderEmail": "Correo electrónico", - "HeaderEmailSettings": "Configuración de correo electrónico", + "HeaderEmail": "Correo-e", + "HeaderEmailSettings": "Ajustes de correo-e", "HeaderEpisodes": "Episodios", - "HeaderEreaderDevices": "Dispositivos Ereader", - "HeaderEreaderSettings": "Configuración del lector", + "HeaderEreaderDevices": "Dispositivos Lector-e", + "HeaderEreaderSettings": "Ajustes del Lector-e", "HeaderFiles": "Archivos", "HeaderFindChapters": "Buscar capítulos", "HeaderIgnoredFiles": "Archivos ignorados", - "HeaderItemFiles": "Archivos de elementos", - "HeaderItemMetadataUtils": "Utilidades de metadatos de elementos", + "HeaderItemFiles": "Archivos del elemento", + "HeaderItemMetadataUtils": "Utilidades de metadatos del elemento", "HeaderLastListeningSession": "Última sesión de escucha", "HeaderLatestEpisodes": "Episodios más recientes", "HeaderLibraries": "Bibliotecas", "HeaderLibraryFiles": "Archivos de biblioteca", "HeaderLibraryStats": "Estadísticas de biblioteca", - "HeaderListeningSessions": "Sesión", + "HeaderListeningSessions": "Sesiones Listadas", "HeaderListeningStats": "Estadísticas de Tiempo Escuchado", - "HeaderLogin": "Acceder", - "HeaderLogs": "Registros", + "HeaderLogin": "Inicio de Sesión", + "HeaderLogs": "Bitácoras", "HeaderManageGenres": "Gestionar géneros", "HeaderManageTags": "Gestionar etiquetas", "HeaderMapDetails": "Asignar Detalles", - "HeaderMatch": "Encontrar", + "HeaderMatch": "Coincidir", "HeaderMetadataOrderOfPrecedence": "Orden de precedencia de metadatos", - "HeaderMetadataToEmbed": "Metadatos para Insertar", - "HeaderNewAccount": "Cuenta nueva", + "HeaderMetadataToEmbed": "Metadatos para empotrar", + "HeaderNewAccount": "Crear Cuenta", "HeaderNewApiKey": "Nueva clave API", "HeaderNewLibrary": "Biblioteca nueva", - "HeaderNotificationCreate": "Crear notificación", - "HeaderNotificationUpdate": "Notificación de actualización", + "HeaderNotificationCreate": "Crear Notificación", + "HeaderNotificationUpdate": "Notificación de Actualización", "HeaderNotifications": "Notificaciones", "HeaderOpenIDConnectAuthentication": "Autenticación OpenID Connect", - "HeaderOpenListeningSessions": "Sesiones públicas de escucha", + "HeaderOpenListeningSessions": "Abrir escucha de sesiones", "HeaderOpenRSSFeed": "Abrir suministro RSS", "HeaderOtherFiles": "Otros archivos", "HeaderPasswordAuthentication": "Autenticación por contraseña", "HeaderPermissions": "Permisos", "HeaderPlayerQueue": "Cola del reproductor", - "HeaderPlayerSettings": "Configuración del reproductor", + "HeaderPlayerSettings": "Ajustes del reproductor", "HeaderPlaylist": "Lista de reproducción", "HeaderPlaylistItems": "Elementos de lista de reproducción", "HeaderPodcastsToAdd": "Pódcast para añadir", - "HeaderPresets": "Preconfiguraciones", + "HeaderPresets": "Preajustes", "HeaderPreviewCover": "Previsualizar cubierta", "HeaderRSSFeedGeneral": "Detalles de RSS", "HeaderRSSFeedIsOpen": "El suministro RSS está abierto", @@ -191,18 +191,18 @@ "HeaderRemoveEpisodes": "Quitar {0} episodios", "HeaderSavedMediaProgress": "Guardar Progreso de Multimedia", "HeaderSchedule": "Horario", - "HeaderScheduleEpisodeDownloads": "Programar descargas automáticas de episodios", - "HeaderScheduleLibraryScans": "Programar Escaneo Automático de Biblioteca", + "HeaderScheduleEpisodeDownloads": "Planificador de auto‐descargas de episodios", + "HeaderScheduleLibraryScans": "Planificar Auto‐Escaneo de Biblioteca", "HeaderSession": "Sesión", - "HeaderSetBackupSchedule": "Programar Respaldo", - "HeaderSettings": "Configuración", + "HeaderSetBackupSchedule": "Establecer Planificación de Respaldo", + "HeaderSettings": "Ajustes", "HeaderSettingsDisplay": "Interfaz", - "HeaderSettingsExperimental": "Funcionalidades experimentales", + "HeaderSettingsExperimental": "Características experimentales", "HeaderSettingsGeneral": "Generales", "HeaderSettingsScanner": "Escáner", "HeaderSettingsSecurity": "Seguridad", "HeaderSettingsWebClient": "Cliente web", - "HeaderSleepTimer": "Temporizador de apagado", + "HeaderSleepTimer": "Cronómetro de dormida", "HeaderStatsLargestItems": "Elementos más grandes", "HeaderStatsLongestItems": "Elementos más extensos (h)", "HeaderStatsMinutesListeningChart": "Minutos escuchando (últimos 7 días)", @@ -233,29 +233,29 @@ "LabelAddToCollectionBatch": "Añadir {0} libros a colección", "LabelAddToPlaylist": "Añadir a lista de reproducción", "LabelAddToPlaylistBatch": "Añadir {0} elementos a lista de reproducción", - "LabelAddedAt": "Añadido", - "LabelAddedDate": "{0} Añadido", + "LabelAddedAt": "Añadido en", + "LabelAddedDate": "Añadido {0}", "LabelAdminUsersOnly": "Solamente usuarios administradores", "LabelAll": "Todos", "LabelAllEpisodesDownloaded": "Todos los episodios descargados", "LabelAllUsers": "Todos los usuarios", "LabelAllUsersExcludingGuests": "Todos los usuarios excepto invitados", "LabelAllUsersIncludingGuests": "Todos los usuarios e invitados", - "LabelAlreadyInYourLibrary": "Ya existe en la Biblioteca", - "LabelApiKeyCreated": "La clave de API “{0}” se ha creado con éxito.", + "LabelAlreadyInYourLibrary": "Ya dentro de tu biblioteca", + "LabelApiKeyCreated": "La clave de API “{0}” se ha creado correctamente.", "LabelApiKeyCreatedDescription": "Asegúrate de copiar la clave de API ahora, no la volverás a ver otra vez.", "LabelApiKeyUser": "Actuar en nombre del usuario", "LabelApiKeyUserDescription": "Esta clave de API tendrá los mismos permisos que el usuario al que representa. En los registros se verá como si la solicitud la hubiera hecho el usuario directamente.", - "LabelApiToken": "Token de la API", + "LabelApiToken": "Vale del API", "LabelAppend": "Adjuntar", - "LabelAudioBitrate": "Tasa de bits del audio (por ejemplo, 128k)", + "LabelAudioBitrate": "Tasa de bit del audio (p.ej., 128k)", "LabelAudioChannels": "Canales de audio (1 o 2)", "LabelAudioCodec": "Códec de audio", "LabelAuthor": "Autor", "LabelAuthorFirstLast": "Autor (Nombre Apellido)", "LabelAuthorLastFirst": "Autor (Apellido, Nombre)", "LabelAuthors": "Autores", - "LabelAutoDownloadEpisodes": "Descargar episodios automáticamente", + "LabelAutoDownloadEpisodes": "Auto‐Descargar episodios", "LabelAutoFetchMetadata": "Recuperar metadatos automáticamente", "LabelAutoFetchMetadataHelp": "Obtiene metadatos de título, autor y serie para agilizar la carga. Es posible que haya que cotejar metadatos adicionales después de la carga.", "LabelAutoLaunch": "Lanzamiento automático", @@ -275,7 +275,7 @@ "LabelBonus": "Bonus", "LabelBooks": "Libros", "LabelButtonText": "Texto del botón", - "LabelByAuthor": "por", + "LabelByAuthor": "por {0}", "LabelChangePassword": "Cambiar contraseña", "LabelChannels": "Canales", "LabelChapterCount": "{0} capítulos", @@ -286,13 +286,13 @@ "LabelClickToUseCurrentValue": "Pulse para utilizar el valor actual", "LabelClosePlayer": "Cerrar reproductor", "LabelCodec": "Codec", - "LabelCollapseSeries": "Colapsar serie", + "LabelCollapseSeries": "Colapsar Series", "LabelCollapseSubSeries": "Contraer la subserie", "LabelCollection": "Colección", "LabelCollections": "Colecciones", "LabelComplete": "Completo", "LabelConfirmPassword": "Confirmar contraseña", - "LabelContinueListening": "Seguir escuchando", + "LabelContinueListening": "Seguir Escuchando", "LabelContinueReading": "Continuar leyendo", "LabelContinueSeries": "Continuar series", "LabelCorsAllowed": "Orígenes CORS Permitidos", @@ -325,14 +325,14 @@ "LabelDurationComparisonLonger": "({0} más largo)", "LabelDurationComparisonShorter": "({0} más corto)", "LabelDurationFound": "Duración Comprobada:", - "LabelEbook": "Libro electrónico", - "LabelEbooks": "Libros electrónicos", + "LabelEbook": "Libro-e", + "LabelEbooks": "Libros-e", "LabelEdit": "Editar", "LabelEmail": "Correo electrónico", "LabelEmailSettingsFromAddress": "Remitente", "LabelEmailSettingsRejectUnauthorized": "Rechazar certificados no autorizados", "LabelEmailSettingsRejectUnauthorizedHelp": "Desactivar la validación de certificados SSL puede exponer su conexión a riesgos de seguridad, como los ataques por intermediario. Desactive esta opción solo si conoce las implicaciones y confía en el servidor de correo al que se conecta.", - "LabelEmailSettingsSecure": "Seguridad", + "LabelEmailSettingsSecure": "Seguro", "LabelEmailSettingsSecureHelp": "Si está activado, se usará TLS para conectarse al servidor. Si está apagado, se usará TLS si su servidor tiene soporte para la extensión STARTTLS. En la mayoría de los casos, puede dejar esta opción activada si se está conectando al puerto 465. Apáguela en el caso de usar los puertos 587 o 25. (de nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Probar dirección", "LabelEmbeddedCover": "Cubierta incrustada", @@ -377,11 +377,12 @@ "LabelFilename": "Nombre del archivo", "LabelFilterByUser": "Filtrar por Usuario", "LabelFindEpisodes": "Buscar Episodio", - "LabelFinished": "Terminado", + "LabelFinished": "Finalizado", + "LabelFinishedDate": "Finalizado {0}", "LabelFolder": "Carpeta", "LabelFolders": "Carpetas", "LabelFontBold": "Negrilla", - "LabelFontBoldness": "Peso tipográfico", + "LabelFontBoldness": "Tipográfico sin Negrita", "LabelFontFamily": "Familia tipográfica", "LabelFontItalic": "Itálica", "LabelFontScale": "Escala de letra", @@ -391,8 +392,8 @@ "LabelGenre": "Género", "LabelGenres": "Géneros", "LabelHardDeleteFile": "Eliminar Definitivamente", - "LabelHasEbook": "Tiene un libro", - "LabelHasSupplementaryEbook": "Tiene un libro complementario", + "LabelHasEbook": "Tiene libro-e", + "LabelHasSupplementaryEbook": "Tiene un libro-e suplementario", "LabelHideSubtitles": "Ocultar subtítulos", "LabelHighestPriority": "Mayor prioridad", "LabelHost": "Anfitrión", @@ -422,6 +423,7 @@ "LabelLanguages": "Idiomas", "LabelLastBookAdded": "Último libro añadido", "LabelLastBookUpdated": "Último libro actualizado", + "LabelLastProgressDate": "Último progreso: {0}", "LabelLastSeen": "Última Vez Visto", "LabelLastTime": "Última Vez", "LabelLastUpdate": "Última Actualización", @@ -434,6 +436,9 @@ "LabelLibraryFilterSublistEmpty": "Sin {0}", "LabelLibraryItem": "Elemento de Biblioteca", "LabelLibraryName": "Nombre de Biblioteca", + "LabelLibrarySortByProgress": "Progreso: Último actualizado", + "LabelLibrarySortByProgressFinished": "Progreso: Finalizado", + "LabelLibrarySortByProgressStarted": "Progreso: Iniciado", "LabelLimit": "Limites", "LabelLineSpacing": "Interlineado", "LabelListenAgain": "Volver a escuchar", @@ -442,6 +447,7 @@ "LabelLogLevelWarn": "Advertencia", "LabelLookForNewEpisodesAfterDate": "Buscar Nuevos Episodios a partir de esta Fecha", "LabelLowestPriority": "Menor prioridad", + "LabelMatchConfidence": "Confidencia", "LabelMatchExistingUsersBy": "Emparejar a los usuarios existentes por", "LabelMatchExistingUsersByDescription": "Se utiliza para conectar usuarios existentes. Una vez conectados, los usuarios serán emparejados por un identificador único de su proveedor de SSO", "LabelMaxEpisodesToDownload": "Número máximo # de episodios para descargar. Usa 0 para descargar una cantidad ilimitada.", @@ -460,7 +466,7 @@ "LabelMissingEbook": "No tiene libro electrónico", "LabelMissingSupplementaryEbook": "No tiene libro electrónico suplementario", "LabelMobileRedirectURIs": "URIs de redirección a móviles permitidos", - "LabelMobileRedirectURIsDescription": "Esta es una lista blanca de URI de redireccionamiento válidos para aplicaciones móviles. El predeterminado es audiobookshelf , que puede eliminar o complementar con URI adicionales para la integración de aplicaciones de terceros. Usando un asterisco ( * ) como única entrada que permite cualquier URI.", + "LabelMobileRedirectURIsDescription": "Esta es una lista en blanco de las URI de re‐direccionamiento válidos para aplicaciones móviles. El predeterminado es audiobookshelf , que puede retirar o sustituir con las URI adicionales para la integración de aplicaciones de terceros. Usando un asterisco (* ) como única entrada que permite cualquier URI.", "LabelMore": "Más", "LabelMoreInfo": "Más información", "LabelName": "Nombre", @@ -473,9 +479,10 @@ "LabelNextBackupDate": "Fecha del siguiente respaldo", "LabelNextChapters": "Los próximos capítulos serán:", "LabelNextScheduledRun": "Próxima ejecución programada", + "LabelNoApiKeys": "Sin claves API", "LabelNoCustomMetadataProviders": "Sin proveedores de metadatos personalizados", "LabelNoEpisodesSelected": "Ningún Episodio Seleccionado", - "LabelNotFinished": "No terminado", + "LabelNotFinished": "No finalizado", "LabelNotStarted": "Sin iniciar", "LabelNotes": "Notas", "LabelNotificationAppriseURL": "URL(s) de Apprise", @@ -489,7 +496,7 @@ "LabelNotificationsMaxQueueSizeHelp": "Las notificaciones están limitadas a 1 por segundo. Las notificaciones serán ignoradas si llegan al numero máximo de cola para prevenir spam de eventos.", "LabelNumberOfBooks": "Número de libros", "LabelNumberOfChapters": "Número de capítulos:", - "LabelNumberOfEpisodes": "N.º de episodios", + "LabelNumberOfEpisodes": "Nº de episodios", "LabelOpenIDAdvancedPermsClaimDescription": "Nombre de la notificación de OpenID que contiene permisos avanzados para acciones de usuario dentro de la aplicación que se aplicarán a roles que no sean de administrador (si están configurados). Si el reclamo no aparece en la respuesta, se denegará el acceso a ABS. Si falta una sola opción, se tratará como falsa. Asegúrese de que la notificación del proveedor de identidades coincida con la estructura esperada:", "LabelOpenIDClaims": "Deje las siguientes opciones vacías para desactivar la asignación avanzada de grupos y permisos, lo que asignaría de manera automática al grupo «Usuario».", "LabelOpenIDGroupClaimDescription": "Nombre de la declaración OpenID que contiene una lista de grupos del usuario. Comúnmente conocidos como grupos. Si se configura, la aplicación asignará automáticamente roles en función de la pertenencia a grupos del usuario, siempre que estos grupos se denominen \"admin\", \"user\" o \"guest\" en la notificación. La solicitud debe contener una lista, y si un usuario pertenece a varios grupos, la aplicación asignará el rol correspondiente al mayor nivel de acceso. Si ningún grupo coincide, se denegará el acceso.", @@ -519,7 +526,7 @@ "LabelPodcasts": "Pódcast", "LabelPort": "Puerto", "LabelPrefixesToIgnore": "Prefijos para ignorar (no distingue entre mayúsculas y minúsculas)", - "LabelPreventIndexing": "Evite que los directorios de pódcast de iTunes y Google indicen su suministro", + "LabelPreventIndexing": "Evite que los directorios de pódcast de iTunes y Google indexen su suministro", "LabelPrimaryEbook": "Libro electrónico principal", "LabelProgress": "Progreso", "LabelProvider": "Proveedor", @@ -531,11 +538,11 @@ "LabelPublishedDecades": "Décadas publicadas", "LabelPublisher": "Editor", "LabelPublishers": "Editores", - "LabelRSSFeedCustomOwnerEmail": "Correo electrónico de dueño personalizado", - "LabelRSSFeedCustomOwnerName": "Nombre de dueño personalizado", + "LabelRSSFeedCustomOwnerEmail": "Correo-e de propietario personalizado", + "LabelRSSFeedCustomOwnerName": "Nombre de propietario personalizado", "LabelRSSFeedOpen": "Fuente RSS Abierta", "LabelRSSFeedPreventIndexing": "Evitar indización", - "LabelRSSFeedSlug": "«Slug» de suministro RSS", + "LabelRSSFeedSlug": "Ficha de suministro RSS", "LabelRSSFeedURL": "URL de suministro RSS", "LabelRandomly": "Aleatorio", "LabelReAddSeriesToContinueListening": "Volver a agregar la serie para continuar escuchándola", @@ -563,11 +570,12 @@ "LabelSelectAll": "Seleccionar todo", "LabelSelectAllEpisodes": "Seleccionar todos los episodios", "LabelSelectEpisodesShowing": "Seleccionar los {0} episodios visibles", + "LabelSelectUser": "Seleccionar usuario", "LabelSelectUsers": "Seleccionar usuarios", "LabelSendEbookToDevice": "Enviar libro electrónico a...", "LabelSequence": "Secuencia", "LabelSerial": "En serie", - "LabelSeries": "Serie", + "LabelSeries": "Series", "LabelSeriesName": "Nombre de la serie", "LabelSeriesProgress": "Progreso de la serie", "LabelServerLogLevel": "Nivel de registro del servidor", @@ -580,8 +588,8 @@ "LabelSettingsBookshelfViewHelp": "Diseño Esqueuomorfo con Estantes de Madera", "LabelSettingsChromecastSupport": "Compatibilidad con Chromecast", "LabelSettingsDateFormat": "Formato de Fecha", - "LabelSettingsEnableWatcher": "Buscar cambios automáticamente en las bibliotecas", - "LabelSettingsEnableWatcherForLibrary": "Buscar cambios automáticamente en la biblioteca", + "LabelSettingsEnableWatcher": "Vigilar automáticamente los cambios en bibliotecas", + "LabelSettingsEnableWatcherForLibrary": "Vigilar automáticamente los cambios de biblioteca", "LabelSettingsEnableWatcherHelp": "Permite agregar/actualizar elementos automáticamente cuando se detectan cambios en los archivos. *Requiere reiniciar el servidor", "LabelSettingsEpubsAllowScriptedContent": "Permitir scripts en epubs", "LabelSettingsEpubsAllowScriptedContentHelp": "Permitir que los archivos epub ejecuten scripts. Se recomienda mantener esta opción desactivada a menos que confíe en el origen de los archivos epub.", @@ -614,14 +622,14 @@ "LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca", "LabelSettingsTimeFormat": "Formato de Tiempo", "LabelShare": "Compartir", - "LabelShareDownloadableHelp": "Permite a quienes posean el enlace de compartición descargar un archivo ZIP del elemento de la biblioteca.", + "LabelShareDownloadableHelp": "Permite a quienes posean el enlace de compartición descargar un archivo zip del elemento de la biblioteca.", "LabelShareOpen": "abrir un recurso compartido", "LabelShareURL": "Compartir la URL", "LabelShowAll": "Mostrar todo", "LabelShowSeconds": "Mostrar segundos", "LabelShowSubtitles": "Mostrar subtítulos", "LabelSize": "Tamaño", - "LabelSleepTimer": "Temporizador de apagado", + "LabelSleepTimer": "Temporizador de dormida", "LabelSlug": "Slug", "LabelSortAscending": "Ascendente", "LabelSortDescending": "Descendente", @@ -630,6 +638,7 @@ "LabelStartTime": "Tiempo de Inicio", "LabelStarted": "Iniciado", "LabelStartedAt": "Iniciado En", + "LabelStartedDate": "Iniciado {0}", "LabelStatsAudioTracks": "Pistas de Audio", "LabelStatsAuthors": "Autores", "LabelStatsBestDay": "Mejor día", @@ -659,6 +668,7 @@ "LabelTheme": "Tema", "LabelThemeDark": "Oscuro", "LabelThemeLight": "Claro", + "LabelThemeSepia": "Sepia", "LabelTimeBase": "Tiempo Base", "LabelTimeDurationXHours": "{0} horas", "LabelTimeDurationXMinutes": "{0} minutos", @@ -727,8 +737,10 @@ "MessageAddToPlayerQueue": "Agregar a fila del Reproductor", "MessageAppriseDescription": "Para usar esta función deberás tener la API de Apprise corriendo o una API que maneje los mismos resultados.
La URL de la API de Apprise debe tener la misma ruta de archivos que donde se envían las notificaciones. Por ejemplo: si su API esta en http://192.168.1.1:8337 entonces pondría http://192.168.1.1:8337/notify.", "MessageAsinCheck": "Cerciórese de usar el ASIN de la región correcta de Audible, no de Amazon.", + "MessageAuthenticationLegacyTokenWarning": "Los vales de API heredados serán retirados en el futuro. Utilice las claves de API en su lugar.", "MessageAuthenticationOIDCChangesRestart": "Reinicie el servidor tras el guardado para aplicar los cambios de OIDC.", - "MessageBackupsDescription": "Los respaldos incluyen: usuarios, el progreso del los usuarios, los detalles de los elementos de la biblioteca, la configuración del servidor y las imágenes en /metadata/items y /metadata/authors. Los Respaldos NO incluyen ningún archivo guardado en la carpeta de tu biblioteca.", + "MessageAuthenticationSecurityMessage": "La autenticación ha sido mejorada para seguridad. Todos los usuarios requieren reiniciar sesión.", + "MessageBackupsDescription": "Los respaldos incluyen: usuarios, el progreso del los usuarios, los detalles de los elementos de la biblioteca, la configuración del servidor y las imágenes en /metadata/items y /metadata/authors. Los Respaldos no incluyen ningún archivo guardado en la carpeta de tu biblioteca.", "MessageBackupsLocationEditNote": "Nota: actualizar la ubicación de la copia de respaldo no moverá ni modificará los respaldos existentes", "MessageBackupsLocationNoEditNote": "Nota: la ubicación de la copia de respaldo se establece a través de una variable de entorno y no se puede cambiar aquí.", "MessageBackupsLocationPathEmpty": "La ruta de la copia de seguridad no puede estar vacía", @@ -750,6 +762,7 @@ "MessageChaptersNotFound": "Capítulos no encontrados", "MessageCheckingCron": "Revisando cron...", "MessageConfirmCloseFeed": "¿Confirma que quiere cerrar este suministro?", + "MessageConfirmDeleteApiKey": "¿Está seguro que desea eliminar la clave API «{0}»?", "MessageConfirmDeleteBackup": "¿Confirma que quiere eliminar el respaldo de {0}?", "MessageConfirmDeleteDevice": "¿Confirma que quiere eliminar el lector electrónico «{0}»?", "MessageConfirmDeleteFile": "Esto eliminará el archivo del sistema de archivos. ¿Quiere continuar?", @@ -768,8 +781,8 @@ "MessageConfirmMarkSeriesFinished": "¿Confirma que quiere marcar todos los libros de esta serie como terminados?", "MessageConfirmMarkSeriesNotFinished": "¿Confirma que quiere marcar todos los libros de esta serie como no terminados?", "MessageConfirmNotificationTestTrigger": "¿Activar esta notificación con datos de prueba?", - "MessageConfirmPurgeCache": "Purgar la antememoria eliminará el directorio completo ubicado en /metadata/cache.

¿Confirma que quiere eliminar el directorio de antememoria?", - "MessageConfirmPurgeItemsCache": "Purgar la antememoria de elementos eliminará el directorio completo ubicado en /metadata/cache/items.
¿Lo confirma?", + "MessageConfirmPurgeCache": "La purga del caché eliminará el directorio completo en /metadata/cache.

¿Confirma que desea quitar el directorio de caché?", + "MessageConfirmPurgeItemsCache": "Purgar el caché de elementos eliminará el directorio completo ubicado en /metadata/cache/items.
¿Lo confirma?", "MessageConfirmQuickEmbed": "Atención: la incrustación rápida no realiza copias de respaldo a ninguno de sus archivos de audio. Cerciórese de haber realizado una copia de los mismos previamente.

¿Quiere continuar?", "MessageConfirmQuickMatchEpisodes": "El reconocimiento rápido de extensiones sobrescribirá los detalles si se encuentra una coincidencia. Se actualizarán las extensiones no reconocidas. ¿Quiere continuar?", "MessageConfirmReScanLibraryItems": "¿Confirma que quiere volver a analizar {0} elementos?", @@ -795,14 +808,16 @@ "MessageDaysListenedInTheLastYear": "{0} días escuchados el año pasado", "MessageDownloadingEpisode": "Descargando episodio", "MessageDragFilesIntoTrackOrder": "Arrastre los archivos al orden correcto de las pistas", - "MessageEmbedFailed": "Falló la incrustación.", - "MessageEmbedFinished": "Finalizó la incrustación.", + "MessageEmbedFailed": "Incorporación incorrecta.", + "MessageEmbedFinished": "Incorporación finalizada.", "MessageEmbedQueue": "En cola para incrustar metadatos ({0} en cola)", "MessageEpisodesQueuedForDownload": "{0} episodio(s) en cola para descargar", "MessageEreaderDevices": "Para garantizar la entrega de libros electrónicos, es posible que tenga que agregar la dirección de correo electrónico anterior como remitente válido para cada dispositivo enumerado a continuación.", "MessageFeedURLWillBe": "El URL del suministro será {0}", "MessageFetching": "Recuperando...", "MessageForceReScanDescription": "Escaneará todos los archivos como un nuevo escaneo. Archivos de audio con etiquetas ID3, archivos OPF y archivos de texto serán escaneados como nuevos.", + "MessageHeatmapListeningTimeTooltip": "{0} escuchando en {1}", + "MessageHeatmapNoListeningSessions": "No enumera sesiones en {0}", "MessageImportantNotice": "¡Notificación importante!", "MessageInsertChapterBelow": "Insertar capítulo debajo", "MessageInvalidAsin": "ASIN no válido", @@ -835,7 +850,7 @@ "MessageNoEpisodes": "Ningún episodio", "MessageNoFoldersAvailable": "Ninguna carpeta disponible", "MessageNoGenres": "Ningún género", - "MessageNoIssues": "Ningún número", + "MessageNoIssues": "Sin incidencias", "MessageNoItems": "Ningún elemento", "MessageNoItemsFound": "Ningún elemento encontrado", "MessageNoListeningSessions": "Ninguna sesión de escucha", @@ -873,7 +888,7 @@ "MessageResetChaptersConfirm": "¿Confirma que quiere deshacer los cambios y restablecer los capítulos a su estado original?", "MessageRestoreBackupConfirm": "¿Confirma que quiere restaurar el respaldo creado el", "MessageRestoreBackupWarning": "Restaurar sobrescribirá toda la base de datos localizada en /config y las imágenes de portadas en /metadata/items y /metadata/authors.

El respaldo no modifica ningún archivo en las carpetas de su biblioteca. Si ha habilitado la opción del servidor para almacenar portadas y metadata en las carpetas de su biblioteca, esos archivos no se respaldan o sobrescriben.

Todos los clientes que usen su servidor se actualizarán automáticamente.", - "MessageScheduleLibraryScanNote": "Para la mayoría de los usuarios, se recomienda dejar esta función desactivada y mantener activada la configuración del observador de carpetas. El observador de carpetas detectará automáticamente los cambios en las carpetas de la biblioteca. El observador de carpetas no funciona para todos los sistemas de archivos (como NFS), por lo que se pueden utilizar exploraciones programadas de la biblioteca en su lugar.", + "MessageScheduleLibraryScanNote": "Para muchos usuarios, es recomendado dejar esta característica inhabilitada y mantener habilitados los ajustes de la «Vigía automática de cambio de biblioteca»: detectará automáticamente los cambios en sus carpetas de bibliotecas. Habilitar esta características si «Vigía automática de cambio de biblioteca» no funciona en su sistema de archivo (como NFS).", "MessageScheduleRunEveryWeekdayAtTime": "Ejecutar cada {0} a las {1}", "MessageSearchResultsFor": "Resultados de la búsqueda de", "MessageSelected": "{0} seleccionado(s)", @@ -999,6 +1014,8 @@ "ToastBulkChapterInvalidCount": "Por favor ingrese un número válido entre 1 y 150", "ToastCachePurgeFailed": "No se pudo purgar la antememoria", "ToastCachePurgeSuccess": "Se purgó la antememoria correctamente", + "ToastChapterLocked": "El capítulo está bloqueado.", + "ToastChapterStartTimeAdjusted": "El capítulo inicia el tiempo ajustado en {0} segundos", "ToastChaptersAllLocked": "Todos los capítulos están bloqueados. Desbloquee algunos capítulos para cambiar sus tiempos.", "ToastChaptersHaveErrors": "Los capítulos tienen errores", "ToastChaptersInvalidShiftAmountLast": "Cantidad de desplazamiento no válida. La hora de inicio del último capítulo se extendería más allá de la duración de este audiolibro.", @@ -1009,6 +1026,8 @@ "ToastCollectionItemsAddFailed": "Artículo(s) añadido(s) a la colección fallido(s)", "ToastCollectionRemoveSuccess": "Colección quitada", "ToastCollectionUpdateSuccess": "Colección actualizada", + "ToastConnectionNotAvailable": "Conexión no disponible. Intenta de nuevo más tarde", + "ToastCoverSearchFailed": "Cobertura de búsqueda incorrecta", "ToastCoverUpdateFailed": "Error al actualizar la cubierta", "ToastDateTimeInvalidOrIncomplete": "Fecha y hora no válidas o incompletas", "ToastDeleteFileFailed": "Falló la eliminación del archivo", @@ -1024,6 +1043,8 @@ "ToastEpisodeDownloadQueueClearSuccess": "Se borró la cola de descargas de los episodios", "ToastEpisodeUpdateSuccess": "{0} episodio(s) actualizado(s)", "ToastErrorCannotShare": "No se puede compartir de forma nativa en este dispositivo", + "ToastFailedToCreate": "Ha fallado al crear", + "ToastFailedToDelete": "Ha fallado al eliminar", "ToastFailedToLoadData": "Error al cargar data", "ToastFailedToMatch": "Error al emparejar", "ToastFailedToShare": "Error al compartir", @@ -1031,6 +1052,7 @@ "ToastInvalidImageUrl": "URL de la imagen no válida", "ToastInvalidMaxEpisodesToDownload": "Número máximo de episodios para descargar no válidos", "ToastInvalidUrl": "URL no válida", + "ToastInvalidUrls": "Una o más URL son inválidas", "ToastItemCoverUpdateSuccess": "Cubierta del elemento actualizada", "ToastItemDeletedFailed": "Error al eliminar el elemento", "ToastItemDeletedSuccess": "Elemento borrado", @@ -1055,6 +1077,7 @@ "ToastMustHaveAtLeastOnePath": "Debe tener al menos una ruta", "ToastNameEmailRequired": "Son obligatorios el nombre y el correo electrónico", "ToastNameRequired": "Nombre obligatorio", + "ToastNewApiKeyUserError": "Debe seleccionar un usuario", "ToastNewEpisodesFound": "{0} nuevo(s) episodio(s) encontrado(s)", "ToastNewUserCreatedFailed": "No se pudo crear la cuenta: «{0}»", "ToastNewUserCreatedSuccess": "Nueva cuenta creada", @@ -1093,8 +1116,8 @@ "ToastRemoveFailed": "Error al eliminar", "ToastRemoveItemFromCollectionFailed": "Error al eliminar el elemento de la colección", "ToastRemoveItemFromCollectionSuccess": "Elemento eliminado de la colección", - "ToastRemoveItemsWithIssuesFailed": "Error en la eliminación de artículos de biblioteca incorrectos", - "ToastRemoveItemsWithIssuesSuccess": "Se eliminaron artículos de biblioteca incorrectos", + "ToastRemoveItemsWithIssuesFailed": "Error en la eliminación de artículos de biblioteca con incidencias", + "ToastRemoveItemsWithIssuesSuccess": "Se eliminaron artículos de biblioteca con incidencias", "ToastRenameFailed": "Error al cambiar el nombre", "ToastRescanFailed": "Error al volver a escanear para {0}", "ToastRescanRemoved": "Se eliminó el elemento reescaneado", @@ -1133,6 +1156,7 @@ "ToastUserRootRequireName": "Debe introducir un nombre de usuario administrativo", "TooltipAddChapters": "Añadir capítulo(s)", "TooltipAddOneSecond": "Añadir 1 segundo", + "TooltipAdjustChapterStart": "Pulse para ajustar la hora de inicio", "TooltipLockAllChapters": "Bloquear todos los capítulos", "TooltipLockChapter": "Bloquear capítulo (Mayús+clic para rango)", "TooltipSubtractOneSecond": "Restar 1 segundo", diff --git a/client/strings/fr.json b/client/strings/fr.json index 497426d37..507574033 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -622,7 +622,7 @@ "LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les fichiers de métadonnées sont stockés dans /metadata/items. En activant ce paramètre, les fichiers de métadonnées seront stockés dans les dossiers des éléments de votre bibliothèque", "LabelSettingsTimeFormat": "Format d’heure", "LabelShare": "Partager", - "LabelShareDownloadableHelp": "Permet aux utilisateurs de télécharger un fichier ZIP de l'élément de la bibliothèque.", + "LabelShareDownloadableHelp": "Permet aux utilisateurs disposant du lien de partage de télécharger un fichier zip contenant l'élément de la bibliothèque.", "LabelShareOpen": "Ouvrir le partage", "LabelShareURL": "Partager l’URL", "LabelShowAll": "Tout afficher", diff --git a/client/strings/hu.json b/client/strings/hu.json index 90f58343a..4c36af869 100644 --- a/client/strings/hu.json +++ b/client/strings/hu.json @@ -16,7 +16,7 @@ "ButtonBrowseForFolder": "Mappa keresése", "ButtonCancel": "Mégse", "ButtonCancelEncode": "Kódolás megszakítása", - "ButtonChangeRootPassword": "Gyökérjelszó megváltoztatása", + "ButtonChangeRootPassword": "Root jelszó megváltoztatása", "ButtonCheckAndDownloadNewEpisodes": "Új epizódok ellenőrzése és letöltése", "ButtonChooseAFolder": "Válassz egy mappát", "ButtonChooseFiles": "Fájlok kiválasztása", diff --git a/client/strings/it.json b/client/strings/it.json index d2178cbf6..4b3921624 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -34,7 +34,7 @@ "ButtonEditChapters": "Modifica Capitoli", "ButtonEditPodcast": "Modifica Podcast", "ButtonEnable": "Abilita", - "ButtonFireAndFail": "Fire and Fail", + "ButtonFireAndFail": "Centro e fallimento", "ButtonFireOnTest": "Fire onTest event", "ButtonForceReScan": "Forza Re-Scan", "ButtonFullPath": "Percorso Completo", @@ -182,7 +182,7 @@ "HeaderPlaylist": "Playlist", "HeaderPlaylistItems": "Elementi della playlist", "HeaderPodcastsToAdd": "Podcasts da Aggiungere", - "HeaderPresets": "Presets", + "HeaderPresets": "Preimpostazioni", "HeaderPreviewCover": "Anteprima Cover", "HeaderRSSFeedGeneral": "Dettagli RSS", "HeaderRSSFeedIsOpen": "RSS Feed è aperto", @@ -306,7 +306,7 @@ "LabelCustomCronExpression": "Espressione Cron personalizzata:", "LabelDatetime": "Data & Ora", "LabelDays": "Giorni", - "LabelDeleteFromFileSystemCheckbox": "Elimina dal file system (togli la spunta per eliminarla solo dal DB)", + "LabelDeleteFromFileSystemCheckbox": "Elimina dal file system (despunta per rimuoverla solo dal database)", "LabelDescription": "Descrizione", "LabelDeselectAll": "Deseleziona Tutto", "LabelDetectedPattern": "Trovato pattern:", @@ -436,9 +436,9 @@ "LabelLibraryFilterSublistEmpty": "Nessuno {0}", "LabelLibraryItem": "Elementi della biblioteca", "LabelLibraryName": "Nome della biblioteca", - "LabelLibrarySortByProgress": "Progressi: Ultimi aggiornamenti", - "LabelLibrarySortByProgressFinished": "Progressi: Completati", - "LabelLibrarySortByProgressStarted": "Progressi: Iniziati", + "LabelLibrarySortByProgress": "Progresso: ultimo aggiornamento", + "LabelLibrarySortByProgressFinished": "Progresso: finito", + "LabelLibrarySortByProgressStarted": "Progresso: iniziato", "LabelLimit": "Limiti", "LabelLineSpacing": "Interlinea", "LabelListenAgain": "Ascolta ancora", @@ -497,7 +497,7 @@ "LabelNumberOfBooks": "Numero di libri", "LabelNumberOfChapters": "Numero di capitoli:", "LabelNumberOfEpisodes": "Numero di episodi", - "LabelOpenIDAdvancedPermsClaimDescription": "Nome dell'attestazione OpenID che contiene autorizzazioni avanzate per le azioni dell'utente all'interno dell'applicazione che verranno applicate ai ruoli non amministratori (se configurato). Se il reclamo manca nella risposta, l'accesso ad ABS verrà negato. Se manca una singola opzione, verrà trattata comefalsa. Assicurati che l'attestazione del provider di identità corrisponda alla struttura prevista:", + "LabelOpenIDAdvancedPermsClaimDescription": "Nome dell'attestazione OpenID che contiene autorizzazioni avanzate per le azioni dell'utente all'interno dell'applicazione che verranno applicate ai ruoli non amministrativi (se configurato). Se il reclamo manca nella risposta, l'accesso ad ABS verrà negato. Se manca una singola opzione, verrà trattata come falso. Assicurati che l'attestazione del provider di identità corrisponda alla struttura prevista:", "LabelOpenIDClaims": "Lasciare vuote le seguenti opzioni per disabilitare l'assegnazione avanzata di gruppi e autorizzazioni, assegnando quindi automaticamente il gruppo \"Utente\".", "LabelOpenIDGroupClaimDescription": "Nome dell'attestazione OpenID che contiene un elenco dei gruppi dell'utente. Comunemente indicato come gruppo. se configurato, l'applicazione assegnerà automaticamente i ruoli in base alle appartenenze ai gruppi dell'utente, a condizione che tali gruppi siano denominati \"admin\", \"utente\" o \"ospite\" senza distinzione tra maiuscole e minuscole nell'attestazione. L'attestazione deve contenere un elenco e, se un utente appartiene a più gruppi, l'applicazione assegnerà il ruolo corrispondente al livello di accesso più alto. Se nessun gruppo corrisponde, l'accesso verrà negato.", "LabelOpenRSSFeed": "Apri RSS Feed", @@ -530,7 +530,7 @@ "LabelPrimaryEbook": "Libro principale", "LabelProgress": "Cominciati", "LabelProvider": "Fornitore", - "LabelProviderAuthorizationValue": "Authorization Header Value", + "LabelProviderAuthorizationValue": "Valore intestazione di autorizzazione", "LabelPubDate": "Data di pubblicazione", "LabelPublishYear": "Anno di pubblicazione", "LabelPublishedDate": "Pubblicati {0}", @@ -674,7 +674,7 @@ "LabelTimeDurationXMinutes": "{0} minuti", "LabelTimeDurationXSeconds": "{0} secondi", "LabelTimeInMinutes": "Tempo in minuti", - "LabelTimeLeft": "{0} sinistra", + "LabelTimeLeft": "{0} rimasti", "LabelTimeListened": "Tempo di Ascolto", "LabelTimeListenedToday": "Tempo di Ascolto Oggi", "LabelTimeRemaining": "{0} rimanente", @@ -682,7 +682,7 @@ "LabelTitle": "Titolo", "LabelToolsEmbedMetadata": "Incorpora Metadata", "LabelToolsEmbedMetadataDescription": "Incorpora i metadati nei file audio, inclusi l'immagine di copertina e i capitoli.", - "LabelToolsM4bEncoder": "M4B Encoder", + "LabelToolsM4bEncoder": "Codificatore M4B", "LabelToolsMakeM4b": "Crea un file M4B", "LabelToolsMakeM4bDescription": "Genera un file audiolibro M4B con metadati incorporati, immagine di copertina e capitoli.", "LabelToolsSplitM4b": "Converti M4B in MP3", @@ -854,7 +854,7 @@ "MessageNoItems": "Nessun oggetto", "MessageNoItemsFound": "Nessun oggetto trovato", "MessageNoListeningSessions": "Nessuna sessione di ascolto", - "MessageNoLogs": "Nessun Log", + "MessageNoLogs": "Nessun rapporto", "MessageNoMediaProgress": "Nessun progresso multimediale", "MessageNoNotifications": "Nessuna notifica", "MessageNoPodcastFeed": "Podcast non valido: nessun feed", @@ -1109,7 +1109,7 @@ "ToastProgressIsNotBeingSynced": "L'avanzamento non è sincronizzato, riavviare la riproduzione", "ToastProviderCreatedFailed": "Impossibile aggiungere il provider", "ToastProviderCreatedSuccess": "Aggiunto nuovo provider", - "ToastProviderNameAndUrlRequired": "Nome e URL richiesti", + "ToastProviderNameAndUrlRequired": "Nome e Url richiesti", "ToastProviderRemoveSuccess": "Provider rimosso", "ToastRSSFeedCloseFailed": "Errore chiusura flusso RSS", "ToastRSSFeedCloseSuccess": "Flusso RSS chiuso", diff --git a/client/strings/ja.json b/client/strings/ja.json index 0621614f4..95cfba2b8 100644 --- a/client/strings/ja.json +++ b/client/strings/ja.json @@ -16,7 +16,7 @@ "ButtonBrowseForFolder": "フォルダーを選択する", "ButtonCancel": "キャンセル", "ButtonCancelEncode": "エンコードを取り消す", - "ButtonChangeRootPassword": "Rootのパスワードを変更する", + "ButtonChangeRootPassword": "ルートのパスワードを変更する", "ButtonCheckAndDownloadNewEpisodes": "新しいエピソードを確認してダウンロード", "ButtonChooseAFolder": "フォルダーを選ぶ", "ButtonChooseFiles": "ファイルを選ぶ", @@ -29,7 +29,7 @@ "ButtonCreate": "作成", "ButtonCreateBackup": "バックアップを作成する", "ButtonDelete": "削除", - "ButtonDownloadQueue": "次に再生", + "ButtonDownloadQueue": "ダウンロードキュー", "ButtonEdit": "編集", "ButtonEditChapters": "チャプターの編集", "ButtonEditPodcast": "ポッドキャストの編集", @@ -52,9 +52,9 @@ "ButtonMatchAllAuthors": "すべての作者と紐付け", "ButtonMatchBooks": "本と紐付け", "ButtonNevermind": "中止", - "ButtonNext": "次", + "ButtonNext": "次へ", "ButtonNextChapter": "次のチャプター", - "ButtonNextItemInQueue": "キューの中の次のアイテム", + "ButtonNextItemInQueue": "キューにある次のアイテム", "ButtonOk": "はい", "ButtonOpenFeed": "フィードを開く", "ButtonOpenManager": "管理画面を開く", @@ -63,19 +63,19 @@ "ButtonPlayAll": "全て再生", "ButtonPlaying": "プレイ中", "ButtonPlaylists": "プレイリスト", - "ButtonPrevious": "先", + "ButtonPrevious": "前へ", "ButtonPreviousChapter": "前のチャプター", "ButtonProbeAudioFile": "オーディオファイルを解析", "ButtonPurgeAllCache": "全てのキャッシュを削除", "ButtonPurgeItemsCache": "項目のキャッシュを削除", "ButtonQueueAddItem": "次に再生する", - "ButtonQueueRemoveItem": "次に再生から削除", + "ButtonQueueRemoveItem": "「次に再生」から削除", "ButtonQuickEmbed": "クイック埋め込み", "ButtonQuickEmbedMetadata": "メタデータの埋め込み", "ButtonQuickMatch": "クイックマッチ", "ButtonReScan": "再スキャン", "ButtonRead": "読む", - "ButtonReadLess": "閉じる", + "ButtonReadLess": "少なく表示", "ButtonReadMore": "もっと見る", "ButtonRefresh": "再読み込み", "ButtonRemove": "削除", @@ -83,7 +83,7 @@ "ButtonRemoveAllLibraryItems": "ライブラリーの項目を全て削除", "ButtonRemoveFromContinueListening": "「続きを聴く」から削除", "ButtonRemoveFromContinueReading": "「続きを読む」から削除", - "ButtonRemoveSeriesFromContinueSeries": "「シリーズを続く」からシリーズを削除", + "ButtonRemoveSeriesFromContinueSeries": "「シリーズを続ける」からシリーズを削除", "ButtonReset": "元に戻す", "ButtonResetToDefault": "デフォルトに戻す", "ButtonRestore": "復元", @@ -108,15 +108,15 @@ "ButtonTest": "テスト", "ButtonUnlinkOpenId": "OpenID 連携解除", "ButtonUpload": "アップロード", - "ButtonUploadBackup": "バックアップのアップロード", + "ButtonUploadBackup": "バックアップをアップロード", "ButtonUploadCover": "カバー画像をアップロード", "ButtonUploadOPMLFile": "OPMLファイルをアップロード", "ButtonUserDelete": "ユーザーを削除 {0}", - "ButtonUserEdit": "ユーザを編集 {0}", + "ButtonUserEdit": "ユーザーを編集 {0}", "ButtonViewAll": "すべて表示", "ButtonYes": "はい", "ErrorUploadFetchMetadataAPI": "メタデータの取得中にエラーが発生しました", - "ErrorUploadFetchMetadataNoResults": "メタデータ取得に失敗しました。タイトルや著者名を更新してください", + "ErrorUploadFetchMetadataNoResults": "メタデータの取得に失敗しました。タイトルや作者名を更新してください", "ErrorUploadLacksTitle": "タイトルは必須です", "HeaderAccount": "アカウント", "HeaderAddCustomMetadataProvider": "カスタムメタデータプロバイダーを追加", @@ -153,8 +153,8 @@ "HeaderLastListeningSession": "直近の再生セッション", "HeaderLatestEpisodes": "最新のエピソード", "HeaderLibraries": "ライブラリー", - "HeaderLibraryFiles": "ライブラリファイル", - "HeaderLibraryStats": "ライブラリ統計", + "HeaderLibraryFiles": "ライブラリーファイル", + "HeaderLibraryStats": "ライブラリー統計", "HeaderListeningSessions": "再生セッション", "HeaderListeningStats": "再生統計", "HeaderLogin": "ログイン", @@ -172,100 +172,994 @@ "HeaderNotificationUpdate": "通知を更新", "HeaderNotifications": "通知", "HeaderOpenIDConnectAuthentication": "OpenID Connect 認証", + "HeaderOpenListeningSessions": "再生中のセッション", "HeaderOpenRSSFeed": "RSS Feedを開く", + "HeaderOtherFiles": "その他のファイル", + "HeaderPasswordAuthentication": "パスワード認証", + "HeaderPermissions": "権限", + "HeaderPlayerQueue": "次に再生", "HeaderPlayerSettings": "プレーヤーの設定", "HeaderPlaylist": "プレイリスト", "HeaderPlaylistItems": "プレイリストアイテム", + "HeaderPodcastsToAdd": "追加するポッドキャスト", + "HeaderPresets": "プリセット", + "HeaderPreviewCover": "カバープレビュー", "HeaderRSSFeedGeneral": "RSS 詳細", "HeaderRSSFeedIsOpen": "RSSフィードが開いています", + "HeaderRSSFeeds": "RSSフィード", + "HeaderRemoveEpisode": "エピソードを削除", + "HeaderRemoveEpisodes": "{0}個のエピソードを削除", + "HeaderSavedMediaProgress": "保存済みメディア進捗", + "HeaderSchedule": "スケジュール", + "HeaderScheduleEpisodeDownloads": "エピソード自動ダウンロードスケジュール", + "HeaderScheduleLibraryScans": "ライブラリー自動スキャンスケジュール", + "HeaderSession": "セッション", + "HeaderSetBackupSchedule": "バックアップスケジュールの設定", "HeaderSettings": "設定", + "HeaderSettingsDisplay": "表示", + "HeaderSettingsExperimental": "実験的な機能", "HeaderSettingsGeneral": "一般", "HeaderSettingsScanner": "スキャナー", + "HeaderSettingsSecurity": "セキュリティ", + "HeaderSettingsWebClient": "Webクライアント", "HeaderSleepTimer": "スリープタイマー", - "HeaderStatsMinutesListeningChart": "過去7日間の視聴時間(分)", + "HeaderStatsLargestItems": "最大のアイテム", + "HeaderStatsLongestItems": "最長のアイテム(時間)", + "HeaderStatsMinutesListeningChart": "過去7日間のリスニング時間(分)", "HeaderStatsRecentSessions": "最近の再生履歴", + "HeaderStatsTop10Authors": "上位10作者", + "HeaderStatsTop5Genres": "上位5ジャンル", "HeaderTableOfContents": "目次", + "HeaderTools": "ツール", + "HeaderUpdateAccount": "アカウントの更新", + "HeaderUpdateApiKey": "APIキーの更新", + "HeaderUpdateAuthor": "作者の更新", + "HeaderUpdateDetails": "詳細の更新", + "HeaderUpdateLibrary": "ライブラリーの更新", + "HeaderUsers": "ユーザー", + "HeaderYearReview": "{0}年の振り返り", "HeaderYourStats": "再生統計", + "LabelAbridged": "要約版", + "LabelAbridgedChecked": "要約版(チェック済み)", + "LabelAbridgedUnchecked": "完全版(未チェック)", + "LabelAccessibleBy": "アクセス可能なユーザー", + "LabelAccountType": "アカウントの種類", + "LabelAccountTypeAdmin": "管理者", + "LabelAccountTypeGuest": "ゲスト", + "LabelAccountTypeUser": "ユーザー", + "LabelActivities": "アクティビティー", + "LabelActivity": "アクティビティー", + "LabelAddToCollection": "コレクションに追加", + "LabelAddToCollectionBatch": "コレクションに{0}冊追加", "LabelAddToPlaylist": "プレイリストの追加", + "LabelAddToPlaylistBatch": "プレイリストに{0}件追加", "LabelAddedAt": "追加日時", - "LabelAddedDate": "追加日時 ­­{0}", + "LabelAddedDate": "追加日時 {0}", + "LabelAdminUsersOnly": "管理者ユーザーのみ", "LabelAll": "すべて", - "LabelAuthor": "著者", - "LabelAuthorFirstLast": "著者(名 氏)", - "LabelAuthorLastFirst": "著者(氏 名)", - "LabelAuthors": "著者", + "LabelAllEpisodesDownloaded": "すべてのエピソードをダウンロード済み", + "LabelAllUsers": "すべてのユーザー", + "LabelAllUsersExcludingGuests": "ゲストを除くすべてのユーザー", + "LabelAllUsersIncludingGuests": "ゲストを含むすべてのユーザー", + "LabelAlreadyInYourLibrary": "すでにライブラリーにあります", + "LabelApiKeyCreated": "APIキー \"{0}\" が正常に作成されました。", + "LabelApiKeyCreatedDescription": "このAPIキーを今すぐコピーしてください。二度と確認できません。", + "LabelApiKeyUser": "ユーザーの代わりに動作", + "LabelApiKeyUserDescription": "このAPIキーは代わりに動作するユーザーと同じ権限を持ちます。ログではユーザーがリクエストを行っているのと同じように表示されます。", + "LabelApiToken": "APIトークン", + "LabelAppend": "追記", + "LabelAudioBitrate": "オーディオビットレート(例: 128k)", + "LabelAudioChannels": "オーディオチャンネル(1または2)", + "LabelAudioCodec": "オーディオコーデック", + "LabelAuthor": "作者", + "LabelAuthorFirstLast": "作者(名 氏)", + "LabelAuthorLastFirst": "作者(氏 名)", + "LabelAuthors": "作者", "LabelAutoDownloadEpisodes": "エピソードの自動ダウンロード", - "LabelBooks": "ほん", - "LabelByAuthor": "著 {0}", + "LabelAutoFetchMetadata": "メタデータの自動取得", + "LabelAutoFetchMetadataHelp": "タイトル・作者・シリーズのメタデータを取得してアップロードを効率化します。アップロード後に追加のメタデータのマッチングが必要になることがあります。", + "LabelAutoLaunch": "自動起動", + "LabelAutoLaunchDescription": "ログインページにアクセスすると自動的に認証プロバイダーにリダイレクトします(手動上書きパス /login?autoLaunch=0)", + "LabelAutoRegister": "自動登録", + "LabelAutoRegisterDescription": "ログイン後に新しいユーザーを自動的に作成", + "LabelBackToUser": "ユーザーに戻る", + "LabelBackupAudioFiles": "オーディオファイルをバックアップ", + "LabelBackupLocation": "バックアップ場所", + "LabelBackupsEnableAutomaticBackups": "自動バックアップ", + "LabelBackupsEnableAutomaticBackupsHelp": "/metadata/backups に保存されたバックアップ", + "LabelBackupsMaxBackupSize": "最大バックアップサイズ(GB)(無制限は0)", + "LabelBackupsMaxBackupSizeHelp": "設定ミスを防ぐため、バックアップが設定サイズを超えると失敗します。", + "LabelBackupsNumberToKeep": "保持するバックアップ数", + "LabelBackupsNumberToKeepHelp": "一度に1つのバックアップのみ削除されます。すでにこれより多くのバックアップがある場合は手動で削除してください。", + "LabelBitrate": "ビットレート", + "LabelBonus": "ボーナス", + "LabelBooks": "本", + "LabelButtonText": "ボタンテキスト", + "LabelByAuthor": "by {0}", + "LabelChangePassword": "パスワードを変更", + "LabelChannels": "チャンネル", + "LabelChapterCount": "{0}チャプター", + "LabelChapterTitle": "チャプタータイトル", "LabelChapters": "チャプター", - "LabelClosePlayer": "プレイヤーを閉じる", + "LabelChaptersFound": "チャプターが見つかりました", + "LabelClickForMoreInfo": "クリックして詳細を表示", + "LabelClickToUseCurrentValue": "クリックして現在の値を使用", + "LabelClosePlayer": "プレーヤーを閉じる", + "LabelCodec": "コーデック", "LabelCollapseSeries": "シリーズを折りたたむ", + "LabelCollapseSubSeries": "サブシリーズを折りたたむ", + "LabelCollection": "コレクション", + "LabelCollections": "コレクション", "LabelComplete": "完了", - "LabelContinueListening": "続きから聞く", + "LabelConfirmPassword": "パスワード確認", + "LabelContinueListening": "続きから聴く", "LabelContinueReading": "続きを読む", - "LabelContinueSeries": "シリーズを続く", + "LabelContinueSeries": "シリーズを続ける", + "LabelCorsAllowed": "許可されたCORSオリジン", + "LabelCover": "カバー", + "LabelCoverImageURL": "カバー画像URL", + "LabelCoverProvider": "カバープロバイダー", + "LabelCreatedAt": "作成日時", + "LabelCronExpression": "Cron式", + "LabelCurrent": "現在", + "LabelCurrently": "現在:", + "LabelCustomCronExpression": "カスタムCron式:", + "LabelDatetime": "日時", + "LabelDays": "日", + "LabelDeleteFromFileSystemCheckbox": "ファイルシステムから削除(チェック解除するとデータベースからのみ削除)", "LabelDescription": "説明", + "LabelDeselectAll": "すべて解除", + "LabelDetectedPattern": "検出されたパターン:", + "LabelDevice": "デバイス", + "LabelDeviceInfo": "デバイス情報", + "LabelDeviceIsAvailableTo": "デバイスが使用可能な対象...", + "LabelDirectory": "ディレクトリ", + "LabelDiscFromFilename": "ファイル名からディスク", + "LabelDiscFromMetadata": "メタデータからディスク", "LabelDiscover": "おすすめ", "LabelDownload": "ダウンロード", + "LabelDownloadNEpisodes": "{0}エピソードをダウンロード", + "LabelDownloadable": "ダウンロード可能", "LabelDuration": "長さ", - "LabelEbook": "Eブック", - "LabelEbooks": "Eブック", + "LabelDurationComparisonExactMatch": "(完全一致)", + "LabelDurationComparisonLonger": "({0} 長い)", + "LabelDurationComparisonShorter": "({0} 短い)", + "LabelDurationFound": "見つかった長さ:", + "LabelEbook": "電子書籍", + "LabelEbooks": "電子書籍", + "LabelEdit": "編集", + "LabelEmail": "メール", + "LabelEmailSettingsFromAddress": "送信元アドレス", + "LabelEmailSettingsRejectUnauthorized": "未認証の証明書を拒否", + "LabelEmailSettingsRejectUnauthorizedHelp": "SSL証明書の検証を無効にすると、中間者攻撃などのセキュリティリスクにさらされる可能性があります。接続するメールサーバーを信頼している場合にのみ無効にしてください。", + "LabelEmailSettingsSecure": "セキュア", + "LabelEmailSettingsSecureHelp": "trueの場合、サーバーへの接続にTLSを使用します。falseの場合、サーバーがSTARTTLS拡張をサポートしていればTLSが使用されます。ポート465に接続する場合はtrueに設定してください。ポート587または25の場合はfalseのままにしてください。(nodemailer.com/smtp/#authentication より)", + "LabelEmailSettingsTestAddress": "テストアドレス", + "LabelEmbeddedCover": "埋め込みカバー", "LabelEnable": "有効", + "LabelEncodingBackupLocation": "元のオーディオファイルのバックアップは以下に保存されます:", + "LabelEncodingChaptersNotEmbedded": "マルチトラックのオーディオブックにはチャプターが埋め込まれません。", + "LabelEncodingClearItemCache": "定期的にアイテムキャッシュを削除してください。", + "LabelEncodingFinishedM4B": "完成したM4Bはオーディオブックフォルダーに配置されます:", + "LabelEncodingInfoEmbedded": "メタデータはオーディオブックフォルダー内のオーディオトラックに埋め込まれます。", + "LabelEncodingStartedNavigation": "タスクが開始されたら、このページから離れることができます。", + "LabelEncodingTimeWarning": "エンコードには最大30分かかる場合があります。", + "LabelEncodingWarningAdvancedSettings": "警告: ffmpegエンコードオプションに詳しくない場合はこれらの設定を変更しないでください。", + "LabelEncodingWatcherDisabled": "ウォッチャーを無効にしている場合は、後でこのオーディオブックを再スキャンする必要があります。", "LabelEnd": "終了", "LabelEndOfChapter": "チャプターの最後", "LabelEpisode": "エピソード", + "LabelEpisodeNotLinkedToRssFeed": "RSSフィードにリンクされていないエピソード", + "LabelEpisodeNumber": "エピソード#{0}", + "LabelEpisodeTitle": "エピソードタイトル", + "LabelEpisodeType": "エピソードタイプ", + "LabelEpisodeUrlFromRssFeed": "RSSフィードのエピソードURL", "LabelEpisodes": "エピソード", "LabelEpisodic": "エピソード", + "LabelExample": "例", + "LabelExpandSeries": "シリーズを展開", + "LabelExpandSubSeries": "サブシリーズを展開", + "LabelExpired": "期限切れ", + "LabelExpiresAt": "有効期限", + "LabelExpiresInSeconds": "有効期限(秒)", + "LabelExpiresNever": "なし", "LabelExplicit": "露骨な表現", - "LabelFeedURL": "Feed URL", + "LabelExplicitChecked": "露骨な表現あり(チェック済み)", + "LabelExplicitUnchecked": "露骨な表現なし(未チェック)", + "LabelExportOPML": "OPMLをエクスポート", + "LabelFeedURL": "フィードURL", + "LabelFetchingMetadata": "メタデータ取得中", "LabelFile": "ファイル", "LabelFileBirthtime": "ファイル作成日時", + "LabelFileBornDate": "{0}に生成", "LabelFileModified": "ファイル更新日時", + "LabelFileModifiedDate": "{0}に更新", "LabelFilename": "ファイル名", + "LabelFilterByUser": "ユーザーでフィルター", + "LabelFindEpisodes": "エピソードを検索", "LabelFinished": "完了", - "LabelFolder": "フォルダ", + "LabelFinishedDate": "{0}に完了", + "LabelFolder": "フォルダー", + "LabelFolders": "フォルダー", + "LabelFontBold": "太字", "LabelFontBoldness": "フォントの太さ", "LabelFontFamily": "フォントファミリー", + "LabelFontItalic": "斜体", "LabelFontScale": "フォントサイズ", + "LabelFontStrikethrough": "打消線", + "LabelFormat": "形式", + "LabelFull": "完全", "LabelGenre": "ジャンル", "LabelGenres": "ジャンル", - "LabelHasEbook": "eBookあり", - "LabelHasSupplementaryEbook": "付属eBookあり", + "LabelHardDeleteFile": "ファイルを完全削除", + "LabelHasEbook": "電子書籍あり", + "LabelHasSupplementaryEbook": "付属電子書籍あり", + "LabelHideSubtitles": "字幕を非表示", + "LabelHighestPriority": "最高優先度", "LabelHost": "ホスト", + "LabelHour": "時間", + "LabelHours": "時間", + "LabelIcon": "アイコン", + "LabelImageURLFromTheWeb": "ウェブからの画像URL", "LabelInProgress": "進行中", + "LabelIncludeInTracklist": "トラックリストに含める", "LabelIncomplete": "未完了", + "LabelInterval": "間隔", + "LabelIntervalCustomDailyWeekly": "カスタム(毎日/曜日指定)", + "LabelIntervalEvery12Hours": "12時間ごと", + "LabelIntervalEvery15Minutes": "15分ごと", + "LabelIntervalEvery2Hours": "2時間ごと", + "LabelIntervalEvery30Minutes": "30分ごと", + "LabelIntervalEvery6Hours": "6時間ごと", + "LabelIntervalEveryDay": "毎日", + "LabelIntervalEveryHour": "1時間ごと", + "LabelIntervalEveryMinute": "1分ごと", + "LabelInvert": "反転", + "LabelItem": "アイテム", + "LabelJumpBackwardAmount": "巻き戻し量", + "LabelJumpForwardAmount": "早送り量", "LabelLanguage": "言語", + "LabelLanguageDefaultServer": "デフォルトサーバー言語", "LabelLanguages": "言語", + "LabelLastBookAdded": "最後に追加された本", + "LabelLastBookUpdated": "最後に更新された本", + "LabelLastProgressDate": "最終進捗: {0}", + "LabelLastSeen": "最終確認", + "LabelLastTime": "終了時刻", + "LabelLastUpdate": "最終更新", "LabelLayout": "レイアウト", "LabelLayoutSinglePage": "単ページ", + "LabelLayoutSplitPage": "ページを分割", + "LabelLess": "少ない", + "LabelLibrariesAccessibleToUser": "ユーザーがアクセス可能なライブラリー", + "LabelLibrary": "ライブラリー", + "LabelLibraryFilterSublistEmpty": "{0}がない", + "LabelLibraryItem": "ライブラリーアイテム", + "LabelLibraryName": "ライブラリー名", + "LabelLibrarySortByProgress": "進捗: 最終更新", + "LabelLibrarySortByProgressFinished": "進捗: 完了", + "LabelLibrarySortByProgressStarted": "進捗: 開始済み", + "LabelLimit": "制限", "LabelLineSpacing": "行間", - "LabelListenAgain": "再度視聴", + "LabelListenAgain": "もう一度聴く", + "LabelLogLevelDebug": "デバッグ", + "LabelLogLevelInfo": "情報", + "LabelLogLevelWarn": "警告", + "LabelLookForNewEpisodesAfterDate": "この日以降の新しいエピソードを検索", + "LabelLowestPriority": "最低優先度", + "LabelMatchConfidence": "信頼度", + "LabelMatchExistingUsersBy": "既存ユーザーの照合方法", + "LabelMatchExistingUsersByDescription": "既存ユーザーの接続に使用されます。接続後はSSOプロバイダーの一意のIDでユーザーが照合されます", + "LabelMaxEpisodesToDownload": "最大ダウンロードエピソード数(無制限は0)。", + "LabelMaxEpisodesToDownloadPerCheck": "1回のチェックでダウンロードする最大の新着エピソード数", + "LabelMaxEpisodesToKeep": "保持する最大エピソード数", + "LabelMaxEpisodesToKeepHelp": "0は上限なし。新しいエピソードの自動ダウンロード後、X件以上のエピソードがある場合は最も古いエピソードを削除します。1回のダウンロードにつき1件のみ削除されます。", + "LabelMediaPlayer": "メディアプレーヤー", "LabelMediaType": "メディアの種類", + "LabelMetaTag": "メタタグ", + "LabelMetaTags": "メタタグ", + "LabelMetadataOrderOfPrecedenceDescription": "優先度の高いメタデータソースが優先度の低いメタデータソースを上書きします", + "LabelMetadataProvider": "メタデータプロバイダー", + "LabelMinute": "分", + "LabelMinutes": "分", + "LabelMissing": "消失", + "LabelMissingEbook": "電子書籍なし", + "LabelMissingSupplementaryEbook": "付属電子書籍なし", + "LabelMobileRedirectURIs": "許可されたモバイルリダイレクトURI", + "LabelMobileRedirectURIsDescription": "これはモバイルアプリの有効なリダイレクトURIのホワイトリストです。デフォルトは audiobookshelf://oauth です。削除するか、サードパーティーアプリ統合用の追加URIで補足できます。アスタリスク(*)を唯一のエントリとして使用すると、任意のURIが許可されます。", + "LabelMore": "多い", "LabelMoreInfo": "追加情報", - "LabelName": "名", + "LabelName": "名前", "LabelNarrator": "ナレーター", "LabelNarrators": "ナレーター", "LabelNew": "新しい", - "LabelNewPassword": "新しいのパスワード", - "LabelNewestAuthors": "最新の著者", + "LabelNewPassword": "新しいパスワード", + "LabelNewestAuthors": "最新の作者", "LabelNewestEpisodes": "最新エピソード", + "LabelNextBackupDate": "次回バックアップ日", + "LabelNextChapters": "次のチャプター:", + "LabelNextScheduledRun": "次のスケジュール実行", + "LabelNoApiKeys": "APIキーなし", + "LabelNoCustomMetadataProviders": "カスタムメタデータプロバイダーなし", + "LabelNoEpisodesSelected": "エピソードが選択されていません", + "LabelNotFinished": "未完了", + "LabelNotStarted": "未開始", + "LabelNotes": "ノート", + "LabelNotificationAppriseURL": "Apprise URL", + "LabelNotificationAvailableVariables": "利用可能な変数", + "LabelNotificationBodyTemplate": "本文テンプレート", + "LabelNotificationEvent": "通知イベント", + "LabelNotificationTitleTemplate": "タイトルテンプレート", + "LabelNotificationsMaxFailedAttempts": "最大失敗試行回数", + "LabelNotificationsMaxFailedAttemptsHelp": "この回数失敗すると通知は無効になります", + "LabelNotificationsMaxQueueSize": "通知イベントの最大キューサイズ", + "LabelNotificationsMaxQueueSizeHelp": "イベントは1秒に1回の発火に制限されます。キューが最大サイズの場合、イベントは無視されます。これは通知スパムを防ぎます。", + "LabelNumberOfBooks": "本の冊数", + "LabelNumberOfChapters": "チャプター数:", + "LabelNumberOfEpisodes": "エピソード数", + "LabelOpenIDAdvancedPermsClaimDescription": "管理者以外のロールに適用されるアプリケーション内のユーザーアクション用の高度な権限を含むOpenIDクレーム名(設定されている場合)。クレームがレスポンスに含まれていない場合、ABSへのアクセスは拒否されます。単一のオプションがない場合は false として扱われます。IDプロバイダーのクレームが期待される構造と一致することを確認してください:", + "LabelOpenIDClaims": "以下のオプションを空のままにすると、高度なグループと権限の割り当てが無効になり、自動的に「User」グループが割り当てられます。", + "LabelOpenIDGroupClaimDescription": "ユーザーのグループリストを含むOpenIDクレーム名。一般的には groups と呼ばれます。設定されている場合、アプリケーションはユーザーのグループメンバーシップに基づいて自動的にロールを割り当てます。クレームのグループ名が大文字小文字を区別せずに「admin」「user」「guest」である場合、対応するロールが割り当てられます。グループが一致しない場合、アクセスは拒否されます。", + "LabelOpenRSSFeed": "RSSフィードを開く", + "LabelOverwrite": "上書き", + "LabelPaginationPageXOfY": "{0}/{1}ページ", "LabelPassword": "パスワード", "LabelPath": "パス", + "LabelPermanent": "無期限", + "LabelPermissionsAccessAllLibraries": "すべてのライブラリーにアクセス可能", + "LabelPermissionsAccessAllTags": "すべてのタグにアクセス可能", + "LabelPermissionsAccessExplicitContent": "露骨なコンテンツにアクセス可能", + "LabelPermissionsCreateEreader": "電子書籍リーダー作成可能", + "LabelPermissionsDelete": "削除可能", + "LabelPermissionsDownload": "ダウンロード可能", + "LabelPermissionsUpdate": "更新可能", + "LabelPermissionsUpload": "アップロード可能", + "LabelPersonalYearReview": "個人の年間振り返り({0})", + "LabelPhotoPathURL": "写真パス/URL", + "LabelPlayMethod": "再生方法", + "LabelPlaybackRateIncrementDecrement": "再生速度の増減量", + "LabelPlayerChapterNumberMarker": "{0}/{1}", "LabelPlaylists": "プレイリスト", "LabelPodcast": "ポッドキャスト", + "LabelPodcastSearchRegion": "ポッドキャスト検索地域", + "LabelPodcastType": "ポッドキャストタイプ", "LabelPodcasts": "ポッドキャスト", + "LabelPort": "ポート", + "LabelPrefixesToIgnore": "無視するプレフィックス(大文字小文字を区別しない)", "LabelPreventIndexing": "フィードがiTunesおよびGoogleのポッドキャストディレクトリにインデックス登録されるのを防ぎます", + "LabelPrimaryEbook": "メイン電子書籍", + "LabelProgress": "進捗", + "LabelProvider": "プロバイダー", + "LabelProviderAuthorizationValue": "認証ヘッダーの値", + "LabelPubDate": "公開日", "LabelPublishYear": "公開年", + "LabelPublishedDate": "{0}発行", + "LabelPublishedDecade": "発行年代", + "LabelPublishedDecades": "発行年代", + "LabelPublisher": "出版社", + "LabelPublishers": "出版社", + "LabelRSSFeedCustomOwnerEmail": "カスタムオーナーメール", + "LabelRSSFeedCustomOwnerName": "カスタムオーナー名", + "LabelRSSFeedOpen": "RSSフィードを公開", + "LabelRSSFeedPreventIndexing": "インデックス化を防止", + "LabelRSSFeedSlug": "RSSフィードスラグ", + "LabelRSSFeedURL": "RSSフィードURL", + "LabelRandomly": "ランダム", + "LabelReAddSeriesToContinueListening": "シリーズを再追加して「続きを聴く」に戻す", + "LabelRead": "読む", + "LabelReadAgain": "もう一度読む", + "LabelReadEbookWithoutProgress": "進捗を記録せずに電子書籍を読む", + "LabelRecentSeries": "最近のシリーズ", + "LabelRecentlyAdded": "最近追加", + "LabelRecommended": "おすすめ", + "LabelRedo": "やり直す", + "LabelRegion": "地域", + "LabelReleaseDate": "リリース日", + "LabelRemoveAllMetadataAbs": "すべてのmetadata.absファイルを削除", + "LabelRemoveAllMetadataJson": "すべてのmetadata.jsonファイルを削除", + "LabelRemoveAudibleBranding": "Audibleのイントロとアウトロをチャプターから削除", + "LabelRemoveCover": "カバーを削除", + "LabelRemoveMetadataFile": "ライブラリーアイテムフォルダー内のメタデータファイルを削除", + "LabelRemoveMetadataFileHelp": "{0}フォルダー内のすべてのmetadata.jsonおよびmetadata.absファイルを削除します。", + "LabelRowsPerPage": "1ページあたりの行数", + "LabelSearchTerm": "検索ワード", + "LabelSearchTitle": "タイトルを検索", + "LabelSearchTitleOrASIN": "タイトルまたはASINを検索", + "LabelSeason": "シーズン", + "LabelSeasonNumber": "シーズン#{0}", + "LabelSelectAll": "すべて選択", + "LabelSelectAllEpisodes": "すべてのエピソードを選択", + "LabelSelectEpisodesShowing": "表示中の{0}件のエピソードを選択", + "LabelSelectUser": "ユーザーを選択", + "LabelSelectUsers": "ユーザーを選択", + "LabelSendEbookToDevice": "電子書籍を送信...", + "LabelSequence": "シーケンス", + "LabelSerial": "シリアル", + "LabelSeries": "シリーズ", + "LabelSeriesName": "シリーズ名", + "LabelSeriesProgress": "シリーズ進捗", + "LabelServerLogLevel": "サーバーログレベル", + "LabelServerYearReview": "サーバーの年間振り返り({0})", + "LabelSetEbookAsPrimary": "メインとして設定", + "LabelSetEbookAsSupplementary": "付属として設定", + "LabelSettingsAllowIframe": "iframeへの埋め込みを許可", + "LabelSettingsAudiobooksOnly": "オーディオブックのみ", + "LabelSettingsAudiobooksOnlyHelp": "この設定を有効にすると電子書籍ファイルは無視されます。ただしオーディオブックフォルダー内の電子書籍は付属電子書籍として設定されます", + "LabelSettingsBookshelfViewHelp": "木製の棚を備えたスキューモーフィックデザイン", + "LabelSettingsChromecastSupport": "Chromecastサポート", + "LabelSettingsDateFormat": "日付フォーマット", + "LabelSettingsEnableWatcher": "ライブラリーの変更を自動的に監視する", + "LabelSettingsEnableWatcherForLibrary": "ライブラリーの変更を自動的に監視する", + "LabelSettingsEnableWatcherHelp": "ファイルの変更が検出されたときにアイテムの自動追加/更新を有効にします。*サーバーの再起動が必要です", + "LabelSettingsEpubsAllowScriptedContent": "epubのスクリプトコンテンツを許可", + "LabelSettingsEpubsAllowScriptedContentHelp": "epubファイルがスクリプトを実行することを許可します。epubファイルのソースを信頼しない限り、この設定を無効のままにすることをお勧めします。", + "LabelSettingsExperimentalFeatures": "実験的機能", + "LabelSettingsExperimentalFeaturesHelp": "開発中の機能でご意見とテストのご協力をお願いしています。クリックしてGitHubディスカッションを開く。", "LabelSettingsFindCovers": "表紙を探す", - "LabelSettingsFindCoversHelp": "もしオーディオブックに表紙が埋め込まれていない、もしくは表紙画像がフォルダー内に見つからなければ、スキャナーは表紙を探そうとします。
注記: これによってスキャン時間が長くなります", + "LabelSettingsFindCoversHelp": "もしオーディオブックに表紙が埋め込まれていない、もしくは表紙画像がフォルダー内に見つからなければ、スキャナーは表紙を探そうとします。
注: これによってスキャン時間が長くなります", + "LabelSettingsHideSingleBookSeries": "単一の本のシリーズを非表示", + "LabelSettingsHideSingleBookSeriesHelp": "単一の本を持つシリーズはシリーズページとホームページの棚から非表示になります。", + "LabelSettingsHomePageBookshelfView": "ホームページは本棚ビューを使用", + "LabelSettingsLibraryBookshelfView": "ライブラリーは本棚ビューを使用", + "LabelSettingsLibraryMarkAsFinishedPercentComplete": "完了率が次より大きい", + "LabelSettingsLibraryMarkAsFinishedTimeRemaining": "残り時間が次より少ない(秒)", + "LabelSettingsLibraryMarkAsFinishedWhen": "メディアアイテムを完了としてマークする条件", + "LabelSettingsOnlyShowLaterBooksInContinueSeries": "「シリーズを続ける」で前の本をスキップ", + "LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp": "「シリーズを続ける」のホームページ棚は、1冊以上完了していて進行中の本がないシリーズの最初の未開始の本を表示します。この設定を有効にすると、最初の未開始の本ではなく、最後に完了した本からシリーズが継続されます。", "LabelSettingsParseSubtitles": "サブタイトルを抽出する", "LabelSettingsParseSubtitlesHelp": "オーディオブックのフォルダー名からサブタイトルを抽出します。
サブタイトルは \"-\" で区切ってください
例: \"本のタイトル - ここにサブタイトル\" という名前だと \"ここにサブタイトル\" というサブタイトルになります", "LabelSettingsPreferMatchedMetadata": "一致したメタデータを優先する", "LabelSettingsPreferMatchedMetadataHelp": "クイックマッチを使用する時、一致したデータは書籍の詳細を上書きします。デフォルトでは、埋まっていない項目のみ入力されます。", + "LabelSettingsSkipMatchingBooksWithASIN": "ASINを持つ本のマッチングをスキップ", + "LabelSettingsSkipMatchingBooksWithISBN": "ISBNを持つ本のマッチングをスキップ", "LabelSettingsSortingIgnorePrefixes": "並び替えでプレフィックスを無視する", "LabelSettingsSortingIgnorePrefixesHelp": "例: プレフィックス \"the\" の付いた本のタイトル \"The Book Title\" は \"Book Title, The\" として並び替えられます", + "LabelSettingsSquareBookCovers": "正方形の本のカバーを使用", + "LabelSettingsSquareBookCoversHelp": "標準の1.6:1のカバーより正方形のカバーを優先", "LabelSettingsStoreCoversWithItem": "表紙を項目と一緒に保存する", "LabelSettingsStoreCoversWithItemHelp": "デフォルトでは表紙は /metadata/items に保存されますが、この設定をオンにするとライブラリーの項目のフォルダーに保存されます。\"cover\" という名前のファイル一つのみが保持されます", "LabelSettingsStoreMetadataWithItem": "メタデータを項目と一緒に保存する", - "LabelSettingsStoreMetadataWithItemHelp": "デフォルトではメタデータは/metadata/itemsに保存されますが、この設定をオンにするとライブラリーの項目のフォルダーに保存されます" + "LabelSettingsStoreMetadataWithItemHelp": "デフォルトではメタデータは/metadata/itemsに保存されますが、この設定をオンにするとライブラリーの項目のフォルダーに保存されます", + "LabelSettingsTimeFormat": "時刻フォーマット", + "LabelShare": "共有", + "LabelShareDownloadableHelp": "共有リンクを持つユーザーがライブラリーアイテムのzipファイルをダウンロードできるようにします。", + "LabelShareOpen": "共有を公開", + "LabelShareURL": "共有URL", + "LabelShowAll": "すべて表示", + "LabelShowSeconds": "秒を表示", + "LabelShowSubtitles": "字幕を表示", + "LabelSize": "サイズ", + "LabelSleepTimer": "スリープタイマー", + "LabelSlug": "スラグ", + "LabelSortAscending": "昇順", + "LabelSortDescending": "降順", + "LabelSortPubDate": "公開日でソート", + "LabelStart": "開始", + "LabelStartTime": "開始時刻", + "LabelStarted": "開始済み", + "LabelStartedAt": "開始日時", + "LabelStartedDate": "{0}に開始", + "LabelStatsAudioTracks": "オーディオトラック", + "LabelStatsAuthors": "作者", + "LabelStatsBestDay": "日ごとの最長", + "LabelStatsDailyAverage": "日ごとの平均", + "LabelStatsDays": "日数", + "LabelStatsDaysListened": "聴いた日数", + "LabelStatsHours": "時間", + "LabelStatsInARow": "連続", + "LabelStatsItemsFinished": "完了したアイテム", + "LabelStatsItemsInLibrary": "ライブラリー内のアイテム", + "LabelStatsMinutes": "分", + "LabelStatsMinutesListening": "聴いた時間(分)", + "LabelStatsOverallDays": "総日数", + "LabelStatsOverallHours": "総時間", + "LabelStatsWeekListening": "週間リスニング", + "LabelSubtitle": "サブタイトル", + "LabelSupportedFileTypes": "サポートされているファイルタイプ", + "LabelTag": "タグ", + "LabelTags": "タグ", + "LabelTagsAccessibleToUser": "ユーザーがアクセス可能なタグ", + "LabelTagsNotAccessibleToUser": "ユーザーがアクセスできないタグ", + "LabelTasks": "実行中のタスク", + "LabelTextEditorBulletedList": "箇条書きリスト", + "LabelTextEditorLink": "リンク", + "LabelTextEditorNumberedList": "番号付きリスト", + "LabelTextEditorUnlink": "リンク解除", + "LabelTheme": "テーマ", + "LabelThemeDark": "ダーク", + "LabelThemeLight": "ライト", + "LabelThemeSepia": "セピア", + "LabelTimeBase": "タイムベース", + "LabelTimeDurationXHours": "{0}時間", + "LabelTimeDurationXMinutes": "{0}分", + "LabelTimeDurationXSeconds": "{0}秒", + "LabelTimeInMinutes": "時間(分単位)", + "LabelTimeLeft": "残り{0}", + "LabelTimeListened": "リスニング時間", + "LabelTimeListenedToday": "本日のリスニング時間", + "LabelTimeRemaining": "残り{0}", + "LabelTimeToShift": "シフトする時間(秒単位)", + "LabelTitle": "タイトル", + "LabelToolsEmbedMetadata": "メタデータの埋め込み", + "LabelToolsEmbedMetadataDescription": "カバー画像とチャプターを含むメタデータをオーディオファイルに埋め込みます。", + "LabelToolsM4bEncoder": "M4Bエンコーダー", + "LabelToolsMakeM4b": "M4Bオーディオブックファイルを作成", + "LabelToolsMakeM4bDescription": "メタデータ、カバー画像、チャプターを埋め込んだ.M4Bオーディオブックファイルを生成します。", + "LabelToolsSplitM4b": "M4BをMP3に分割", + "LabelToolsSplitM4bDescription": "メタデータ、カバー画像、チャプターを埋め込んだチャプター分割MP3ファイルをM4Bから作成します。", + "LabelTotalDuration": "総再生時間", + "LabelTotalTimeListened": "合計リスニング時間", + "LabelTrackFromFilename": "ファイル名からトラック", + "LabelTrackFromMetadata": "メタデータからトラック", + "LabelTracks": "トラック", + "LabelTracksMultiTrack": "マルチトラック", + "LabelTracksNone": "トラックなし", + "LabelTracksSingleTrack": "シングルトラック", + "LabelTrailer": "トレーラー", + "LabelType": "タイプ", + "LabelUnabridged": "完全版", + "LabelUndo": "元に戻す", + "LabelUnknown": "不明", + "LabelUnknownPublishDate": "公開日不明", + "LabelUpdateCover": "カバーを更新", + "LabelUpdateCoverHelp": "マッチが見つかった場合、選択した本の既存のカバーの上書きを許可", + "LabelUpdateDetails": "詳細を更新", + "LabelUpdateDetailsHelp": "マッチが見つかった場合、選択した本の既存の詳細の上書きを許可", + "LabelUpdatedAt": "更新日時", + "LabelUploaderDragAndDrop": "ファイルまたはフォルダーをドラッグ&ドロップ", + "LabelUploaderDragAndDropFilesOnly": "ファイルをドラッグ&ドロップ", + "LabelUploaderDropFiles": "ファイルをドロップ", + "LabelUploaderItemFetchMetadataHelp": "タイトル、作者、シリーズを自動取得", + "LabelUseAdvancedOptions": "詳細オプションを使用", + "LabelUseChapterTrack": "チャプタートラックを使用", + "LabelUseFullTrack": "フルトラックを使用", + "LabelUseZeroForUnlimited": "0で無制限", + "LabelUser": "ユーザー", + "LabelUsername": "ユーザー名", + "LabelValue": "値", + "LabelVersion": "バージョン", + "LabelViewBookmarks": "ブックマークを表示", + "LabelViewChapters": "チャプターを表示", + "LabelViewPlayerSettings": "プレーヤー設定を表示", + "LabelViewQueue": "「次に再生」を表示", + "LabelVolume": "ボリューム", + "LabelWebRedirectURLsDescription": "ログイン後にウェブアプリへのリダイレクトを許可するため、OAuthプロバイダーでこれらのURLを認可してください:", + "LabelWebRedirectURLsSubfolder": "リダイレクトURLのサブフォルダー", + "LabelWeekdaysToRun": "実行する曜日", + "LabelXBooks": "{0}冊の本", + "LabelXItems": "{0}件のアイテム", + "LabelYearReviewHide": "年間振り返りを非表示", + "LabelYearReviewShow": "年間振り返りを表示", + "LabelYourAudiobookDuration": "オーディオブックの再生時間", + "LabelYourBookmarks": "ブックマーク", + "LabelYourPlaylists": "プレイリスト", + "LabelYourProgress": "進捗状況", + "MessageAddToPlayerQueue": "「次に再生」に追加", + "MessageAppriseDescription": "この機能を使用するには、Apprise API のインスタンスを起動するか、同じリクエストを処理できるAPIが必要です。
Apprise API URLは通知を送信するための完全なURLパスである必要があります。例えば、APIインスタンスが http://192.168.1.1:8337 で起動している場合は http://192.168.1.1:8337/notify を入力してください。", + "MessageAsinCheck": "AmazonではなくAudibleの正しいリージョンのASINを使用していることを確認してください。", + "MessageAuthenticationLegacyTokenWarning": "レガシーAPIトークンは将来削除されます。代わりに APIキー を使用してください。", + "MessageAuthenticationOIDCChangesRestart": "OIDCの変更を適用するには、保存後にサーバーを再起動してください。", + "MessageAuthenticationSecurityMessage": "セキュリティのため認証が改善されました。すべてのユーザーは再ログインが必要です。", + "MessageBackupsDescription": "バックアップにはユーザー、ユーザーの進捗状況、ライブラリーアイテムの詳細、サーバー設定、および /metadata/items/metadata/authors に保存されている画像が含まれます。バックアップにはライブラリーフォルダーに保存されているファイルは含まれません。", + "MessageBackupsLocationEditNote": "注: バックアップ場所を更新しても、既存のバックアップは移動または変更されません", + "MessageBackupsLocationNoEditNote": "注: バックアップ場所は環境変数で設定されており、ここでは変更できません。", + "MessageBackupsLocationPathEmpty": "バックアップ場所のパスは空にできません", + "MessageBatchEditPopulateMapDetailsAllHelp": "すべてのアイテムのデータで有効なフィールドを設定します。複数の値があるフィールドはマージされます", + "MessageBatchEditPopulateMapDetailsItemHelp": "このアイテムのデータで有効なマップ詳細フィールドを設定します", + "MessageBatchQuickMatchDescription": "クイックマッチは選択したアイテムの不足しているカバーとメタデータを追加しようとします。クイックマッチが既存のカバーやメタデータを上書きできるようにするには、以下のオプションを有効にしてください。", + "MessageBookshelfNoCollections": "まだコレクションを作成していません", + "MessageBookshelfNoCollectionsHelp": "コレクションは公開されています。ライブラリーにアクセスできるすべてのユーザーが見ることができます。", + "MessageBookshelfNoRSSFeeds": "公開中のRSSフィードはありません", + "MessageBookshelfNoResultsForFilter": "フィルター \"{0}: {1}\" に一致する結果がありません", + "MessageBookshelfNoResultsForQuery": "クエリに一致する結果がありません", + "MessageBookshelfNoSeries": "シリーズがありません", + "MessageBulkChapterPattern": "この番号パターンでいくつのチャプターを追加しますか?", + "MessageChapterEndIsAfter": "チャプターの終了がオーディオブックの末尾を超えています", + "MessageChapterErrorFirstNotZero": "最初のチャプターは0から始まる必要があります", + "MessageChapterErrorStartGteDuration": "開始時間はオーディオブックの長さより短くなければなりません", + "MessageChapterErrorStartLtPrev": "開始時間は前のチャプターの開始時間以上でなければなりません", + "MessageChapterStartIsAfter": "チャプターの開始がオーディオブックの末尾を超えています", + "MessageChaptersNotFound": "チャプターが見つかりません", + "MessageCheckingCron": "cronを確認中...", + "MessageConfirmCloseFeed": "このフィードを閉じてもよろしいですか?", + "MessageConfirmDeleteApiKey": "APIキー \"{0}\" を削除してもよろしいですか?", + "MessageConfirmDeleteBackup": "{0} のバックアップを削除してもよろしいですか?", + "MessageConfirmDeleteDevice": "電子書籍リーダー端末 \"{0}\" を削除してもよろしいですか?", + "MessageConfirmDeleteFile": "ファイルシステムからファイルを削除します。よろしいですか?", + "MessageConfirmDeleteLibrary": "ライブラリー \"{0}\" を完全に削除してもよろしいですか?", + "MessageConfirmDeleteLibraryItem": "データベースとファイルシステムからライブラリーアイテムを削除します。よろしいですか?", + "MessageConfirmDeleteLibraryItems": "データベースとファイルシステムから {0}件のライブラリーアイテムを削除します。よろしいですか?", + "MessageConfirmDeleteMetadataProvider": "カスタムメタデータプロバイダー \"{0}\" を削除してもよろしいですか?", + "MessageConfirmDeleteNotification": "この通知を削除してもよろしいですか?", + "MessageConfirmDeleteSession": "このセッションを削除してもよろしいですか?", + "MessageConfirmEmbedMetadataInAudioFiles": "{0}件のオーディオファイルにメタデータを埋め込んでもよろしいですか?", + "MessageConfirmForceReScan": "強制的に再スキャンしてもよろしいですか?", + "MessageConfirmMarkAllEpisodesFinished": "すべてのエピソードを完了済みにしてもよろしいですか?", + "MessageConfirmMarkAllEpisodesNotFinished": "すべてのエピソードを未完了にしてもよろしいですか?", + "MessageConfirmMarkItemFinished": "\"{0}\" を完了済みにしてもよろしいですか?", + "MessageConfirmMarkItemNotFinished": "\"{0}\" を未完了にしてもよろしいですか?", + "MessageConfirmMarkSeriesFinished": "このシリーズのすべての本を完了済みにしてもよろしいですか?", + "MessageConfirmMarkSeriesNotFinished": "このシリーズのすべての本を未完了にしてもよろしいですか?", + "MessageConfirmNotificationTestTrigger": "テストデータでこの通知をトリガーしてもよろしいですか?", + "MessageConfirmPurgeCache": "キャッシュの削除により /metadata/cache ディレクトリ全体が削除されます。

キャッシュディレクトリを削除してもよろしいですか?", + "MessageConfirmPurgeItemsCache": "アイテムキャッシュの削除により /metadata/cache/items ディレクトリ全体が削除されます。
よろしいですか?", + "MessageConfirmQuickEmbed": "警告!クイック埋め込みはオーディオファイルをバックアップしません。オーディオファイルのバックアップがあることを確認してください。

続行しますか?", + "MessageConfirmQuickMatchEpisodes": "エピソードのクイックマッチは一致が見つかった場合に詳細を上書きします。未マッチのエピソードのみ更新されます。よろしいですか?", + "MessageConfirmReScanLibraryItems": "{0}件のアイテムを再スキャンしてもよろしいですか?", + "MessageConfirmRemoveAllChapters": "すべてのチャプターを削除してもよろしいですか?", + "MessageConfirmRemoveAuthor": "作者 \"{0}\" を削除してもよろしいですか?", + "MessageConfirmRemoveCollection": "コレクション \"{0}\" を削除してもよろしいですか?", + "MessageConfirmRemoveEpisode": "エピソード \"{0}\" を削除してもよろしいですか?", + "MessageConfirmRemoveEpisodeNote": "注: 「ファイルも削除する」を有効にしない限り、オーディオファイルは削除されません", + "MessageConfirmRemoveEpisodes": "{0}件のエピソードを削除してもよろしいですか?", + "MessageConfirmRemoveListeningSessions": "{0}件のリスニングセッションを削除してもよろしいですか?", + "MessageConfirmRemoveMetadataFiles": "ライブラリーアイテムフォルダー内のすべての metadata.{0} ファイルを削除してもよろしいですか?", + "MessageConfirmRemoveNarrator": "ナレーター \"{0}\" を削除してもよろしいですか?", + "MessageConfirmRemovePlaylist": "プレイリスト \"{0}\" を削除してもよろしいですか?", + "MessageConfirmRenameGenre": "すべてのアイテムのジャンル \"{0}\" を \"{1}\" に変更してもよろしいですか?", + "MessageConfirmRenameGenreMergeNote": "注: このジャンルはすでに存在するためマージされます。", + "MessageConfirmRenameGenreWarning": "警告!大文字・小文字が異なる類似したジャンル \"{0}\" がすでに存在します。", + "MessageConfirmRenameTag": "すべてのアイテムのタグ \"{0}\" を \"{1}\" に変更してもよろしいですか?", + "MessageConfirmRenameTagMergeNote": "注: このタグはすでに存在するためマージされます。", + "MessageConfirmRenameTagWarning": "警告!大文字・小文字が異なる類似したタグ \"{0}\" がすでに存在します。", + "MessageConfirmResetProgress": "進捗状況をリセットしてもよろしいですか?", + "MessageConfirmSendEbookToDevice": "{0} の電子書籍 \"{1}\" を端末 \"{2}\" に送信してもよろしいですか?", + "MessageConfirmUnlinkOpenId": "このユーザーをOpenIDからリンク解除してもよろしいですか?", + "MessageDaysListenedInTheLastYear": "過去1年間に {0}日間聴きました", + "MessageDownloadingEpisode": "エピソードをダウンロード中", + "MessageDragFilesIntoTrackOrder": "ファイルを正しいトラック順にドラッグしてください", + "MessageEmbedFailed": "埋め込み失敗!", + "MessageEmbedFinished": "埋め込み完了!", + "MessageEmbedQueue": "メタデータ埋め込みキューに追加されました(キュー内: {0}件)", + "MessageEpisodesQueuedForDownload": "{0}件のエピソードがダウンロードキューに追加されました", + "MessageEreaderDevices": "電子書籍を確実に配信するため、以下の各端末で上記のメールアドレスを送信元として許可する設定が必要になることがあります。", + "MessageFeedURLWillBe": "フィードURLは {0} になります", + "MessageFetching": "取得中...", + "MessageForceReScanDescription": "すべてのファイルを新規スキャンのように再スキャンします。オーディオファイルのID3タグ、OPFファイル、テキストファイルが新規として読み込まれます。", + "MessageHeatmapListeningTimeTooltip": "{1} に {0} のリスニング", + "MessageHeatmapNoListeningSessions": "{0} にリスニングセッションはありません", + "MessageImportantNotice": "重要なお知らせ!", + "MessageInsertChapterBelow": "下にチャプターを挿入", + "MessageInvalidAsin": "無効なASIN", + "MessageItemsSelected": "{0}件選択中", + "MessageItemsUpdated": "{0}件更新されました", + "MessageJoinUsOn": "参加する:", + "MessageLoading": "読み込み中...", + "MessageLoadingFolders": "フォルダーを読み込み中...", + "MessageLogsDescription": "ログは /metadata/logs にJSONファイルとして保存されます。クラッシュログは /metadata/logs/crash_logs.txt に保存されます。", + "MessageM4BFailed": "M4B変換失敗!", + "MessageM4BFinished": "M4B変換完了!", + "MessageMapChapterTitles": "タイムスタンプを調整せずにチャプタータイトルを既存のオーディオブックのチャプターに割り当てます", + "MessageMarkAllEpisodesFinished": "すべてのエピソードを完了済みにする", + "MessageMarkAllEpisodesNotFinished": "すべてのエピソードを未完了にする", + "MessageMarkAsFinished": "完了済みにする", + "MessageMarkAsNotFinished": "未完了にする", + "MessageMatchBooksDescription": "ライブラリー内の本を選択した検索プロバイダーの本と照合し、空の詳細とカバーアートを補完しようとします。既存の詳細は上書きしません。", + "MessageNoAudioTracks": "オーディオトラックなし", + "MessageNoAuthors": "作者なし", + "MessageNoBackups": "バックアップなし", + "MessageNoBookmarks": "ブックマークなし", + "MessageNoChapters": "チャプターなし", + "MessageNoCollections": "コレクションなし", + "MessageNoCoversFound": "カバーが見つかりません", + "MessageNoDescription": "説明なし", + "MessageNoDevices": "端末なし", + "MessageNoDownloadsInProgress": "現在進行中のダウンロードはありません", + "MessageNoDownloadsQueued": "ダウンロードキューが空です", + "MessageNoEpisodeMatchesFound": "エピソードの一致が見つかりません", + "MessageNoEpisodes": "エピソードなし", + "MessageNoFoldersAvailable": "利用可能なフォルダーがありません", + "MessageNoGenres": "ジャンルなし", + "MessageNoIssues": "問題なし", + "MessageNoItems": "アイテムなし", + "MessageNoItemsFound": "アイテムが見つかりません", + "MessageNoListeningSessions": "リスニングセッションなし", + "MessageNoLogs": "ログなし", + "MessageNoMediaProgress": "メディアの進捗状況なし", + "MessageNoNotifications": "通知なし", + "MessageNoPodcastFeed": "無効なポッドキャスト: フィードなし", + "MessageNoPodcastsFound": "ポッドキャストが見つかりません", + "MessageNoResults": "結果なし", + "MessageNoSearchResultsFor": "\"{0}\" の検索結果はありません", + "MessageNoSeries": "シリーズなし", + "MessageNoTags": "タグなし", + "MessageNoTasksRunning": "実行中のタスクなし", + "MessageNoUpdatesWereNecessary": "更新は不要でした", + "MessageNoUserPlaylists": "プレイリストがありません", + "MessageNoUserPlaylistsHelp": "プレイリストは非公開です。作成したユーザーのみが見ることができます。", + "MessageNotYetImplemented": "未実装", + "MessageOpmlPreviewNote": "注: これは解析されたOPMLファイルのプレビューです。実際のポッドキャストタイトルはRSSフィードから取得されます。", + "MessageOr": "または", + "MessagePauseChapter": "チャプターの再生を一時停止", + "MessagePlayChapter": "チャプターの最初から聴く", + "MessagePlaylistCreateFromCollection": "コレクションからプレイリストを作成", + "MessagePleaseWait": "しばらくお待ちください...", + "MessagePodcastHasNoRSSFeedForMatching": "このポッドキャストには照合に使用できるRSSフィードURLがありません", + "MessagePodcastSearchField": "検索キーワードまたはRSSフィードURLを入力", + "MessageQuickEmbedInProgress": "クイック埋め込みを実行中", + "MessageQuickEmbedQueue": "クイック埋め込みキューに追加されました(キュー内: {0}件)", + "MessageQuickMatchAllEpisodes": "すべてのエピソードをクイックマッチ", + "MessageQuickMatchDescription": "'{0}' の最初の一致結果でアイテムの空の詳細とカバーを設定します。サーバー設定の「一致したメタデータを優先」が有効な場合を除き、既存の詳細は上書きされません。", + "MessageRemoveChapter": "チャプターを削除", + "MessageRemoveEpisodes": "{0}件のエピソードを削除", + "MessageRemoveFromPlayerQueue": "「次に再生」から削除", + "MessageRemoveUserWarning": "ユーザー \"{0}\" を完全に削除してもよろしいですか?", + "MessageReportBugsAndContribute": "バグ報告、機能リクエスト、コントリビューション:", + "MessageResetChaptersConfirm": "チャプターをリセットして変更を元に戻してもよろしいですか?", + "MessageRestoreBackupConfirm": "以下の日時に作成されたバックアップを復元してもよろしいですか:", + "MessageRestoreBackupWarning": "バックアップを復元すると、/config にあるデータベース全体と /metadata/items および /metadata/authors のカバー画像が上書きされます。

バックアップはライブラリーフォルダー内のファイルを変更しません。カバーアートとメタデータをライブラリーフォルダーに保存するサーバー設定を有効にしている場合、それらはバックアップされず上書きもされません。

サーバーを使用しているすべてのクライアントは自動的に更新されます。", + "MessageScheduleLibraryScanNote": "ほとんどのユーザーには、この機能を無効のままにして「ライブラリーの変更を自動的に監視する」設定を有効にしておくことをお勧めします。これによりライブラリーフォルダーの変更が自動的に検出されます。NFS などのファイルシステムで「ライブラリーの変更を自動的に監視する」が機能しない場合はこの機能を有効にしてください。", + "MessageScheduleRunEveryWeekdayAtTime": "毎{0} {1} に実行", + "MessageSearchResultsFor": "検索結果:", + "MessageSelected": "{0}件選択中", + "MessageSeriesSequenceCannotContainSpaces": "シリーズのシーケンスにスペースを含めることはできません", + "MessageServerCouldNotBeReached": "サーバーに接続できませんでした", + "MessageSetChaptersFromTracksDescription": "各オーディオファイルをチャプターとして設定し、チャプタータイトルにはファイル名を使用します", + "MessageShareExpirationWillBe": "有効期限は {0} になります", + "MessageShareExpiresIn": "{0} 後に期限切れ", + "MessageShareURLWillBe": "共有URLは {0} になります", + "MessageStartPlaybackAtTime": "\"{0}\" を {1} から再生しますか?", + "MessageTaskAudioFileNotWritable": "オーディオファイル \"{0}\" は書き込み不可です", + "MessageTaskCanceledByUser": "タスクがユーザーによりキャンセルされました", + "MessageTaskDownloadingEpisodeDescription": "エピソード \"{0}\" をダウンロード中", + "MessageTaskEmbeddingMetadata": "メタデータを埋め込み中", + "MessageTaskEmbeddingMetadataDescription": "オーディオブック \"{0}\" にメタデータを埋め込み中", + "MessageTaskEncodingM4b": "M4Bをエンコード中", + "MessageTaskEncodingM4bDescription": "オーディオブック \"{0}\" を単一のm4bファイルにエンコード中", + "MessageTaskFailed": "失敗", + "MessageTaskFailedToBackupAudioFile": "オーディオファイル \"{0}\" のバックアップに失敗しました", + "MessageTaskFailedToCreateCacheDirectory": "キャッシュディレクトリの作成に失敗しました", + "MessageTaskFailedToEmbedMetadataInFile": "ファイル \"{0}\" へのメタデータ埋め込みに失敗しました", + "MessageTaskFailedToMergeAudioFiles": "オーディオファイルのマージに失敗しました", + "MessageTaskFailedToMoveM4bFile": "m4bファイルの移動に失敗しました", + "MessageTaskFailedToWriteMetadataFile": "メタデータファイルの書き込みに失敗しました", + "MessageTaskMatchingBooksInLibrary": "ライブラリー \"{0}\" の本を照合中", + "MessageTaskNoFilesToScan": "スキャンするファイルがありません", + "MessageTaskOpmlImport": "OPMLインポート", + "MessageTaskOpmlImportDescription": "{0}件のRSSフィードからポッドキャストを作成中", + "MessageTaskOpmlImportFeed": "OPMLインポートフィード", + "MessageTaskOpmlImportFeedDescription": "RSSフィード \"{0}\" をインポート中", + "MessageTaskOpmlImportFeedFailed": "ポッドキャストフィードの取得に失敗しました", + "MessageTaskOpmlImportFeedPodcastDescription": "ポッドキャスト \"{0}\" を作成中", + "MessageTaskOpmlImportFeedPodcastExists": "このパスにポッドキャストがすでに存在します", + "MessageTaskOpmlImportFeedPodcastFailed": "ポッドキャストの作成に失敗しました", + "MessageTaskOpmlImportFinished": "{0}件のポッドキャストを追加しました", + "MessageTaskOpmlParseFailed": "OPMLファイルの解析に失敗しました", + "MessageTaskOpmlParseFastFail": "無効なOPMLファイル: タグが見つからないか タグが見つかりません", + "MessageTaskOpmlParseNoneFound": "OPMLファイルにフィードが見つかりません", + "MessageTaskScanItemsAdded": "{0}件追加", + "MessageTaskScanItemsMissing": "{0}件不明", + "MessageTaskScanItemsUpdated": "{0}件更新", + "MessageTaskScanNoChangesNeeded": "変更は不要でした", + "MessageTaskScanningFileChanges": "\"{0}\" のファイル変更をスキャン中", + "MessageTaskScanningLibrary": "\"{0}\" ライブラリーをスキャン中", + "MessageTaskTargetDirectoryNotWritable": "ターゲットディレクトリは書き込み不可です", + "MessageThinking": "処理中...", + "MessageUploaderItemFailed": "アップロード失敗", + "MessageUploaderItemSuccess": "アップロード成功!", + "MessageUploading": "アップロード中...", + "MessageValidCronExpression": "有効なCron式です", + "MessageWatcherIsDisabledGlobally": "ウォッチャーはサーバー設定でグローバルに無効化されています", + "MessageXLibraryIsEmpty": "{0} ライブラリーは空です!", + "MessageYourAudiobookDurationIsLonger": "オーディオブックの長さが見つかった長さより長くなっています", + "MessageYourAudiobookDurationIsShorter": "オーディオブックの長さが見つかった長さより短くなっています", + "NoteChangeRootPassword": "ルートユーザーのみ空のパスワードを設定できます", + "NoteChapterEditorTimes": "注: 最初のチャプターの開始時間は必ず0:00のままにしてください。また最後のチャプターの開始時間はオーディオブックの長さを超えることはできません。", + "NoteFolderPicker": "注: すでにマッピング済みのフォルダーは表示されません", + "NoteRSSFeedPodcastAppsHttps": "警告: ほとんどのポッドキャストアプリはHTTPSのRSSフィードURLが必要です", + "NoteRSSFeedPodcastAppsPubDate": "警告: 1件以上のエピソードに公開日がありません。一部のポッドキャストアプリではこれが必要です。", + "NoteUploaderFoldersWithMediaFiles": "メディアファイルを含むフォルダーは別々のライブラリーアイテムとして処理されます。", + "NoteUploaderOnlyAudioFiles": "オーディオファイルのみをアップロードする場合、各オーディオファイルは個別のオーディオブックとして処理されます。", + "NoteUploaderUnsupportedFiles": "サポートされていないファイルは無視されます。フォルダーを選択またはドロップする場合、アイテムフォルダー内にない他のファイルは無視されます。", + "NotificationOnBackupCompletedDescription": "バックアップが完了したときにトリガーされます", + "NotificationOnBackupFailedDescription": "バックアップが失敗したときにトリガーされます", + "NotificationOnEpisodeDownloadedDescription": "ポッドキャストエピソードが自動ダウンロードされたときにトリガーされます", + "NotificationOnRSSFeedDisabledDescription": "失敗が多すぎたため自動エピソードダウンロードが無効になったときにトリガーされます", + "NotificationOnRSSFeedFailedDescription": "自動エピソードダウンロードのRSSフィードリクエストが失敗したときにトリガーされます", + "NotificationOnTestDescription": "通知システムのテスト用イベント", + "PlaceholderBulkChapterInput": "チャプタータイトルを入力するか、番号付けを使用(例: 「エピソード 1」「チャプター 10」「1.」)", + "PlaceholderNewCollection": "新しいコレクション名", + "PlaceholderNewFolderPath": "新しいフォルダーパス", + "PlaceholderNewPlaylist": "新しいプレイリスト名", + "PlaceholderSearch": "検索...", + "PlaceholderSearchEpisode": "エピソードを検索...", + "StatsAuthorsAdded": "追加された作者", + "StatsBooksAdded": "追加された本", + "StatsBooksAdditional": "その他の追加内容…", + "StatsBooksFinished": "完了した本", + "StatsBooksFinishedThisYear": "今年完了した本…", + "StatsBooksListenedTo": "聴いた本", + "StatsCollectionGrewTo": "あなたの本のコレクションが増えました…", + "StatsSessions": "セッション", + "StatsSpentListening": "リスニング時間", + "StatsTopAuthor": "トップ作者", + "StatsTopAuthors": "トップ作者", + "StatsTopGenre": "トップジャンル", + "StatsTopGenres": "トップジャンル", + "StatsTopMonth": "トップ月", + "StatsTopNarrator": "トップナレーター", + "StatsTopNarrators": "トップナレーター", + "StatsTotalDuration": "合計再生時間…", + "StatsYearInReview": "年間振り返り", + "ToastAccountUpdateSuccess": "アカウントを更新しました", + "ToastAppriseUrlRequired": "Apprise URLを入力してください", + "ToastAsinRequired": "ASINを入力してください", + "ToastAuthorImageRemoveSuccess": "作者の画像を削除しました", + "ToastAuthorNotFound": "作者「{0}」が見つかりません", + "ToastAuthorRemoveSuccess": "作者を削除しました", + "ToastAuthorSearchNotFound": "作者が見つかりません", + "ToastAuthorUpdateMerged": "作者をマージしました", + "ToastAuthorUpdateSuccess": "作者を更新しました", + "ToastAuthorUpdateSuccessNoImageFound": "作者を更新しました(画像が見つかりません)", + "ToastBackupAppliedSuccess": "バックアップを適用しました", + "ToastBackupCreateFailed": "バックアップの作成に失敗しました", + "ToastBackupCreateSuccess": "バックアップを作成しました", + "ToastBackupDeleteFailed": "バックアップの削除に失敗しました", + "ToastBackupDeleteSuccess": "バックアップを削除しました", + "ToastBackupInvalidMaxKeep": "保持するバックアップ数が無効です", + "ToastBackupInvalidMaxSize": "バックアップの最大サイズが無効です", + "ToastBackupRestoreFailed": "バックアップの復元に失敗しました", + "ToastBackupUploadFailed": "バックアップのアップロードに失敗しました", + "ToastBackupUploadSuccess": "バックアップをアップロードしました", + "ToastBatchApplyDetailsToItemsSuccess": "詳細をアイテムに適用しました", + "ToastBatchDeleteFailed": "一括削除に失敗しました", + "ToastBatchDeleteSuccess": "一括削除に成功しました", + "ToastBatchQuickMatchFailed": "一括クイックマッチに失敗しました!", + "ToastBatchQuickMatchStarted": "{0}冊の一括クイックマッチを開始しました!", + "ToastBatchUpdateFailed": "一括更新に失敗しました", + "ToastBatchUpdateSuccess": "一括更新に成功しました", + "ToastBookmarkCreateFailed": "ブックマークの作成に失敗しました", + "ToastBookmarkCreateSuccess": "ブックマークを追加しました", + "ToastBookmarkRemoveSuccess": "ブックマークを削除しました", + "ToastBulkChapterInvalidCount": "1〜150の数値を入力してください", + "ToastCachePurgeFailed": "キャッシュのクリアに失敗しました", + "ToastCachePurgeSuccess": "キャッシュをクリアしました", + "ToastChapterLocked": "チャプターはロックされています。", + "ToastChapterStartTimeAdjusted": "チャプター開始時間を{0}秒調整しました", + "ToastChaptersAllLocked": "すべてのチャプターがロックされています。時間をシフトするにはいくつかのチャプターのロックを解除してください。", + "ToastChaptersHaveErrors": "チャプターにエラーがあります", + "ToastChaptersInvalidShiftAmountLast": "シフト量が無効です。最後のチャプターの開始時間がこのオーディオブックの長さを超えてしまいます。", + "ToastChaptersInvalidShiftAmountStart": "シフト量が無効です。最初のチャプターがゼロまたは負の長さになり、2番目のチャプターに上書きされます。2番目のチャプターの開始時間を長くしてください。", + "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": "1つ以上のURLが無効です", + "ToastItemCoverUpdateSuccess": "アイテムのカバーを更新しました", + "ToastItemDeletedFailed": "アイテムの削除に失敗しました", + "ToastItemDeletedSuccess": "アイテムを削除しました", + "ToastItemDetailsUpdateSuccess": "アイテムの詳細を更新しました", + "ToastItemMarkedAsFinishedFailed": "完了としてマークするのに失敗しました", + "ToastItemMarkedAsFinishedSuccess": "アイテムを完了としてマークしました", + "ToastItemMarkedAsNotFinishedFailed": "未完了としてマークするのに失敗しました", + "ToastItemMarkedAsNotFinishedSuccess": "アイテムを未完了としてマークしました", + "ToastItemUpdateSuccess": "アイテムを更新しました", + "ToastLibraryCreateFailed": "ライブラリーの作成に失敗しました", + "ToastLibraryCreateSuccess": "ライブラリー「{0}」を作成しました", + "ToastLibraryDeleteFailed": "ライブラリーの削除に失敗しました", + "ToastLibraryDeleteSuccess": "ライブラリーを削除しました", + "ToastLibraryScanFailedToStart": "スキャンの開始に失敗しました", + "ToastLibraryScanStarted": "ライブラリースキャンを開始しました", + "ToastLibraryUpdateSuccess": "ライブラリー「{0}」を更新しました", + "ToastMatchAllAuthorsFailed": "すべての作者のマッチングに失敗しました", + "ToastMetadataFilesRemovedError": "metadata.{0}ファイルの削除中にエラーが発生しました", + "ToastMetadataFilesRemovedNoneFound": "ライブラリー内にmetadata.{0}ファイルが見つかりません", + "ToastMetadataFilesRemovedNoneRemoved": "削除されたmetadata.{0}ファイルはありません", + "ToastMetadataFilesRemovedSuccess": "{0}個のmetadata.{1}ファイルを削除しました", + "ToastMustHaveAtLeastOnePath": "少なくとも1つのパスが必要です", + "ToastNameEmailRequired": "名前とメールアドレスが必要です", + "ToastNameRequired": "名前を入力してください", + "ToastNewApiKeyUserError": "ユーザーを選択してください", + "ToastNewEpisodesFound": "{0}個の新しいエピソードが見つかりました", + "ToastNewUserCreatedFailed": "アカウントの作成に失敗しました:「{0}」", + "ToastNewUserCreatedSuccess": "新しいアカウントを作成しました", + "ToastNewUserLibraryError": "少なくとも1つのライブラリーを選択してください", + "ToastNewUserPasswordError": "パスワードが必要です。ルートユーザーのみ空のパスワードを使用できます", + "ToastNewUserTagError": "少なくとも1つのタグを選択してください", + "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": "少なくとも1人のユーザーを選択してください", + "ToastSendEbookToDeviceFailed": "電子書籍のデバイスへの送信に失敗しました", + "ToastSendEbookToDeviceSuccess": "電子書籍をデバイス「{0}」に送信しました", + "ToastSeriesSubmitFailedSameName": "同じ名前のシリーズを2つ追加することはできません", + "ToastSeriesUpdateFailed": "シリーズの更新に失敗しました", + "ToastSeriesUpdateSuccess": "シリーズを更新しました", + "ToastServerSettingsUpdateSuccess": "サーバー設定を更新しました", + "ToastSessionCloseFailed": "セッションのクローズに失敗しました", + "ToastSessionDeleteFailed": "セッションの削除に失敗しました", + "ToastSessionDeleteSuccess": "セッションを削除しました", + "ToastSleepTimerDone": "スリープタイマーが終了しました... zZzzZz", + "ToastSlugMustChange": "スラグに無効な文字が含まれています", + "ToastSlugRequired": "スラグを入力してください", + "ToastSocketConnected": "ソケットが接続されました", + "ToastSocketDisconnected": "ソケットが切断されました", + "ToastSocketFailedToConnect": "ソケット接続に失敗しました", + "ToastSortingPrefixesEmptyError": "少なくとも1つのソート接頭辞が必要です", + "ToastSortingPrefixesUpdateSuccess": "ソート接頭辞を更新しました({0}アイテム)", + "ToastTitleRequired": "タイトルを入力してください", + "ToastUnknownError": "不明なエラー", + "ToastUnlinkOpenIdFailed": "OpenIDからのユーザーのリンク解除に失敗しました", + "ToastUnlinkOpenIdSuccess": "ユーザーをOpenIDからリンク解除しました", + "ToastUploaderFilepathExistsError": "ファイルパス「{0}」はサーバーにすでに存在しています", + "ToastUploaderItemExistsInSubdirectoryError": "アイテム「{0}」はアップロードパスのサブディレクトリーを使用しています。", + "ToastUserDeleteFailed": "ユーザーの削除に失敗しました", + "ToastUserDeleteSuccess": "ユーザーを削除しました", + "ToastUserPasswordChangeSuccess": "パスワードを変更しました", + "ToastUserPasswordMismatch": "パスワードが一致しません", + "ToastUserPasswordMustChange": "新しいパスワードは古いパスワードと同じにすることはできません", + "ToastUserRootRequireName": "ルートユーザー名を入力してください", + "TooltipAddChapters": "チャプターを追加", + "TooltipAddOneSecond": "1秒追加", + "TooltipAdjustChapterStart": "クリックして開始時刻を調整", + "TooltipLockAllChapters": "すべてのチャプターをロック", + "TooltipLockChapter": "チャプターをロック(Shift+クリックで範囲選択)", + "TooltipSubtractOneSecond": "1秒減算", + "TooltipUnlockAllChapters": "すべてのチャプターをロック解除", + "TooltipUnlockChapter": "チャプターをロック解除(Shift+クリックで範囲選択)" } diff --git a/client/strings/lv.json b/client/strings/lv.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/client/strings/lv.json @@ -0,0 +1 @@ +{} diff --git a/client/strings/nl.json b/client/strings/nl.json index 708ea7a97..41a5a814b 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -2,7 +2,7 @@ "ButtonAdd": "Toevoegen", "ButtonAddApiKey": "API Key toevoegen", "ButtonAddChapters": "Hoofdstukken toevoegen", - "ButtonAddDevice": "Toestel toevoegen", + "ButtonAddDevice": "Apparaat toevoegen", "ButtonAddLibrary": "Bibliotheek toevoegen", "ButtonAddPodcasts": "Podcasts toevoegen", "ButtonAddUser": "Gebruiker toevoegen", @@ -139,7 +139,7 @@ "HeaderCustomMetadataProviders": "Aangepaste Metadata Providers", "HeaderDetails": "Details", "HeaderDownloadQueue": "Download-wachtrij", - "HeaderEbookFiles": "Ebook bestanden", + "HeaderEbookFiles": "E-book bestanden", "HeaderEmail": "E-mail", "HeaderEmailSettings": "E-mail instellingen", "HeaderEpisodes": "Afleveringen", @@ -275,7 +275,7 @@ "LabelBonus": "Bonus", "LabelBooks": "Boeken", "LabelButtonText": "Knop Tekst", - "LabelByAuthor": "Door {0}", + "LabelByAuthor": "door {0}", "LabelChangePassword": "Wachtwoord wijzigen", "LabelChannels": "Kanalen", "LabelChapterCount": "{0} Hoofdstukken", @@ -383,7 +383,7 @@ "LabelFolders": "Mappen", "LabelFontBold": "Vetgedrukt", "LabelFontBoldness": "Lettertype Dikte", - "LabelFontFamily": "Lettertypefamilie", + "LabelFontFamily": "Letterfamilie", "LabelFontItalic": "Cursief", "LabelFontScale": "Lettertype schaal", "LabelFontStrikethrough": "Doorgestreept", @@ -436,9 +436,9 @@ "LabelLibraryFilterSublistEmpty": "Nee {0}", "LabelLibraryItem": "Bibliotheekonderdeel", "LabelLibraryName": "Bibliotheeknaam", - "LabelLibrarySortByProgress": "Voortuigang geüpdatet", - "LabelLibrarySortByProgressFinished": "Datum voltooid", - "LabelLibrarySortByProgressStarted": "Datum gestart", + "LabelLibrarySortByProgress": "Voortgang: Laatst geüpdatet", + "LabelLibrarySortByProgressFinished": "Voortgang: Voltooid", + "LabelLibrarySortByProgressStarted": "Voortgang: Gestart", "LabelLimit": "Limiet", "LabelLineSpacing": "Regelruimte", "LabelListenAgain": "Opnieuw Beluisteren", @@ -588,8 +588,8 @@ "LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken", "LabelSettingsChromecastSupport": "Chromecast ondersteuning", "LabelSettingsDateFormat": "Datumnotatie", - "LabelSettingsEnableWatcher": "Bibliotheken automatisch scannen op wijzigingen", - "LabelSettingsEnableWatcherForLibrary": "Bibliotheek automatisch scannen op wijzigingen", + "LabelSettingsEnableWatcher": "Bibliotheken automatisch monitoren op wijzigingen", + "LabelSettingsEnableWatcherForLibrary": "Bibliotheek automatisch monitoren op wijzigingen", "LabelSettingsEnableWatcherHelp": "Zorgt voor het automatisch toevoegen/bijwerken van onderdelen als bestandswijzigingen worden gedetecteerd. *Vereist herstarten van server", "LabelSettingsEpubsAllowScriptedContent": "Sta scripted content toe in epubs", "LabelSettingsEpubsAllowScriptedContentHelp": "Sta toe dat epub-bestanden scripts uitvoeren. Het wordt aanbevolen om deze instelling uitgeschakeld te houden, tenzij u de bron van de epub-bestanden vertrouwt.", @@ -888,7 +888,7 @@ "MessageResetChaptersConfirm": "Weet je zeker dat je de hoofdstukken wil resetten en de wijzigingen die je gemaakt hebt ongedaan wil maken?", "MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op", "MessageRestoreBackupWarning": "Een back-up herstellen zal de volledige database in /config en de omslagen in /metadata/items & /metadata/authors overschrijven.

Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om omslagen en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.

Alle apparaten die je server gebruiken, worden automatisch ververst.", - "MessageScheduleLibraryScanNote": "Voor de meeste gebruikers is het raadzaam om deze functie uitgeschakeld te laten en de folder watcher-instelling ingeschakeld te houden. De folder watcher detecteert automatisch wijzigingen in uw bibliotheekmappen. De folder watcher werkt niet voor elk bestandssysteem (zoals NFS), dus geplande bibliotheekscans kunnen in plaats daarvan worden gebruikt.", + "MessageScheduleLibraryScanNote": "Voor de meeste gebruikers is het aangeraden om deze functie uitgeschakeld te laten en de \"Bibliotheek automatisch monitoren op wijzigingen\" instelling ingeschakeld te houden - deze detecteert automatisch wijzigingen in uw bibliotheekmappen. Activeer deze instelling als \"Bibliotheek automatisch monitoren op wijzigingen\" niet werkt voor uw bestandssysteem (zoals NFS).", "MessageScheduleRunEveryWeekdayAtTime": "Elke {0} uitvoeren op {1}", "MessageSearchResultsFor": "Zoekresultaten voor", "MessageSelected": "{0} geselecteerd", @@ -1026,6 +1026,8 @@ "ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt", "ToastCollectionRemoveSuccess": "Collectie verwijderd", "ToastCollectionUpdateSuccess": "Collectie bijgewerkt", + "ToastConnectionNotAvailable": "Verbinding niet beschikbaar. Gelieve later opnieuw te proberen", + "ToastCoverSearchFailed": "Omslag zoeken mislukt", "ToastCoverUpdateFailed": "Omslag bijwerken mislukt", "ToastDateTimeInvalidOrIncomplete": "Datum en tijd ongeldig of onvolledig", "ToastDeleteFileFailed": "Bestand verwijderen mislukt", diff --git a/client/strings/pl.json b/client/strings/pl.json index d1bc6c062..f04e61c46 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -951,6 +951,11 @@ "NoteUploaderFoldersWithMediaFiles": "Foldery z plikami multimedialnymi będą traktowane jako osobne elementy w bibliotece.", "NoteUploaderOnlyAudioFiles": "Jeśli przesyłasz tylko pliki audio, każdy plik audio będzie traktowany jako osobny audiobook.", "NoteUploaderUnsupportedFiles": "Nieobsługiwane pliki są ignorowane. Podczas dodawania folderu, inne pliki, które nie znajdują się w folderze elementu, są ignorowane.", + "NotificationOnBackupCompletedDescription": "Wyzwalane po zakończeniu tworzenia kopii zapasowej", + "NotificationOnBackupFailedDescription": "Wyzwalane w przypadku gdy stworzenie kopii zapasowej rzuci błąd", + "NotificationOnEpisodeDownloadedDescription": "Wyzwalane, gdy odcinek podcastu zostanie automatycznie pobrany", + "NotificationOnRSSFeedDisabledDescription": "Wyzwalane, gdy automatyczne pobieranie odcinków jest wyłączone z powodu zbyt wielu nieudanych prób", + "NotificationOnRSSFeedFailedDescription": "Wyzwalane, gdy żądanie kanału RSS dotyczące automatycznego pobrania odcinka nie powiedzie się", "NotificationOnTestDescription": "Zdarzenie używane do testowania systemu powiadomień", "PlaceholderBulkChapterInput": "Wpisz tytuł rozdziału lub użyj numeracji (np. „Odcinek 1”, „Rozdział 10”, „1.”)", "PlaceholderNewCollection": "Nowa nazwa kolekcji", @@ -960,6 +965,7 @@ "PlaceholderSearchEpisode": "Szukanie odcinka..", "StatsAuthorsAdded": "dodano autorów", "StatsBooksAdded": "dodano książki", + "StatsBooksAdditional": "Niektóre dodatki obejmują…", "StatsBooksFinished": "ukończone książki", "StatsBooksFinishedThisYear": "Wybrane książki ukończone w tym roku…", "StatsBooksListenedTo": "książki wysłuchane", @@ -976,6 +982,7 @@ "StatsTotalDuration": "O sumarycznej długości…", "StatsYearInReview": "PRZEGLĄD ROKU", "ToastAccountUpdateSuccess": "Zaktualizowano konto", + "ToastAppriseUrlRequired": "Należy wprowadzić adres URL Apprise", "ToastAsinRequired": "ASIN jest wymagany", "ToastAuthorImageRemoveSuccess": "Zdjęcie autora usunięte", "ToastAuthorNotFound": "Autor \"{0}\" nie został znaleziony", @@ -994,8 +1001,11 @@ "ToastBackupRestoreFailed": "Nie udało się przywrócić kopii zapasowej", "ToastBackupUploadFailed": "Nie udało się przesłać kopii zapasowej", "ToastBackupUploadSuccess": "Kopia zapasowa została przesłana", + "ToastBatchApplyDetailsToItemsSuccess": "Szczegóły zastosowane do elementów", "ToastBatchDeleteFailed": "Usuwanie zbiorcze nie powiodło się", "ToastBatchDeleteSuccess": "Usuwanie zbiorcze powiodło się", + "ToastBatchQuickMatchFailed": "Szybkie dopasowanie partii nie powiodło się!", + "ToastBatchQuickMatchStarted": "Rozpoczęto partię szybkiego dopasowania {0} książek!", "ToastBatchUpdateFailed": "Aktualizacja zbiorcza nie powiodła się", "ToastBatchUpdateSuccess": "Aktualizacja zbiorcza powiodła się", "ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki", @@ -1033,7 +1043,14 @@ "ToastEpisodeDownloadQueueClearSuccess": "Wyczyszczono kolejkę epizodów do ściągnięcia", "ToastEpisodeUpdateSuccess": "Zaktualizowano {0} odcinków", "ToastErrorCannotShare": "Nie można udostępniać natywnie na tym urządzeniu.", + "ToastFailedToCreate": "Nie udało się utworzyć", + "ToastFailedToDelete": "Nie udało się usunąć", + "ToastFailedToLoadData": "Nie udało się załadować danych", + "ToastFailedToMatch": "Nie udało się dopasować", + "ToastFailedToShare": "Nie udało się udostępnić", + "ToastFailedToUpdate": "Nie udało się zaktualizować", "ToastInvalidImageUrl": "Nieprawidłowy URL obrazu", + "ToastInvalidMaxEpisodesToDownload": "Nieprawidłowa maksymalna liczba odcinków do pobrania", "ToastInvalidUrl": "Nieprawidłowy URL", "ToastInvalidUrls": "Jeden lub więcej URL-i są nieprawidłowe", "ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę", @@ -1044,6 +1061,7 @@ "ToastItemMarkedAsFinishedSuccess": "Pozycja oznaczona jako ukończona", "ToastItemMarkedAsNotFinishedFailed": "Oznaczenie pozycji jako ukończonej nie powiodło się", "ToastItemMarkedAsNotFinishedSuccess": "Pozycja oznaczona jako nieukończona", + "ToastItemUpdateSuccess": "Element zaktualizowany", "ToastLibraryCreateFailed": "Nie udało się utworzyć biblioteki", "ToastLibraryCreateSuccess": "Biblioteka \"{0}\" stworzona", "ToastLibraryDeleteFailed": "Nie udało się usunąć biblioteki", @@ -1052,6 +1070,10 @@ "ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki", "ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji", "ToastMatchAllAuthorsFailed": "Nie udało się dopasować wszystkich autorów", + "ToastMetadataFilesRemovedError": "Błąd podczas usuwania metadata.{0} plików", + "ToastMetadataFilesRemovedNoneFound": "Nie znaleziono metadata.{0} plików w bibliotece", + "ToastMetadataFilesRemovedNoneRemoved": "Nie usunięto żadnego metadata.{0} pliku", + "ToastMetadataFilesRemovedSuccess": "{0} metadata.{0} plików usunięto", "ToastMustHaveAtLeastOnePath": "Musi mieć przynajmniej jedną ścieżkę", "ToastNameEmailRequired": "Nazwa i email są wymagane", "ToastNameRequired": "Imię jest wymagane", @@ -1065,7 +1087,15 @@ "ToastNewUserUsernameError": "Wprowadź nazwę użytkownika", "ToastNoNewEpisodesFound": "Nie znaleziono nowych odcinków", "ToastNoRSSFeed": "Podcast nie posiada RSS Feed", + "ToastNoUpdatesNecessary": "Brak konieczności aktualizacji", + "ToastNotificationCreateFailed": "Nie udało się utworzyć powiadomienia", + "ToastNotificationDeleteFailed": "Nie udało się usunąć powiadomienia", "ToastNotificationFailedMaximum": "Maks. ilość nieudanych prób musi być >= 0", + "ToastNotificationQueueMaximum": "Maksymalna liczba powiadomień w kolejce musi być >= 0", + "ToastNotificationSettingsUpdateSuccess": "Zaktualizowano ustawienia powiadomień", + "ToastNotificationTestTriggerFailed": "Nie udało się wywołać powiadomienia testowego", + "ToastNotificationTestTriggerSuccess": "Wyzwolono powiadomienie testowe", + "ToastNotificationUpdateSuccess": "Powiadomienie zaktualizowane", "ToastPlaylistCreateFailed": "Nie udało się utworzyć playlisty", "ToastPlaylistCreateSuccess": "Playlista utworzona", "ToastPlaylistRemoveSuccess": "Playlista usunięta", @@ -1073,8 +1103,17 @@ "ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu", "ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony", "ToastPodcastEpisodeUpdated": "Zaktualizowano odcinki", + "ToastPodcastGetFeedFailed": "Nie udało się pobrać kanału podcastu", + "ToastPodcastNoEpisodesInFeed": "Nie znaleziono żadnych odcinków w kanale RSS", + "ToastPodcastNoRssFeed": "Podcast nie ma kanału RSS", + "ToastProgressIsNotBeingSynced": "Postęp nie jest synchronizowany, uruchom ponownie odtwarzanie", + "ToastProviderCreatedFailed": "Nie udało się dodać dostawcy", + "ToastProviderCreatedSuccess": "Dodano nowego dostawcę", + "ToastProviderNameAndUrlRequired": "Wymagane jest podanie nazwy i adresu URL", + "ToastProviderRemoveSuccess": "Dostawca usunięty", "ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się", "ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS powiodło się", + "ToastRemoveFailed": "Nie udało się usunąć", "ToastRemoveItemFromCollectionFailed": "Nie udało się usunąć elementu z kolekcji", "ToastRemoveItemFromCollectionSuccess": "Pozycja usunięta z kolekcji", "ToastRemoveItemsWithIssuesFailed": "Nie udało się usunąć wadliwych elementów z biblioteki", @@ -1096,16 +1135,25 @@ "ToastSessionDeleteFailed": "Nie udało się usunąć sesji", "ToastSessionDeleteSuccess": "Sesja usunięta", "ToastSleepTimerDone": "Słodkich snów... zZzzZz", + "ToastSlugMustChange": "Slug zawiera nieprawidłowe znaki", + "ToastSlugRequired": "Slug jest wymagany", "ToastSocketConnected": "Nawiązano połączenie z serwerem", "ToastSocketDisconnected": "Połączenie z serwerem zostało zamknięte", "ToastSocketFailedToConnect": "Poączenie z serwerem nie powiodło się", + "ToastSortingPrefixesEmptyError": "Musi mieć co najmniej 1 prefiks sortowania", + "ToastSortingPrefixesUpdateSuccess": "Zaktualizowano prefiksy sortowania ({0} elementów)", "ToastTitleRequired": "Tytuł jest wymagany", "ToastUnknownError": "Nieznany błąd", "ToastUnlinkOpenIdFailed": "Nie udało się odpiąć użytkownika z OpenID", "ToastUnlinkOpenIdSuccess": "Użytkownik odpięty z OpenID", "ToastUploaderFilepathExistsError": "Ścieżka \"{0}\" już istnieje na serwerze", + "ToastUploaderItemExistsInSubdirectoryError": "Element \"{0}\" używa podkatalogu ścieżki przesyłania.", "ToastUserDeleteFailed": "Nie udało się usunąć użytkownika", "ToastUserDeleteSuccess": "Użytkownik usunięty", + "ToastUserPasswordChangeSuccess": "Hasło zostało pomyślnie zmienione", + "ToastUserPasswordMismatch": "Hasła nie są zgodne", + "ToastUserPasswordMustChange": "Nowe hasło nie może być takie samo jak stare hasło", + "ToastUserRootRequireName": "Należy wprowadzić nazwę użytkownika root", "TooltipAddChapters": "Dodaj rozdział(y)", "TooltipAddOneSecond": "Dodaj sekundę", "TooltipAdjustChapterStart": "Kliknij, aby skorygować czas początkowy", diff --git a/client/strings/ru.json b/client/strings/ru.json index c84fe9dcf..ed3f18b89 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -392,7 +392,7 @@ "LabelGenre": "Жанр", "LabelGenres": "Жанры", "LabelHardDeleteFile": "Жесткое удаление файла", - "LabelHasEbook": "Есть e-книга", + "LabelHasEbook": "Есть электронная книга", "LabelHasSupplementaryEbook": "Есть дополнительная e-книга", "LabelHideSubtitles": "Скрыть серии", "LabelHighestPriority": "Наивысший приоритет", @@ -437,8 +437,8 @@ "LabelLibraryItem": "Элемент библиотеки", "LabelLibraryName": "Имя библиотеки", "LabelLibrarySortByProgress": "Прогресс: Последнее обновление", - "LabelLibrarySortByProgressFinished": "Прогресс: Завершено", - "LabelLibrarySortByProgressStarted": "Прогресс: Начато", + "LabelLibrarySortByProgressFinished": "Прогресс: Закончена", + "LabelLibrarySortByProgressStarted": "Прогресс: Начата", "LabelLimit": "Лимит", "LabelLineSpacing": "Межстрочный интервал", "LabelListenAgain": "Послушать снова", diff --git a/client/strings/sk.json b/client/strings/sk.json index 6101eba7d..23ed276e8 100644 --- a/client/strings/sk.json +++ b/client/strings/sk.json @@ -275,7 +275,7 @@ "LabelBonus": "Bonus", "LabelBooks": "Knihy", "LabelButtonText": "Text tlačidla", - "LabelByAuthor": "od", + "LabelByAuthor": "od {0}", "LabelChangePassword": "Zmeniť heslo", "LabelChannels": "Kanály", "LabelChapterCount": "{0} kapitol", diff --git a/package-lock.json b/package-lock.json index e07fba51d..9cee15b25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.33.0", + "version": "2.35.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.33.0", + "version": "2.35.1", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 3108b5170..a7755fd66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.33.0", + "version": "2.35.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index ad55f6605..a54231d75 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -3,6 +3,7 @@ const Logger = require('./Logger') const Database = require('./Database') const TokenManager = require('./auth/TokenManager') const CoverSearchManager = require('./managers/CoverSearchManager') +const { LogLevel } = require('./utils/constants') /** * @typedef SocketClient @@ -85,6 +86,14 @@ class SocketAuthority { } } + requireAdminSocket(socket, eventName) { + const client = this.clients[socket.id] + if (client?.user?.isAdminOrUp) return true + + Logger.warn(`[SocketAuthority] Unauthorized ${eventName} socket event from socket ${socket.id}`) + return false + } + /** * Emits event with library item to all clients that can access the library item * Note: Emits toOldJSONExpanded() @@ -179,14 +188,25 @@ class SocketAuthority { socket.on('auth', (token) => this.authenticateSocket(socket, token)) // Scanning - socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId)) + socket.on('cancel_scan', (libraryId) => { + if (!this.requireAdminSocket(socket, 'cancel_scan')) return + this.cancelScan(libraryId) + }) // Cover search streaming socket.on('search_covers', (payload) => this.handleCoverSearch(socket, payload)) socket.on('cancel_cover_search', (requestId) => this.handleCancelCoverSearch(socket, requestId)) // Logs - socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level)) + socket.on('set_log_listener', (level) => { + if (!this.requireAdminSocket(socket, 'set_log_listener')) return + + if (!Number.isInteger(level) || !Object.values(LogLevel).includes(level)) { + Logger.warn(`[SocketAuthority] Invalid set_log_listener level from socket ${socket.id}`) + return + } + Logger.addSocketListener(socket, level) + }) socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id)) // Sent automatically from socket.io clients diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index 5efeb7a64..5933209c7 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -1,4 +1,5 @@ const { Op } = require('sequelize') +const uuid = require('uuid') const Database = require('../Database') const Logger = require('../Logger') @@ -115,6 +116,7 @@ class TokenManager { const payload = { userId: user.id, username: user.username, + jti: uuid.v4(), type: 'access' } const options = { @@ -138,6 +140,7 @@ class TokenManager { const payload = { userId: user.id, username: user.username, + jti: uuid.v4(), type: 'refresh' } const options = { @@ -183,20 +186,56 @@ class TokenManager { * @param {import('../models/User')} user * @param {import('express').Request} req * @param {import('express').Response} res + * @param {boolean} gracePeriod - whether to use the grace period * @returns {Promise<{ accessToken:string, refreshToken:string }>} */ - async rotateTokensForSession(session, user, req, res) { - // Generate new tokens + async rotateTokensForSession(session, user, req, res, gracePeriod = true) { + const previousRefreshToken = session.refreshToken const newAccessToken = this.generateTempAccessToken(user) - const newRefreshToken = this.generateRefreshToken(user) - - // Calculate new expiration time + let newRefreshToken = this.generateRefreshToken(user) const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) - // Update the session with the new refresh token and expiration - session.refreshToken = newRefreshToken - session.expiresAt = newExpiresAt - await session.save() + let lastRefreshToken = null + let lastRefreshTokenExpiresAt = null + if (gracePeriod) { + // Set grace period of old refresh token in case of race condition in token rotation. + // This grace period may need to be longer if fetching the user data takes longer due to large progress objects + lastRefreshToken = previousRefreshToken + lastRefreshTokenExpiresAt = new Date(Date.now() + 60 * 1000) // 1 minute grace period + } + + // Only update if this session row still has the refresh token we read + const [numUpdated] = await Database.sessionModel.update( + { + refreshToken: newRefreshToken, + expiresAt: newExpiresAt, + lastRefreshToken, + lastRefreshTokenExpiresAt + }, + { + where: { + id: session.id, + refreshToken: previousRefreshToken + } + } + ) + + if (numUpdated === 0) { + Logger.debug(`[TokenManager] Race condition in rotateTokensForSession for user ${user.id}, getting new token`) + + const updatedSession = await Database.sessionModel.findOne({ where: { id: session.id } }) + + newRefreshToken = updatedSession.refreshToken + session.refreshToken = updatedSession.refreshToken + session.expiresAt = updatedSession.expiresAt + session.lastRefreshToken = updatedSession.lastRefreshToken + session.lastRefreshTokenExpiresAt = updatedSession.lastRefreshTokenExpiresAt + } else { + session.refreshToken = newRefreshToken + session.expiresAt = newExpiresAt + session.lastRefreshToken = lastRefreshToken + session.lastRefreshTokenExpiresAt = lastRefreshTokenExpiresAt + } // Set new refresh token cookie this.setRefreshTokenCookie(req, res, newRefreshToken) @@ -294,23 +333,40 @@ class TokenManager { } } - const session = await Database.sessionModel.findOne({ - where: { refreshToken: refreshToken } + let session = await Database.sessionModel.findOne({ + where: { + [Op.or]: [{ refreshToken: refreshToken }, { lastRefreshToken: refreshToken }] + } }) if (!session) { - Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`) + Logger.error(`[TokenManager] Failed to refresh token. Session not found`) return { error: 'Invalid refresh token' } } - // Check if session is expired in database - if (session.expiresAt < new Date()) { - Logger.info(`[TokenManager] Session expired in database, cleaning up`) - await session.destroy() - return { - error: 'Refresh token expired' + let isGracePeriod = false + if (session.refreshToken !== refreshToken) { + // Token matched lastRefreshToken + if (session.lastRefreshTokenExpiresAt && session.lastRefreshTokenExpiresAt > new Date()) { + isGracePeriod = true + Logger.debug(`[TokenManager] Grace period hit for user ${session.userId}`) + } else { + Logger.debug(`[TokenManager] Grace period expired for user ${session.userId}`) + return { + error: 'Invalid refresh token' + } + } + } else { + // Token matched current refreshToken + // Check if session is expired in database + if (session.expiresAt < new Date()) { + Logger.info(`[TokenManager] Session expired in database, cleaning up`) + await session.destroy() + return { + error: 'Refresh token expired' + } } } @@ -322,6 +378,20 @@ class TokenManager { } } + if (isGracePeriod) { + // Return the already rotated refresh token store in the database, + // and generate a new access token without changing the refresh token + // again + const accessToken = this.generateTempAccessToken(user) + this.setRefreshTokenCookie(req, res, session.refreshToken) + + return { + accessToken, + refreshToken: session.refreshToken, + user + } + } + const newTokens = await this.rotateTokensForSession(session, user, req, res) return { accessToken: newTokens.accessToken, @@ -375,7 +445,7 @@ class TokenManager { // So rotate token for current session const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } }) if (currentSession) { - const newTokens = await this.rotateTokensForSession(currentSession, user, req, res) + const newTokens = await this.rotateTokensForSession(currentSession, user, req, res, false) // Invalidate all sessions for the user except the current one await Database.sessionModel.destroy({ @@ -389,7 +459,7 @@ class TokenManager { return newTokens.accessToken } else { - Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`) + Logger.error(`[TokenManager] No session found to rotate tokens`) } } @@ -413,7 +483,7 @@ class TokenManager { try { const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } }) - Logger.info(`[TokenManager] Refresh token ${refreshToken} invalidated, ${numDeleted} sessions deleted`) + Logger.info(`[TokenManager] Refresh token invalidated, ${numDeleted} sessions deleted`) return true } catch (error) { Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`) diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js index 82ed3e50a..8c2e80aec 100644 --- a/server/controllers/AuthorController.js +++ b/server/controllers/AuthorController.js @@ -10,7 +10,7 @@ const CacheManager = require('../managers/CacheManager') const CoverManager = require('../managers/CoverManager') const AuthorFinder = require('../finders/AuthorFinder') -const { reqSupportsWebp, isValidASIN } = require('../utils/index') +const { reqSupportsWebp, isValidASIN, clampPositiveInt } = require('../utils/index') const naturalSort = createNewSortInstance({ comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare @@ -149,7 +149,7 @@ class AuthorController { }) if (libraryItems.length) { await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor - await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate) // Create all new BookAuthor + await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate, { ignoreDuplicates: true }) // Create all new unique BookAuthor for (const libraryItem of libraryItems) { await libraryItem.saveMetadataFile() } @@ -412,8 +412,8 @@ class AuthorController { const options = { format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'), - height: height ? parseInt(height) : null, - width: width ? parseInt(width) : null + height: clampPositiveInt(height ? parseInt(height) : null, 4096), + width: clampPositiveInt(width ? parseInt(width) : null, 4096) } return CacheManager.handleAuthorCache(res, authorId, options) } diff --git a/server/controllers/CollectionController.js b/server/controllers/CollectionController.js index 475adfe0f..bb00ea346 100644 --- a/server/controllers/CollectionController.js +++ b/server/controllers/CollectionController.js @@ -3,6 +3,7 @@ const Sequelize = require('sequelize') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') +const htmlSanitizer = require('../utils/htmlSanitizer') const RssFeedManager = require('../managers/RssFeedManager') @@ -31,13 +32,19 @@ class CollectionController { async create(req, res) { const reqBody = req.body || {} + const nameCleaned = htmlSanitizer.stripAllTags(reqBody.name) + // Validation - if (!reqBody.name || !reqBody.libraryId) { + if (!nameCleaned || !reqBody.libraryId) { return res.status(400).send('Invalid collection data') } if (reqBody.description && typeof reqBody.description !== 'string') { return res.status(400).send('Invalid collection description') } + if (!req.user.checkCanAccessLibrary(reqBody.libraryId)) { + Logger.warn(`[CollectionController] User "${req.user.username}" attempted to create collection in inaccessible library ${reqBody.libraryId}`) + return res.sendStatus(403) + } const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string') if (!libraryItemIds.length) { return res.status(400).send('Invalid collection data. No books') @@ -65,7 +72,7 @@ class CollectionController { newCollection = await Database.collectionModel.create( { libraryId: reqBody.libraryId, - name: reqBody.name, + name: nameCleaned, description: reqBody.description || null }, { transaction } @@ -106,8 +113,9 @@ class CollectionController { */ async findAll(req, res) { const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user) + const accessibleCollections = collectionsExpanded.filter((c) => req.user.checkCanAccessLibrary(c.libraryId)) res.json({ - collections: collectionsExpanded + collections: accessibleCollections }) } @@ -145,9 +153,12 @@ class CollectionController { collectionUpdatePayload.description = req.body.description wasUpdated = true } - if (req.body.name !== undefined && req.body.name !== req.collection.name) { - collectionUpdatePayload.name = req.body.name - wasUpdated = true + if (req.body.name !== undefined && typeof req.body.name === 'string') { + const nameCleaned = htmlSanitizer.stripAllTags(req.body.name) + if (nameCleaned !== req.collection.name) { + collectionUpdatePayload.name = nameCleaned + wasUpdated = true + } } if (wasUpdated) { @@ -425,6 +436,10 @@ class CollectionController { if (!collection) { return res.status(404).send('Collection not found') } + if (!req.user.checkCanAccessLibrary(collection.libraryId)) { + Logger.warn(`[CollectionController] User "${req.user.username}" attempted to access collection ${collection.id} in inaccessible library ${collection.libraryId}`) + return res.status(404).send('Collection not found') + } req.collection = collection } diff --git a/server/controllers/FileSystemController.js b/server/controllers/FileSystemController.js index 4b0a94b39..41e082fd4 100644 --- a/server/controllers/FileSystemController.js +++ b/server/controllers/FileSystemController.js @@ -117,7 +117,7 @@ class FileSystemController { filepath = fileUtils.filePathToPOSIX(filepath) // Ensure filepath is inside library folder (prevents directory traversal) - if (!filepath.startsWith(libraryFolder.path)) { + if (!fileUtils.isSameOrSubPath(libraryFolder.path, filepath)) { Logger.error(`[FileSystemController] Filepath is not inside library folder: ${filepath}`) return res.sendStatus(400) } diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 55ef45690..73b3d5c60 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -462,7 +462,7 @@ class LibraryController { } } Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`) - await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id) } if (authorIds.length) { @@ -563,7 +563,7 @@ class LibraryController { mediaItemIds.push(libraryItem.mediaId) } Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`) - await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id) } // Set PlaybackSessions libraryId to null @@ -714,7 +714,7 @@ class LibraryController { } } Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`) - await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id) } if (authorIds.length) { @@ -1435,10 +1435,15 @@ class LibraryController { const libraryItems = await Database.libraryItemModel.findAll({ attributes: ['id', 'libraryId', 'path', 'isFile'], where: { - id: itemIds + id: itemIds, + libraryId: req.library.id } }) + if (libraryItems.length < itemIds.length) { + Logger.warn(`[LibraryController] User "${req.user.username}" requested ${itemIds.length} items but only ${libraryItems.length} are in library "${req.library.id}"`) + } + Logger.info(`[LibraryController] User "${req.user.username}" requested download for items "${itemIds}"`) const filename = `LibraryItems-${Date.now()}.zip` diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index 5247dbb06..07b4ee67f 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -1,13 +1,14 @@ const { Request, Response, NextFunction } = require('express') const Path = require('path') const fs = require('../libs/fsExtra') +const cron = require('../libs/nodeCron') const uaParserJs = require('../libs/uaParser') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') const zipHelpers = require('../utils/zipHelpers') -const { reqSupportsWebp } = require('../utils/index') +const { reqSupportsWebp, clampPositiveInt } = require('../utils/index') const { ScanResult, AudioMimeType } = require('../utils/constants') const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils') const LibraryItemScanner = require('../scanner/LibraryItemScanner') @@ -36,6 +37,24 @@ const ShareManager = require('../managers/ShareManager') * @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile */ +/** + * Enforce per-item access for batch item routes + * + * @param {RequestWithUser} req + * @param {Response} res + * @param {import('../models/LibraryItem')[]} libraryItems + * @returns {boolean} true if the user may access every item; false if 403 was sent + */ +function ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems) { + for (const libraryItem of libraryItems) { + if (!req.user.checkCanAccessLibraryItem(libraryItem)) { + res.sendStatus(403) + return false + } + } + return true +} + class LibraryItemController { constructor() {} @@ -111,7 +130,7 @@ class LibraryItemController { } } - await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds, req.libraryItem.libraryId) if (hardDelete) { Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) await fs.remove(libraryItemPath).catch((error) => { @@ -202,6 +221,11 @@ class LibraryItemController { } else if (mediaPayload.autoDownloadSchedule !== undefined && req.libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) { isPodcastAutoDownloadUpdated = true } + + if (mediaPayload.autoDownloadSchedule && !cron.validate(mediaPayload.autoDownloadSchedule)) { + Logger.error(`[LibraryItemController] Invalid auto download schedule cron expression "${mediaPayload.autoDownloadSchedule}" for library item "${req.libraryItem.media.title}"`) + return res.status(400).send('Invalid auto download schedule cron expression') + } } let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url @@ -398,8 +422,8 @@ class LibraryItemController { const options = { format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'), - height: height ? parseInt(height) : null, - width: width ? parseInt(width) : null + height: clampPositiveInt(height ? parseInt(height) : null, 4096), + width: clampPositiveInt(width ? parseInt(width) : null, 4096) } return CacheManager.handleCoverCache(res, libraryItemId, options) } @@ -547,7 +571,13 @@ class LibraryItemController { return res.sendStatus(404) } + // Ensure user has permission to delete these library items + if (!ensureUserCanAccessLibraryItemsForBatch(req, res, itemsToDelete)) { + return + } + const libraryId = itemsToDelete[0].libraryId + for (const libraryItem of itemsToDelete) { const libraryItemPath = libraryItem.path Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.title}" with id "${libraryItem.id}"`) @@ -565,7 +595,7 @@ class LibraryItemController { authorIds.push(...libraryItem.media.authors.map((au) => au.id)) } } - await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds) + await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, libraryItem.libraryId) if (hardDelete) { Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`) await fs.remove(libraryItemPath).catch((error) => { @@ -581,6 +611,7 @@ class LibraryItemController { } await Database.resetLibraryIssuesFilterData(libraryId) + res.sendStatus(200) } @@ -593,6 +624,11 @@ class LibraryItemController { * @param {Response} res */ async batchUpdate(req, res) { + if (!req.user.canUpdate) { + Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to batch update without permission`) + return res.sendStatus(403) + } + const updatePayloads = req.body if (!Array.isArray(updatePayloads) || !updatePayloads.length) { Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`) @@ -615,6 +651,11 @@ class LibraryItemController { return res.sendStatus(404) } + // Ensure user has permission to update these library items + if (!ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems)) { + return + } + let itemsUpdated = 0 const seriesIdsRemoved = [] @@ -624,6 +665,11 @@ class LibraryItemController { const mediaPayload = updatePayload.mediaPayload const libraryItem = libraryItems.find((li) => li.id === updatePayload.id) + if (libraryItem.isPodcast && mediaPayload.autoDownloadSchedule && !cron.validate(mediaPayload.autoDownloadSchedule)) { + Logger.warn(`[LibraryItemController] Invalid auto download schedule cron expression "${mediaPayload.autoDownloadSchedule}" for library item "${libraryItem.media.title}" - skipping update`) + continue + } + let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload) if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) { @@ -695,6 +741,10 @@ class LibraryItemController { const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ id: libraryItemIds }) + // Ensure user has permission to access these library items + if (!ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems)) { + return + } res.json({ libraryItems: libraryItems.map((li) => li.toOldJSONExpanded()) }) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 490cb27d2..591f8ccf2 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -8,7 +8,7 @@ const Database = require('../Database') const Watcher = require('../Watcher') const libraryItemFilters = require('../utils/queries/libraryItemFilters') -const patternValidation = require('../libs/nodeCron/pattern-validation') +const cron = require('../libs/nodeCron') const { isObject, getTitleIgnorePrefix } = require('../utils/index') const { sanitizeFilename } = require('../utils/fileUtils') @@ -605,13 +605,11 @@ class MiscController { return res.sendStatus(400) } - try { - patternValidation(expression) - res.sendStatus(200) - } catch (error) { - Logger.warn(`[MiscController] Invalid cron expression ${expression}`, error.message) - res.status(400).send(error.message) + if (!cron.validate(expression)) { + Logger.warn(`[MiscController] Invalid cron expression ${expression}`) + return res.status(400).send('Invalid cron expression') } + res.sendStatus(200) } /** diff --git a/server/controllers/PlaylistController.js b/server/controllers/PlaylistController.js index 972c352a4..6ad7cff9e 100644 --- a/server/controllers/PlaylistController.js +++ b/server/controllers/PlaylistController.js @@ -2,6 +2,7 @@ const { Request, Response, NextFunction } = require('express') const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') +const htmlSanitizer = require('../utils/htmlSanitizer') /** * @typedef RequestUserObject @@ -29,12 +30,17 @@ class PlaylistController { const reqBody = req.body || {} // Validation - if (!reqBody.name || !reqBody.libraryId) { + const nameCleaned = htmlSanitizer.stripAllTags(reqBody.name) + if (!nameCleaned || !reqBody.libraryId) { return res.status(400).send('Invalid playlist data') } if (reqBody.description && typeof reqBody.description !== 'string') { return res.status(400).send('Invalid playlist description') } + if (!req.user.checkCanAccessLibrary(reqBody.libraryId)) { + Logger.warn(`[PlaylistController] User "${req.user.username}" attempted to create playlist in inaccessible library ${reqBody.libraryId}`) + return res.sendStatus(403) + } const items = reqBody.items || [] const isPodcast = items.some((i) => i.episodeId) const libraryItemIds = new Set() @@ -84,7 +90,7 @@ class PlaylistController { { libraryId: reqBody.libraryId, userId: req.user.id, - name: reqBody.name, + name: nameCleaned, description: reqBody.description || null }, { transaction } @@ -131,8 +137,9 @@ class PlaylistController { */ async findAllForUser(req, res) { const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id) + const accessiblePlaylists = playlistsForUser.filter((p) => req.user.checkCanAccessLibrary(p.libraryId)) res.json({ - playlists: playlistsForUser + playlists: accessiblePlaylists }) } @@ -174,7 +181,11 @@ class PlaylistController { } const playlistUpdatePayload = {} - if (reqBody.name) playlistUpdatePayload.name = reqBody.name + + const nameCleaned = htmlSanitizer.stripAllTags(reqBody.name) + if (nameCleaned) { + playlistUpdatePayload.name = nameCleaned + } if (reqBody.description) playlistUpdatePayload.description = reqBody.description // Update name and description @@ -502,6 +513,10 @@ class PlaylistController { if (!collection) { return res.status(404).send('Collection not found') } + if (!req.user.checkCanAccessLibrary(collection.libraryId)) { + Logger.warn(`[PlaylistController] User "${req.user.username}" attempted to create playlist from collection ${collection.id} in inaccessible library ${collection.libraryId}`) + return res.status(404).send('Collection not found') + } // Expand collection to get library items const collectionExpanded = await collection.getOldJsonExpanded(req.user) if (!collectionExpanded) { @@ -567,6 +582,10 @@ class PlaylistController { Logger.warn(`[PlaylistController] Playlist ${req.params.id} requested by user ${req.user.id} that is not the owner`) return res.sendStatus(403) } + if (!req.user.checkCanAccessLibrary(playlist.libraryId)) { + Logger.warn(`[PlaylistController] User "${req.user.username}" attempted to access playlist ${playlist.id} in inaccessible library ${playlist.libraryId}`) + return res.status(404).send('Playlist not found') + } req.playlist = playlist } diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index c70287600..85173e622 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -5,9 +5,10 @@ const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') const fs = require('../libs/fsExtra') +const cron = require('../libs/nodeCron') const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils') -const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils') +const { getFileTimestampsWithIno, filePathToPOSIX, isSameOrSubPath } = require('../utils/fileUtils') const { validateUrl } = require('../utils/index') const htmlSanitizer = require('../utils/htmlSanitizer') @@ -46,6 +47,11 @@ class PodcastController { return res.status(400).send('Invalid request body. "media" and "media.metadata" are required') } + if (payload.media.autoDownloadSchedule && !cron.validate(payload.media.autoDownloadSchedule)) { + Logger.error(`[PodcastController] Invalid auto download schedule cron expression "${payload.media.autoDownloadSchedule}"`) + return res.status(400).send('Invalid auto download schedule cron expression') + } + const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId) if (!library) { Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`) @@ -58,8 +64,18 @@ class PodcastController { return res.status(404).send('Folder not found') } + if (typeof payload.path !== 'string' || !payload.path.trim()) { + return res.status(400).send('Invalid request body. "path" must be a non-empty string') + } + + const libraryFolderPath = filePathToPOSIX(folder.path) const podcastPath = filePathToPOSIX(payload.path) + if (!isSameOrSubPath(libraryFolderPath, podcastPath)) { + Logger.error(`[PodcastController] Create: Podcast path is outside library folder "${libraryFolderPath}": "${podcastPath}"`) + return res.status(400).send('Podcast path must be inside the selected library folder') + } + // Check if a library item with this podcast folder exists already const existingLibraryItem = (await Database.libraryItemModel.count({ @@ -83,7 +99,7 @@ class PodcastController { const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath) - let relPath = payload.path.replace(folder.fullPath, '') + let relPath = podcastPath.replace(libraryFolderPath, '') if (relPath.startsWith('/')) relPath = relPath.slice(1) let newLibraryItem = null diff --git a/server/controllers/ShareController.js b/server/controllers/ShareController.js index 3e7ea1deb..73da84a8a 100644 --- a/server/controllers/ShareController.js +++ b/server/controllers/ShareController.js @@ -53,6 +53,10 @@ class ShareController { if (playbackSession) { if (mediaItemShare.id === playbackSession.mediaItemShareId) { Logger.debug(`[ShareController] Found share playback session ${req.cookies.share_session_id}`) + // If ?t was provided, override the cached currentTime + if (startTime > 0 && startTime < playbackSession.duration) { + playbackSession.currentTime = startTime + } mediaItemShare.playbackSession = playbackSession.toJSONForClient() return res.json(mediaItemShare) } else { diff --git a/server/managers/ApiCacheManager.js b/server/managers/ApiCacheManager.js index 8a67e4898..e34e4c055 100644 --- a/server/managers/ApiCacheManager.js +++ b/server/managers/ApiCacheManager.js @@ -42,11 +42,14 @@ class ApiCacheManager { } clearUserProgressSlices(modelName, hook) { - const removedPersonalized = this.modelsInvalidatingPersonalized.has(modelName) ? this.clearByUrlPattern(/^\/libraries\/[^/]+\/personalized/) : 0 + let removedPersonalized = 0 + let removedRecentEpisodes = 0 + if (this.modelsInvalidatingPersonalized.has(modelName)) { + removedPersonalized = this.clearByUrlPattern(/^\/libraries\/[^/]+\/personalized/) + removedRecentEpisodes = this.clearByUrlPattern(/^\/libraries\/[^/]+\/recent-episodes/) + } const removedMe = this.modelsInvalidatingMe.has(modelName) ? this.clearByUrlPattern(/^\/me(\/|\?|$)/) : 0 - Logger.debug( - `[ApiCacheManager] ${modelName}.${hook}: cleared user-progress cache slices (personalized=${removedPersonalized}, me=${removedMe})` - ) + Logger.debug(`[ApiCacheManager] ${modelName}.${hook}: cleared user-progress cache slices (personalized=${removedPersonalized}, recentEpisodes=${removedRecentEpisodes}, me=${removedMe})`) } clear(model, hook) { diff --git a/server/managers/BackupManager.js b/server/managers/BackupManager.js index 2697f94ea..a7b531e62 100644 --- a/server/managers/BackupManager.js +++ b/server/managers/BackupManager.js @@ -126,13 +126,31 @@ class BackupManager { } catch (error) { // Not a valid zip file Logger.error('[BackupManager] Failed to read backup file - backup might not be a valid .zip file', tempPath, error) + await zip.close().catch(() => {}) + await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err)) return res.status(400).send('Failed to read backup file - backup might not be a valid .zip file') } - if (!Object.keys(entries).includes('absdatabase.sqlite')) { + if (!entries['absdatabase.sqlite']) { Logger.error(`[BackupManager] Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.`) + await zip.close().catch(() => {}) + await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err)) return res.status(500).send('Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.') } + const detailsEntry = entries['details'] + if (!detailsEntry) { + Logger.error('[BackupManager] Invalid backup - missing details entry') + await zip.close().catch(() => {}) + await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err)) + return res.status(400).send('Invalid backup file - missing details entry') + } + if (detailsEntry.size > 1024 * 1024) { + Logger.error(`[BackupManager] Backup details entry too large: ${detailsEntry.size} bytes`) + await zip.close().catch(() => {}) + await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err)) + return res.status(400).send('Invalid backup file - details entry too large') + } + const data = await zip.entryData('details') const details = data.toString('utf8').split('\n') @@ -140,9 +158,13 @@ class BackupManager { if (!backup.serverVersion) { Logger.error(`[BackupManager] Invalid backup with no server version - might be a backup created before version 2.0.0`) + await zip.close().catch(() => {}) + await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err)) return res.status(500).send('Invalid backup. Might be a backup created before version 2.0.0.') } + await zip.close().catch(() => {}) + backup.fileSize = await getFileSize(backup.fullPath) const existingBackupIndex = this.backups.findIndex((b) => b.id === backup.id) @@ -257,9 +279,24 @@ class BackupManager { let data = null try { zip = new StreamZip.async({ file: fullFilePath }) + const entries = await zip.entries() + + const detailsEntry = entries['details'] + if (!detailsEntry) { + Logger.error(`[BackupManager] Backup "${fullFilePath}" missing details entry - skipping`) + await zip.close().catch(() => {}) + continue + } + if (detailsEntry.size > 1024 * 1024) { + Logger.error(`[BackupManager] Backup "${fullFilePath}" details entry too large (${detailsEntry.size} bytes) - skipping`) + await zip.close().catch(() => {}) + continue + } + data = await zip.entryData('details') } catch (error) { Logger.error(`[BackupManager] Failed to unzip backup "${fullFilePath}"`, error) + if (zip) await zip.close().catch(() => {}) continue } diff --git a/server/managers/CronManager.js b/server/managers/CronManager.js index d3e652129..7138d26a8 100644 --- a/server/managers/CronManager.js +++ b/server/managers/CronManager.js @@ -153,6 +153,11 @@ class CronManager { startPodcastCron(expression, libraryItemIds) { try { + if (!cron.validate(expression)) { + Logger.error(`[CronManager] Invalid auto download schedule cron expression "${expression}" - not starting podcast episode check cron`) + return + } + Logger.debug(`[CronManager] Scheduling podcast episode check cron "${expression}" for ${libraryItemIds.length} item(s)`) const task = cron.schedule(expression, () => { if (this.podcastCronExpressionsExecuting.includes(expression)) { @@ -167,7 +172,7 @@ class CronManager { task }) } catch (error) { - Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error) + Logger.error(`[PodcastManager] Failed to schedule podcast cron ${expression}`, error) } } diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index c4681bdc2..a066a0d32 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -2,6 +2,7 @@ const { Request, Response } = require('express') const Path = require('path') const Logger = require('../Logger') +const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') @@ -216,6 +217,11 @@ class RssFeedManager { res.sendStatus(404) return } + // Express does not set the correct mimetype for m4b files so use our defined mimetypes if available + const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(episodePath)) + if (audioMimeType) { + res.setHeader('Content-Type', audioMimeType) + } res.sendFile(episodePath) } diff --git a/server/migrations/v2.35.0-add-last-refresh-token.js b/server/migrations/v2.35.0-add-last-refresh-token.js new file mode 100644 index 000000000..0ad190e9a --- /dev/null +++ b/server/migrations/v2.35.0-add-last-refresh-token.js @@ -0,0 +1,84 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.35.0' +const migrationName = `${migrationVersion}-add-last-refresh-token` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This migration script adds lastRefreshToken and lastRefreshTokenExpiresAt columns to the sessions table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists('sessions')) { + const tableDescription = await queryInterface.describeTable('sessions') + + if (!tableDescription.lastRefreshToken) { + logger.info(`${loggerPrefix} Adding lastRefreshToken column to sessions table`) + await queryInterface.addColumn('sessions', 'lastRefreshToken', { + type: queryInterface.sequelize.Sequelize.DataTypes.STRING, + allowNull: true + }) + } else { + logger.info(`${loggerPrefix} lastRefreshToken column already exists in sessions table`) + } + + if (!tableDescription.lastRefreshTokenExpiresAt) { + logger.info(`${loggerPrefix} Adding lastRefreshTokenExpiresAt column to sessions table`) + await queryInterface.addColumn('sessions', 'lastRefreshTokenExpiresAt', { + type: queryInterface.sequelize.Sequelize.DataTypes.DATE, + allowNull: true + }) + } else { + logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column already exists in sessions table`) + } + } else { + logger.info(`${loggerPrefix} sessions table does not exist`) + } + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This migration script removes the lastRefreshToken and lastRefreshTokenExpiresAt columns from the sessions table. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + if (await queryInterface.tableExists('sessions')) { + const tableDescription = await queryInterface.describeTable('sessions') + + if (tableDescription.lastRefreshToken) { + logger.info(`${loggerPrefix} Removing lastRefreshToken column from sessions table`) + await queryInterface.removeColumn('sessions', 'lastRefreshToken') + } else { + logger.info(`${loggerPrefix} lastRefreshToken column does not exist in sessions table`) + } + + if (tableDescription.lastRefreshTokenExpiresAt) { + logger.info(`${loggerPrefix} Removing lastRefreshTokenExpiresAt column from sessions table`) + await queryInterface.removeColumn('sessions', 'lastRefreshTokenExpiresAt') + } else { + logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column does not exist in sessions table`) + } + } else { + logger.info(`${loggerPrefix} sessions table does not exist`) + } + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +module.exports = { up, down } diff --git a/server/models/Author.js b/server/models/Author.js index 287b66976..65561e211 100644 --- a/server/models/Author.js +++ b/server/models/Author.js @@ -111,16 +111,17 @@ class Author extends Model { * * @param {string} name * @param {string} libraryId - * @returns {Promise} + * @returns {Promise<{ author: Author, created: boolean }>} */ static async findOrCreateByNameAndLibrary(name, libraryId) { const author = await this.getByNameAndLibrary(name, libraryId) - if (author) return author - return this.create({ + if (author) return { author, created: false } + const newAuthor = await this.create({ name, lastFirst: this.getLastFirst(name), libraryId }) + return { author: newAuthor, created: true } } /** diff --git a/server/models/Book.js b/server/models/Book.js index 96371f3a2..d9f2ff132 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -4,6 +4,7 @@ const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils') const parseNameString = require('../utils/parsers/parseNameString') const htmlSanitizer = require('../utils/htmlSanitizer') const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters') +const SocketAuthority = require('../SocketAuthority') /** * @typedef EBookFileObject @@ -470,13 +471,23 @@ class Book extends Model { for (const author of authorsRemoved) { await bookAuthorModel.removeByIds(author.id, this.id) + const numBooks = await bookAuthorModel.getCountForAuthor(author.id) + if (numBooks > 0) { + SocketAuthority.emitter('author_updated', author.toOldJSONExpanded(numBooks)) + } Logger.debug(`[Book] "${this.title}" Removed author "${author.name}"`) this.authors = this.authors.filter((au) => au.id !== author.id) } const authorsAdded = [] for (const authorName of newAuthorNames) { - const author = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId) + const { author, created } = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId) await bookAuthorModel.create({ bookId: this.id, authorId: author.id }) + if (created) { + SocketAuthority.emitter('author_added', author.toOldJSON()) + } else { + const numBooks = await bookAuthorModel.getCountForAuthor(author.id) + SocketAuthority.emitter('author_updated', author.toOldJSONExpanded(numBooks)) + } Logger.debug(`[Book] "${this.title}" Added author "${author.name}"`) this.authors.push(author) authorsAdded.push(author) diff --git a/server/models/Podcast.js b/server/models/Podcast.js index a96e1dd02..6dbe1cd19 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -78,6 +78,7 @@ class Podcast extends Model { */ static async createFromRequest(payload, transaction) { const title = typeof payload.metadata.title === 'string' ? payload.metadata.title : null + // cron expression validated in controller const autoDownloadSchedule = typeof payload.autoDownloadSchedule === 'string' ? payload.autoDownloadSchedule : null const genres = Array.isArray(payload.metadata.genres) && payload.metadata.genres.every((g) => typeof g === 'string' && g.length) ? payload.metadata.genres : [] const tags = Array.isArray(payload.tags) && payload.tags.every((t) => typeof t === 'string' && t.length) ? payload.tags : [] @@ -89,6 +90,9 @@ class Podcast extends Model { } }) + const rawDescription = typeof payload.metadata.description === 'string' ? payload.metadata.description : null + const description = rawDescription ? htmlSanitizer.sanitize(rawDescription) : null + return this.create( { title, @@ -97,7 +101,7 @@ class Podcast extends Model { releaseDate: typeof payload.metadata.releaseDate === 'string' ? payload.metadata.releaseDate : null, feedURL: typeof payload.metadata.feedUrl === 'string' ? payload.metadata.feedUrl : null, imageURL: typeof payload.metadata.imageUrl === 'string' ? payload.metadata.imageUrl : null, - description: typeof payload.metadata.description === 'string' ? payload.metadata.description : null, + description, itunesPageURL: typeof payload.metadata.itunesPageUrl === 'string' ? payload.metadata.itunesPageUrl : null, itunesId: typeof payload.metadata.itunesId === 'string' ? payload.metadata.itunesId : null, itunesArtistId: typeof payload.metadata.itunesArtistId === 'string' ? payload.metadata.itunesArtistId : null, @@ -270,6 +274,7 @@ class Podcast extends Model { hasUpdates = true } if (typeof payload.autoDownloadSchedule === 'string' && payload.autoDownloadSchedule !== this.autoDownloadSchedule) { + // cron expression validated in controller this.autoDownloadSchedule = payload.autoDownloadSchedule hasUpdates = true } diff --git a/server/models/Session.js b/server/models/Session.js index fe9dd5425..3b85bd46a 100644 --- a/server/models/Session.js +++ b/server/models/Session.js @@ -18,6 +18,10 @@ class Session extends Model { this.userId /** @type {Date} */ this.expiresAt + /** @type {string} */ + this.lastRefreshToken + /** @type {Date} */ + this.lastRefreshTokenExpiresAt // Expanded properties @@ -66,6 +70,14 @@ class Session extends Model { expiresAt: { type: DataTypes.DATE, allowNull: false + }, + lastRefreshToken: { + type: DataTypes.STRING, + allowNull: true + }, + lastRefreshTokenExpiresAt: { + type: DataTypes.DATE, + allowNull: true } }, { diff --git a/server/models/User.js b/server/models/User.js index 936efde13..f380f8e4f 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -352,11 +352,7 @@ class User extends Model { if (cachedUser) return cachedUser const user = await this.findOne({ - where: { - username: { - [sequelize.Op.like]: username - } - }, + where: sequelize.where(sequelize.fn('lower', sequelize.col('username')), username.toLowerCase()), include: this.sequelize.models.mediaProgress }) @@ -377,11 +373,7 @@ class User extends Model { if (cachedUser) return cachedUser const user = await this.findOne({ - where: { - email: { - [sequelize.Op.like]: email - } - }, + where: sequelize.where(sequelize.fn('lower', sequelize.col('email')), email.toLowerCase()), include: this.sequelize.models.mediaProgress }) diff --git a/server/objects/DeviceInfo.js b/server/objects/DeviceInfo.js index 22ebfbea4..68237f0fb 100644 --- a/server/objects/DeviceInfo.js +++ b/server/objects/DeviceInfo.js @@ -96,7 +96,12 @@ class DeviceInfo { this.clientVersion = stripAllTags(clientDeviceInfo?.clientVersion) || serverVersion this.manufacturer = stripAllTags(clientDeviceInfo?.manufacturer) || null this.model = stripAllTags(clientDeviceInfo?.model) || null - this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || null + + if (typeof clientDeviceInfo?.sdkVersion === 'number') { + this.sdkVersion = clientDeviceInfo.sdkVersion.toString() + } else { + this.sdkVersion = stripAllTags(clientDeviceInfo?.sdkVersion) || null + } this.clientName = stripAllTags(clientDeviceInfo?.clientName) || null if (this.sdkVersion) { diff --git a/server/objects/PlaybackSession.js b/server/objects/PlaybackSession.js index ba031b665..ace0256e8 100644 --- a/server/objects/PlaybackSession.js +++ b/server/objects/PlaybackSession.js @@ -110,7 +110,8 @@ class PlaybackSession { startedAt: this.startedAt, updatedAt: this.updatedAt, audioTracks: this.audioTracks.map((at) => at.toJSON?.() || { ...at }), - libraryItem: libraryItem?.toOldJSONExpanded() || null + libraryItem: libraryItem?.toOldJSONExpanded() || null, + coverAspectRatio: this.coverAspectRatio !== null ? this.coverAspectRatio : undefined // Used for share sessions } } diff --git a/server/objects/Stream.js b/server/objects/Stream.js index 5aa013e8e..70361463f 100644 --- a/server/objects/Stream.js +++ b/server/objects/Stream.js @@ -73,7 +73,7 @@ class Stream extends EventEmitter { return [AudioMimeType.FLAC, AudioMimeType.OPUS, AudioMimeType.WMA, AudioMimeType.AIFF, AudioMimeType.WEBM, AudioMimeType.WEBMA, AudioMimeType.AWB, AudioMimeType.CAF] } get codecsToForceAAC() { - return ['alac', 'ac3', 'eac3'] + return ['alac', 'ac3', 'eac3', 'opus'] } get userToken() { return this.user.token diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index db04bf5ec..e89b364f3 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -363,8 +363,9 @@ class ApiRouter { * Remove library item and associated entities * @param {string} libraryItemId * @param {string[]} mediaItemIds array of bookId or podcastEpisodeId + * @param {string} libraryId */ - async handleDeleteLibraryItem(libraryItemId, mediaItemIds) { + async handleDeleteLibraryItem(libraryItemId, mediaItemIds, libraryId) { const numProgressRemoved = await Database.mediaProgressModel.destroy({ where: { mediaItemId: mediaItemIds @@ -395,7 +396,8 @@ class ApiRouter { await Database.libraryItemModel.removeById(libraryItemId) SocketAuthority.emitter('item_removed', { - id: libraryItemId + id: libraryItemId, + libraryId }) } diff --git a/server/scanner/AbsMetadataFileScanner.js b/server/scanner/AbsMetadataFileScanner.js index e554dfb44..0ea34b1bb 100644 --- a/server/scanner/AbsMetadataFileScanner.js +++ b/server/scanner/AbsMetadataFileScanner.js @@ -5,15 +5,15 @@ const { LogLevel } = require('../utils/constants') const abmetadataGenerator = require('../utils/generators/abmetadataGenerator') class AbsMetadataFileScanner { - constructor() { } + constructor() {} /** * Check for metadata.json file and set book metadata - * - * @param {import('./LibraryScan')} libraryScan - * @param {import('./LibraryItemScanData')} libraryItemData - * @param {Object} bookMetadata - * @param {string} [existingLibraryItemId] + * + * @param {import('./LibraryScan')} libraryScan + * @param {import('./LibraryItemScanData')} libraryItemData + * @param {Object} bookMetadata + * @param {string} [existingLibraryItemId] */ async scanBookMetadataFile(libraryScan, libraryItemData, bookMetadata, existingLibraryItemId = null) { const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile @@ -32,7 +32,8 @@ class AbsMetadataFileScanner { if (metadataText) { libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`) - const abMetadata = abmetadataGenerator.parseJson(metadataText) || {} + const abMetadata = abmetadataGenerator.parseJson(metadataText, 'book') || {} + for (const key in abMetadata) { // TODO: When to override with null or empty arrays? if (abMetadata[key] === undefined || abMetadata[key] === null) continue @@ -48,11 +49,11 @@ class AbsMetadataFileScanner { /** * Check for metadata.json file and set podcast metadata - * - * @param {import('./LibraryScan')} libraryScan - * @param {import('./LibraryItemScanData')} libraryItemData - * @param {Object} podcastMetadata - * @param {string} [existingLibraryItemId] + * + * @param {import('./LibraryScan')} libraryScan + * @param {import('./LibraryItemScanData')} libraryItemData + * @param {Object} podcastMetadata + * @param {string} [existingLibraryItemId] */ async scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId = null) { const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile @@ -71,7 +72,7 @@ class AbsMetadataFileScanner { if (metadataText) { libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`) - const abMetadata = abmetadataGenerator.parseJson(metadataText) || {} + const abMetadata = abmetadataGenerator.parseJson(metadataText, 'podcast') || {} for (const key in abMetadata) { if (abMetadata[key] === undefined || abMetadata[key] === null) continue if (key === 'tags' && !abMetadata.tags?.length) continue @@ -81,4 +82,4 @@ class AbsMetadataFileScanner { } } } -module.exports = new AbsMetadataFileScanner() \ No newline at end of file +module.exports = new AbsMetadataFileScanner() diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index a1e7ff507..8ae616fe8 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -7,6 +7,7 @@ const parseNameString = require('../utils/parsers/parseNameString') const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata') const globals = require('../utils/globals') const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils') +const htmlSanitizer = require('../utils/htmlSanitizer') const AudioFileScanner = require('./AudioFileScanner') const Database = require('../Database') @@ -688,6 +689,10 @@ class BookScanner { bookMetadata.titleIgnorePrefix = getTitleIgnorePrefix(bookMetadata.title) + if (typeof bookMetadata.description === 'string' && bookMetadata.description) { + bookMetadata.description = htmlSanitizer.sanitize(bookMetadata.description) + } + return bookMetadata } @@ -820,6 +825,9 @@ class BookScanner { const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) + /** + * Keys must match abmetadataGenerator.js + */ const jsonObject = { tags: libraryItem.media.tags || [], chapters: libraryItem.media.chapters?.map((c) => ({ ...c })) || [], diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js index c9569c3ad..f3241d536 100644 --- a/server/scanner/PodcastScanner.js +++ b/server/scanner/PodcastScanner.js @@ -11,6 +11,7 @@ const LibraryFile = require('../objects/files/LibraryFile') const fsExtra = require('../libs/fsExtra') const PodcastEpisode = require('../models/PodcastEpisode') const AbsMetadataFileScanner = require('./AbsMetadataFileScanner') +const htmlSanitizer = require('../utils/htmlSanitizer') /** * Metadata for podcasts pulled from files @@ -398,6 +399,10 @@ class PodcastScanner { podcastMetadata.titleIgnorePrefix = getTitleIgnorePrefix(podcastMetadata.title) + if (typeof podcastMetadata.description === 'string' && podcastMetadata.description) { + podcastMetadata.description = htmlSanitizer.sanitize(podcastMetadata.description) + } + return podcastMetadata } @@ -420,6 +425,9 @@ class PodcastScanner { const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) + /** + * Keys must match abmetadataGenerator.js + */ const jsonObject = { tags: libraryItem.media.tags || [], title: libraryItem.media.title, diff --git a/server/utils/constants.js b/server/utils/constants.js index cc5217f41..925035e17 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -48,6 +48,8 @@ module.exports.AudioMimeType = { AIF: 'audio/x-aiff', WEBM: 'audio/webm', WEBMA: 'audio/webm', + // TODO: Switch to `audio/matroska`? marked as deprecated in IANA registry + // ref: https://datatracker.ietf.org/doc/html/rfc9559 MKA: 'audio/x-matroska', AWB: 'audio/amr-wb', CAF: 'audio/x-caf', diff --git a/server/utils/ffmpegHelpers.js b/server/utils/ffmpegHelpers.js index 80832cc77..7ad2a3aee 100644 --- a/server/utils/ffmpegHelpers.js +++ b/server/utils/ffmpegHelpers.js @@ -1,4 +1,5 @@ const axios = require('axios') +const ssrfFilter = require('ssrf-req-filter') const Ffmpeg = require('../libs/fluentFfmpeg') const ffmpgegUtils = require('../libs/fluentFfmpeg/utils') const fs = require('../libs/fsExtra') @@ -97,6 +98,8 @@ async function resizeImage(filePath, outputPath, width, height) { module.exports.resizeImage = resizeImage /** + * Download podcast episode + * Uses SSRF filter to prevent internal URLs * * @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload * @returns {Promise<{success: boolean, isRequestError?: boolean}>} @@ -121,7 +124,9 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => { Accept: '*/*', 'User-Agent': userAgent }, - timeout: global.PodcastDownloadTimeout + timeout: global.PodcastDownloadTimeout, + httpAgent: global.DisableSsrfRequestFilter?.(podcastEpisodeDownload.url) ? null : ssrfFilter(podcastEpisodeDownload.url), + httpsAgent: global.DisableSsrfRequestFilter?.(podcastEpisodeDownload.url) ? null : ssrfFilter(podcastEpisodeDownload.url) }) Logger.debug(`[ffmpegHelpers] Successfully connected with User-Agent: ${userAgent}`) diff --git a/server/utils/generators/abmetadataGenerator.js b/server/utils/generators/abmetadataGenerator.js index 01f853288..d0d278752 100644 --- a/server/utils/generators/abmetadataGenerator.js +++ b/server/utils/generators/abmetadataGenerator.js @@ -1,7 +1,51 @@ const Logger = require('../../Logger') const parseSeriesString = require('../parsers/parseSeriesString') -function parseJsonMetadataText(text) { +const mediaTypeKeys = { + book: { + tags: 'stringArray', + title: 'string', + subtitle: 'string', + authors: 'stringArray', + narrators: 'stringArray', + series: 'stringArray', + genres: 'stringArray', + publishedYear: 'string', + publishedDate: 'string', + publisher: 'string', + description: 'string', + isbn: 'string', + asin: 'string', + language: 'string', + explicit: 'boolean', + abridged: 'boolean' + }, + podcast: { + tags: 'stringArray', + title: 'string', + author: 'string', + description: 'string', + releaseDate: 'string', + genres: 'stringArray', + feedURL: 'string', + imageURL: 'string', + itunesPageURL: 'string', + itunesId: 'string', + itunesArtistId: 'string', + asin: 'string', + language: 'string', + explicit: 'boolean', + podcastType: 'string' + } +} + +/** + * + * @param {string} text + * @param {"book" | "podcast"} mediaType + * @returns {Object} + */ +function parseJsonMetadataText(text, mediaType) { try { const abmetadataData = JSON.parse(text) @@ -19,28 +63,41 @@ function parseJsonMetadataText(text) { } delete abmetadataData.metadata - if (abmetadataData.series?.length) { - abmetadataData.series = [...new Set(abmetadataData.series.map((t) => t?.trim()).filter((t) => t))] - abmetadataData.series = abmetadataData.series.map((series) => parseSeriesString.parse(series)) + const expectedKeys = mediaTypeKeys[mediaType] + if (!expectedKeys) { + Logger.error(`[abmetadataGenerator] Invalid media type "${mediaType}"`) + return null } - // clean tags & remove dupes - if (abmetadataData.tags?.length) { - abmetadataData.tags = [...new Set(abmetadataData.tags.map((t) => t?.trim()).filter((t) => t))] + + const validated = {} + for (const key in expectedKeys) { + const expectedType = expectedKeys[key] + if (!(key in abmetadataData)) continue + + const validatedValue = validateMetadataValue(key, abmetadataData[key], expectedType) + if (validatedValue !== undefined) { + validated[key] = validatedValue + } } - if (abmetadataData.chapters?.length) { - abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.title) + + if (validated.series?.length) { + validated.series = validated.series.map((series) => parseSeriesString.parse(series)).filter(Boolean) } - // clean remove dupes - if (abmetadataData.authors?.length) { - abmetadataData.authors = [...new Set(abmetadataData.authors.map((t) => t?.trim()).filter((t) => t))] + + if (mediaType === 'book' && 'chapters' in abmetadataData) { + if (abmetadataData.chapters === null) { + validated.chapters = [] + } else if (Array.isArray(abmetadataData.chapters)) { + const cleanedChapters = cleanChaptersArray(abmetadataData.chapters, validated.title ?? abmetadataData.title) + if (cleanedChapters) { + validated.chapters = cleanedChapters + } + } else { + Logger.warn(`[abmetadataGenerator] Invalid metadata key "chapters" expected array, got ${typeof abmetadataData.chapters}`) + } } - if (abmetadataData.narrators?.length) { - abmetadataData.narrators = [...new Set(abmetadataData.narrators.map((t) => t?.trim()).filter((t) => t))] - } - if (abmetadataData.genres?.length) { - abmetadataData.genres = [...new Set(abmetadataData.genres.map((t) => t?.trim()).filter((t) => t))] - } - return abmetadataData + + return validated } catch (error) { Logger.error(`[abmetadataGenerator] Invalid metadata.json JSON`, error) return null @@ -48,6 +105,54 @@ function parseJsonMetadataText(text) { } module.exports.parseJson = parseJsonMetadataText +/** + * @param {string} key + * @param {*} value + * @param {string} expectedType + * @returns {*|undefined} undefined excludes the key + */ +function validateMetadataValue(key, value, expectedType) { + if (expectedType === 'string') { + if (value === null) return null + if (typeof value === 'number') return String(value) + if (typeof value === 'string') return value + Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected string, got ${typeof value}`) + return undefined + } + + if (expectedType === 'boolean') { + if (value === null) return null + if (typeof value === 'boolean') return value + if (typeof value === 'string') { + const lower = value.toLowerCase() + if (lower === 'true') return true + if (lower === 'false') return false + } + Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected boolean, got ${typeof value}`) + return undefined + } + + // Filter empty strings and deduplicate + if (expectedType === 'stringArray') { + if (value === null) return [] + if (!Array.isArray(value)) { + Logger.warn(`[abmetadataGenerator] Invalid metadata key "${key}" expected string array, got ${typeof value}`) + return undefined + } + + const cleanedArray = value.filter((t) => typeof t === 'string') + return [...new Set(cleanedArray.map((t) => t.trim()).filter((t) => t))] + } + + Logger.warn(`[abmetadataGenerator] Unknown expected type "${expectedType}" for key "${key}"`) + return undefined +} + +/** + * @param {Object[]} chaptersArray + * @param {string} mediaTitle + * @returns {Object[]} + */ function cleanChaptersArray(chaptersArray, mediaTitle) { const chapters = [] let index = 0 diff --git a/server/utils/index.js b/server/utils/index.js index c7700a783..49a7c8e67 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -54,6 +54,16 @@ module.exports.isNullOrNaN = (num) => { return num === null || isNaN(num) } +/** + * @param {number|null|undefined} value + * @param {number} max + * @returns {number|null} + */ +module.exports.clampPositiveInt = (value, max) => { + if (value == null || !Number.isFinite(value) || value <= 0) return null + return Math.min(Math.floor(value), max) +} + const xmlToJSON = (xml) => { return new Promise((resolve, reject) => { parseString(xml, (err, results) => { diff --git a/server/utils/notifications.js b/server/utils/notifications.js index 700d3c389..497d7a1a1 100644 --- a/server/utils/notifications.js +++ b/server/utils/notifications.js @@ -100,7 +100,7 @@ module.exports.notificationData = { variables: ['version'], defaults: { title: 'Test Notification on Abs {{version}}', - body: 'Test notificataion body for abs {{version}}.' + body: 'Test notification body for abs {{version}}.' }, testData: { version: 'v' + version diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 2042a8e39..1cb0c4cb4 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -217,6 +217,10 @@ function extractEpisodeData(item) { episode[cleanKey] = extractFirstArrayItemString(item, key) }) + if (episode.subtitle) { + episode.subtitle = htmlSanitizer.sanitize(episode.subtitle.trim()) + } + // Extract psc:chapters if duration is set episode.durationSeconds = episode.duration ? timestampToSeconds(episode.duration) : null diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js index 5a0422392..1d57219d3 100644 --- a/test/server/controllers/LibraryItemController.test.js +++ b/test/server/controllers/LibraryItemController.test.js @@ -123,7 +123,9 @@ describe('LibraryItemController', () => { const fakeReq = { query: {}, user: { - canDelete: true + username: 'test', + canDelete: true, + checkCanAccessLibraryItem: () => true }, body: { libraryItemIds: [libraryItem1Id] @@ -199,4 +201,102 @@ describe('LibraryItemController', () => { expect(series2Exists).to.be.true }) }) + + describe('batch item access control', () => { + let lib1Id + let itemLib1Id + let itemLib2Id + + beforeEach(async () => { + const lib1 = await Database.libraryModel.create({ name: 'Lib 1', mediaType: 'book' }) + const folder1 = await Database.libraryFolderModel.create({ path: '/l1', libraryId: lib1.id }) + const book1 = await Database.bookModel.create({ title: 'B1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + const li1 = await Database.libraryItemModel.create({ + libraryFiles: [], + mediaId: book1.id, + mediaType: 'book', + libraryId: lib1.id, + libraryFolderId: folder1.id + }) + lib1Id = lib1.id + itemLib1Id = li1.id + + const lib2 = await Database.libraryModel.create({ name: 'Lib 2', mediaType: 'book' }) + const folder2 = await Database.libraryFolderModel.create({ path: '/l2', libraryId: lib2.id }) + const book2 = await Database.bookModel.create({ title: 'B2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] }) + const li2 = await Database.libraryItemModel.create({ + libraryFiles: [], + mediaId: book2.id, + mediaType: 'book', + libraryId: lib2.id, + libraryFolderId: folder2.id + }) + itemLib2Id = li2.id + }) + + const userLimitedToLib1 = () => ({ + username: 'limited', + canDelete: true, + canUpdate: true, + checkCanAccessLibraryItem(li) { + return li.libraryId === lib1Id + } + }) + + it('batchGet returns 403 for a library item the user cannot access', async () => { + const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() } + const fakeReq = { + body: { libraryItemIds: [itemLib2Id] }, + user: userLimitedToLib1() + } + await LibraryItemController.batchGet.bind(apiRouter)(fakeReq, fakeRes) + expect(fakeRes.sendStatus.calledWith(403)).to.be.true + }) + + it('batchGet returns items when the user can access them', async () => { + const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() } + const fakeReq = { + body: { libraryItemIds: [itemLib1Id] }, + user: userLimitedToLib1() + } + await LibraryItemController.batchGet.bind(apiRouter)(fakeReq, fakeRes) + expect(fakeRes.json.calledOnce).to.be.true + const payload = fakeRes.json.firstCall.args[0] + expect(payload.libraryItems).to.have.length(1) + expect(payload.libraryItems[0].id).to.equal(itemLib1Id) + }) + + it('batchUpdate returns 403 for a library item the user cannot access', async () => { + const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() } + const fakeReq = { + user: userLimitedToLib1(), + body: [{ id: itemLib2Id, mediaPayload: {} }] + } + await LibraryItemController.batchUpdate.bind(apiRouter)(fakeReq, fakeRes) + expect(fakeRes.sendStatus.calledWith(403)).to.be.true + }) + + it('batchUpdate returns 403 when the user lacks canUpdate', async () => { + const u = userLimitedToLib1() + u.canUpdate = false + const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() } + const fakeReq = { + user: u, + body: [{ id: itemLib1Id, mediaPayload: {} }] + } + await LibraryItemController.batchUpdate.bind(apiRouter)(fakeReq, fakeRes) + expect(fakeRes.sendStatus.calledWith(403)).to.be.true + }) + + it('batchDelete returns 403 for a library item the user cannot access', async () => { + const fakeRes = { sendStatus: sinon.spy() } + const fakeReq = { + query: {}, + user: userLimitedToLib1(), + body: { libraryItemIds: [itemLib2Id] } + } + await LibraryItemController.batchDelete.bind(apiRouter)(fakeReq, fakeRes) + expect(fakeRes.sendStatus.calledWith(403)).to.be.true + }) + }) }) diff --git a/test/server/managers/ApiCacheManager.test.js b/test/server/managers/ApiCacheManager.test.js index 19bbeecf6..2e9ad5b77 100644 --- a/test/server/managers/ApiCacheManager.test.js +++ b/test/server/managers/ApiCacheManager.test.js @@ -1,6 +1,7 @@ // Import dependencies and modules for testing const { expect } = require('chai') const sinon = require('sinon') +const { LRUCache } = require('lru-cache') const ApiCacheManager = require('../../../server/managers/ApiCacheManager') describe('ApiCacheManager', () => { @@ -94,4 +95,17 @@ describe('ApiCacheManager', () => { expect(res.originalSend.calledWith(body)).to.be.true }) }) + + describe('clear on mediaProgress', () => { + it('should remove recent-episodes cache entries', () => { + const key = JSON.stringify({ user: 'u', url: '/libraries/abc-123/recent-episodes?limit=50&page=0' }) + const cache = new LRUCache({ max: 10 }) + cache.set(key, { body: '[]', headers: {}, statusCode: 200 }) + const manager = new ApiCacheManager(cache) + + manager.clear({ name: 'mediaProgress' }, 'afterUpdate') + + expect(cache.get(key)).to.be.undefined + }) + }) })