Compare commits

...

52 commits

Author SHA1 Message Date
advplyr
c009db9f28
Merge pull request #5256 from nichwall/fix-bookauthor-collision-on-rename
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Build and Push Docker Image / build (push) Waiting to run
Integration Test / build and test (push) Waiting to run
Run Unit Tests / Run Unit Tests (push) Waiting to run
Fix duplicate bookAuthor creation when renaming authors
2026-05-22 15:43:13 -05:00
advplyr
325469c5a5
Merge pull request #5255 from nichwall/refresh-token-uniqueness
Add unique UUID to access and refresh tokens
2026-05-22 15:39:01 -05:00
Nicholas Wallace
c97b36e11c Add ignoreDuplicates for bookAuthor when renaming to respect unique index 2026-05-21 21:06:17 -07:00
Nicholas Wallace
e944b2a2f5 Add unique UUID to access and refresh tokens 2026-05-21 17:08:39 -07:00
advplyr
2d0a5462d2 Merge branch 'master' of https://github.com/advplyr/audiobookshelf
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Run Component Tests / Run Component Tests (push) Has been cancelled
Build and Push Docker Image / build (push) Has been cancelled
Verify all i18n files are alphabetized / update_translations (push) Has been cancelled
Integration Test / build and test (push) Has been cancelled
Run Unit Tests / Run Unit Tests (push) Has been cancelled
2026-05-17 14:31:45 -05:00
advplyr
72dc75482f Version bump v2.35.0 2026-05-17 14:31:41 -05:00
advplyr
cac74f3477
Merge pull request #5004 from nichwall/token_refresh_race_condition
Access token refresh grace period
2026-05-17 14:16:58 -05:00
advplyr
1ad11b2b9e
Merge pull request #5216 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2026-05-17 14:16:24 -05:00
Pavel Miniutka
50eeca2e0f
Translated using Weblate (Belarusian)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/be/
2026-05-15 18:13:27 +00:00
EteranlK
4f21fc023c
Translated using Weblate (Arabic)
Currently translated at 96.3% (1120 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ar/
2026-05-15 18:13:26 +00:00
advplyr
52a485d135
Added translation using Weblate (Latvian) 2026-05-15 18:13:25 +00:00
d0nizam
3b025076e8
Translated using Weblate (Bulgarian)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/bg/
2026-05-15 18:13:23 +00:00
Mateusz Lesiak
6d5d89429d
Translated using Weblate (Polish)
Currently translated at 99.8% (1161 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/pl/
2026-05-15 18:13:22 +00:00
advplyr
c010f0e1eb Fix android device sdkVersion not handling it using number type, causing android session device names to show as iOS
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Build and Push Docker Image / build (push) Has been cancelled
Integration Test / build and test (push) Has been cancelled
Run Unit Tests / Run Unit Tests (push) Has been cancelled
2026-05-15 13:13:14 -05:00
advplyr
eee377e081 Cleanup TokenManager logs
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Build and Push Docker Image / build (push) Has been cancelled
Integration Test / build and test (push) Has been cancelled
Run Unit Tests / Run Unit Tests (push) Has been cancelled
2026-05-13 16:23:26 -05:00
advplyr
b0aaa24660 Update socket events to check client is admin & validate log level
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Build and Push Docker Image / build (push) Waiting to run
Integration Test / build and test (push) Waiting to run
Run Unit Tests / Run Unit Tests (push) Waiting to run
2026-05-12 16:57:28 -05:00
advplyr
47ea6b5092 Update book/podcast scanner to sanitize description pulled from metadata
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Build and Push Docker Image / build (push) Has been cancelled
Integration Test / build and test (push) Has been cancelled
Run Unit Tests / Run Unit Tests (push) Has been cancelled
2026-05-05 17:18:49 -05:00
advplyr
4b060febc2
Merge pull request #5221 from brandonfhall/fix/rss-feed-m4b-content-type
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Build and Push Docker Image / build (push) Has been cancelled
Integration Test / build and test (push) Has been cancelled
Run Unit Tests / Run Unit Tests (push) Has been cancelled
Fix: RSS feed serves m4b files with correct Content-Type: audio/mp4
2026-05-03 14:40:43 -05:00
Brandon
40869bcf39 fix: set correct Content-Type for RSS feed audio files
Express's mime package does not recognize .m4b, causing it to fall back
to application/octet-stream. This reuses the existing
getAudioMimeTypeFromExtname utility (already applied to the download
endpoint) to set the correct audio/mp4 header before sendFile.

Fixes #5041
2026-05-02 22:13:35 -04:00
advplyr
3942805129 Cleanup rotateTokensForSession 2026-04-30 16:25:43 -05:00
advplyr
dc446862c1 Rename migration to v2.35.0 & merge master 2026-04-30 16:08:24 -05:00
advplyr
379f6c716a Merge branch 'master' into token_refresh_race_condition 2026-04-30 15:59:22 -05:00
advplyr
47457ee1e7 Version bump v2.34.0
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Run Component Tests / Run Component Tests (push) Has been cancelled
Build and Push Docker Image / build (push) Has been cancelled
Verify all i18n files are alphabetized / update_translations (push) Has been cancelled
Integration Test / build and test (push) Has been cancelled
Run Unit Tests / Run Unit Tests (push) Has been cancelled
2026-04-27 16:51:34 -05:00
advplyr
cb6ff9eedf
Merge pull request #5204 from weblate/weblate-audiobookshelf-abs-web-client
Translations update from Hosted Weblate
2026-04-27 16:45:57 -05:00
LvanAlphen
5dc01261c1
Translated using Weblate (Dutch)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/nl/
2026-04-26 21:51:49 +00:00
Naoto Ishikawa
cbc103cf05
Translated using Weblate (Japanese)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/ja/
2026-04-26 21:51:49 +00:00
Pavel Miniutka
e79256d0fb
Translated using Weblate (Belarusian)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/be/
2026-04-26 21:51:48 +00:00
ugyes
f8ef56c6bc
Translated using Weblate (Hungarian)
Currently translated at 100.0% (1163 of 1163 strings)

Translation: Audiobookshelf/Abs Web Client
Translate-URL: https://hosted.weblate.org/projects/audiobookshelf/abs-web-client/hu/
2026-04-26 21:51:48 +00:00
advplyr
62d7097e23 Add ApiCacheManager test for should remove recent-episodes cache entries
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Run Component Tests / Run Component Tests (push) Waiting to run
Build and Push Docker Image / build (push) Waiting to run
Integration Test / build and test (push) Waiting to run
Run Unit Tests / Run Unit Tests (push) Waiting to run
2026-04-26 16:51:39 -05:00
advplyr
92df92ec99 Fix recent episodes endpoint cache not being cleared when updating media progress #5159 2026-04-26 16:51:08 -05:00
advplyr
1c229e0627
Merge pull request #5211 from na3shkw/add-i18n-japanese
Add Japanese (ja) language and Japan podcast search region
2026-04-26 16:19:37 -05:00
advplyr
f8a71cc514
Merge pull request #5089 from meek2100/pass_managers
feat: add autocomplete attributes for password manager support
2026-04-26 16:16:42 -05:00
Naoto Ishikawa
63de5bb2d5
Merge branch 'advplyr:master' into add-i18n-japanese 2026-04-26 15:23:22 +09:00
advplyr
2c3108a1fa
Merge pull request #5163 from pjkottke/master
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Run Component Tests / Run Component Tests (push) Waiting to run
Build and Push Docker Image / build (push) Waiting to run
Integration Test / build and test (push) Waiting to run
Run Unit Tests / Run Unit Tests (push) Waiting to run
The timestamp in the share URL should override the saved position for the user.
2026-04-25 17:15:23 -05:00
advplyr
928051744a ShareController check ?t param is less than duration, revert frontend mounted usage of param 2026-04-25 17:13:22 -05:00
advplyr
3ccdcaec1a Implement SSRF filter for podcast episode downloads 2026-04-25 16:46:54 -05:00
na3shkw
f47bbc7886 Add Japanese language and Japan podcast search region 2026-04-25 15:56:16 +00:00
advplyr
7c0ca44727 Update podcast create/update endpoints to validate autoDownloadSchedule cron expression, validate cron expression before starting in CronManager
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Run Component Tests / Run Component Tests (push) Waiting to run
Build and Push Docker Image / build (push) Waiting to run
Integration Test / build and test (push) Waiting to run
Run Unit Tests / Run Unit Tests (push) Waiting to run
2026-04-24 16:55:42 -05:00
advplyr
d6a2e5596b Fix undefined variable in error log for when podcast cron is invalid 2026-04-24 16:18:56 -05:00
advplyr
a5362de9cc Update podcast createFromRequest to sanitize html description
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Build and Push Docker Image / build (push) Waiting to run
Integration Test / build and test (push) Waiting to run
Run Unit Tests / Run Unit Tests (push) Waiting to run
2026-04-23 14:34:59 -05:00
advplyr
9ab35ef418 Update playlist endpoints to check user still has library access
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Build and Push Docker Image / build (push) Waiting to run
Integration Test / build and test (push) Waiting to run
Run Unit Tests / Run Unit Tests (push) Waiting to run
2026-04-22 16:42:58 -05:00
advplyr
79cc9765cf Update collection endpoints to check user library access 2026-04-22 16:29:47 -05:00
advplyr
5b2a788cfc Update LibraryItemController test with 403 tests
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Build and Push Docker Image / build (push) Waiting to run
Integration Test / build and test (push) Waiting to run
Run Unit Tests / Run Unit Tests (push) Waiting to run
2026-04-21 17:13:52 -05:00
advplyr
80b39abaa2 Update library item batch api endpoints check users per-item access & return 403 2026-04-21 17:13:06 -05:00
peter.kottke
5a6b3d8e61 updates to allow share t argument to over-ride server stored position 2026-04-01 21:05:48 -04:00
meek2100
a9e12657f5 Add autocomplete attributes to login and setup fields for password manager support 2026-02-26 14:29:28 -08:00
Nicholas Wallace
cfeb6bd502 Fix: grace period enable statement 2026-01-24 18:57:40 -07:00
Nicholas Wallace
077b523bd6 Fix JS Doc deletion 2026-01-24 18:42:50 -07:00
Nicholas Wallace
b8a2d113f0 Allow rotation without grace period for invalidating all user sessions 2026-01-24 18:26:11 -07:00
Nicholas Wallace
e1ae4f2d31 Fix: race condition in rotation 2026-01-24 18:10:38 -07:00
Nicholas Wallace
7aa2f84daa Revert default token expiry 2026-01-24 17:00:07 -07:00
Nicholas Wallace
da0a64daed Add: 10 second grace period to access token cycle 2026-01-24 16:57:25 -07:00
39 changed files with 1594 additions and 116 deletions

View file

@ -158,6 +158,8 @@ export default {
this.isProcessing = true this.isProcessing = true
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => { var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => {
console.error('Failed to update', 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 return false
}) })
this.isProcessing = false this.isProcessing = false

View file

@ -1,6 +1,6 @@
<template> <template>
<div ref="wrapper" class="relative"> <div ref="wrapper" class="relative">
<input :id="inputId" :name="inputName" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" dir="auto" class="rounded-sm bg-primary text-gray-200 focus:bg-bg focus:outline-hidden border h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" /> <input :id="inputId" :name="inputName" ref="input" v-model="inputValue" :type="actualType" :step="step" :min="min" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" :autocomplete="autocomplete" dir="auto" class="rounded-sm bg-primary text-gray-200 focus:bg-bg focus:outline-hidden border h-full w-full" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center"> <div v-if="clearable && inputValue" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
<span class="material-symbols text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span> <span class="material-symbols text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
</div> </div>
@ -41,7 +41,8 @@ export default {
step: [String, Number], step: [String, Number],
min: [String, Number], min: [String, Number],
customInputClass: String, customInputClass: String,
trimWhitespace: Boolean trimWhitespace: Boolean,
autocomplete: String
}, },
data() { data() {
return { return {

View file

@ -6,7 +6,7 @@
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em> <em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</label> </label>
</slot> </slot>
<ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :min="min" :show-copy="showCopy" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" /> <ui-text-input :placeholder="placeholder || label" :inputId="identifier" ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" :min="min" :show-copy="showCopy" :autocomplete="autocomplete" class="w-full" :class="inputClass" :trim-whitespace="trimWhitespace" @blur="inputBlurred" />
</div> </div>
</template> </template>
@ -26,7 +26,8 @@ export default {
disabled: Boolean, disabled: Boolean,
inputClass: String, inputClass: String,
showCopy: Boolean, showCopy: Boolean,
trimWhitespace: Boolean trimWhitespace: Boolean,
autocomplete: String
}, },
data() { data() {
return {} return {}

View file

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

View file

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

View file

@ -17,9 +17,9 @@
<form @submit.prevent="submitServerSetup"> <form @submit.prevent="submitServerSetup">
<p class="text-lg font-semibold mb-2 pl-1 text-center">Create Root User</p> <p class="text-lg font-semibold mb-2 pl-1 text-center">Create Root User</p>
<ui-text-input-with-label v-model.trim="newRoot.username" label="Username" :disabled="processing" class="w-full mb-3 text-sm" /> <ui-text-input-with-label v-model.trim="newRoot.username" label="Username" autocomplete="username" :disabled="processing" class="w-full mb-3 text-sm" />
<ui-text-input-with-label v-model="newRoot.password" label="Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" /> <ui-text-input-with-label v-model="newRoot.password" label="Password" type="password" autocomplete="new-password" :disabled="processing" class="w-full mb-3 text-sm" />
<ui-text-input-with-label v-model="confirmPassword" label="Confirm Password" type="password" :disabled="processing" class="w-full mb-3 text-sm" /> <ui-text-input-with-label v-model="confirmPassword" label="Confirm Password" type="password" autocomplete="new-password" :disabled="processing" class="w-full mb-3 text-sm" />
<p class="text-lg font-semibold mt-6 mb-2 pl-1 text-center">Directory Paths</p> <p class="text-lg font-semibold mt-6 mb-2 pl-1 text-center">Directory Paths</p>
<ui-text-input-with-label v-model="ConfigPath" label="Config Path" disabled class="w-full mb-3 text-sm" /> <ui-text-input-with-label v-model="ConfigPath" label="Config Path" disabled class="w-full mb-3 text-sm" />
@ -51,10 +51,10 @@
<form v-show="login_local" @submit.prevent="submitForm"> <form v-show="login_local" @submit.prevent="submitForm">
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label> <label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
<ui-text-input v-model.trim="username" :disabled="processing" class="mb-3 w-full" inputName="username" /> <ui-text-input v-model.trim="username" autocomplete="username" :disabled="processing" class="mb-3 w-full" inputName="username" />
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelPassword }}</label> <label class="text-xs text-gray-300 uppercase">{{ $strings.LabelPassword }}</label>
<ui-text-input v-model.trim="password" type="password" :disabled="processing" class="w-full mb-3" inputName="password" /> <ui-text-input v-model.trim="password" type="password" autocomplete="current-password" :disabled="processing" class="w-full mb-3" inputName="password" />
<div class="w-full flex justify-end py-3"> <div class="w-full flex justify-end py-3">
<ui-btn type="submit" :disabled="processing" color="bg-primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn> <ui-btn type="submit" :disabled="processing" color="bg-primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn>
</div> </div>

View file

@ -364,6 +364,7 @@ export default {
} }
const startTime = this.playbackSession.currentTime || 0 const startTime = this.playbackSession.currentTime || 0
this.localAudioPlayer.set(null, this.audioTracks, false, startTime, false) this.localAudioPlayer.set(null, this.audioTracks, false, startTime, false)
this.localAudioPlayer.on('stateChange', this.playerStateChange.bind(this)) this.localAudioPlayer.on('stateChange', this.playerStateChange.bind(this))
this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this)) this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this))

View file

@ -21,6 +21,7 @@ const languageCodeMap = {
he: { label: 'עברית', dateFnsLocale: 'he' }, he: { label: 'עברית', dateFnsLocale: 'he' },
hr: { label: 'Hrvatski', dateFnsLocale: 'hr' }, hr: { label: 'Hrvatski', dateFnsLocale: 'hr' },
it: { label: 'Italiano', dateFnsLocale: 'it' }, it: { label: 'Italiano', dateFnsLocale: 'it' },
ja: { label: '日本語', dateFnsLocale: 'ja' },
lt: { label: 'Lietuvių', dateFnsLocale: 'lt' }, lt: { label: 'Lietuvių', dateFnsLocale: 'lt' },
hu: { label: 'Magyar', dateFnsLocale: 'hu' }, hu: { label: 'Magyar', dateFnsLocale: 'hu' },
ko: { label: '한국어', dateFnsLocale: 'ko' }, ko: { label: '한국어', dateFnsLocale: 'ko' },
@ -60,6 +61,7 @@ const podcastSearchRegionMap = {
hr: { label: 'Hrvatska' }, hr: { label: 'Hrvatska' },
il: { label: 'ישראל / إسرائيل' }, il: { label: 'ישראל / إسرائيل' },
it: { label: 'Italia' }, it: { label: 'Italia' },
jp: { label: '日本' },
lu: { label: 'Luxembourg / Luxemburg / Lëtezebuerg' }, lu: { label: 'Luxembourg / Luxemburg / Lëtezebuerg' },
hu: { label: 'Magyarország' }, hu: { label: 'Magyarország' },
nl: { label: 'Nederland' }, nl: { label: 'Nederland' },

View file

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

View file

@ -1,6 +1,6 @@
{ {
"ButtonAdd": "Дадаць", "ButtonAdd": "Дадаць",
"ButtonAddApiKey": "Дадаць API-ключ", "ButtonAddApiKey": "Дадаць ключ API",
"ButtonAddChapters": "Дадаць раздзелы", "ButtonAddChapters": "Дадаць раздзелы",
"ButtonAddDevice": "Дадаць прыладу", "ButtonAddDevice": "Дадаць прыладу",
"ButtonAddLibrary": "Дадаць бібліятэку", "ButtonAddLibrary": "Дадаць бібліятэку",
@ -88,7 +88,7 @@
"ButtonResetToDefault": "Скінуць да прадвызначаных", "ButtonResetToDefault": "Скінуць да прадвызначаных",
"ButtonRestore": "Аднавіць", "ButtonRestore": "Аднавіць",
"ButtonSave": "Захаваць", "ButtonSave": "Захаваць",
"ButtonSaveAndClose": "Захаваць і зачыніць", "ButtonSaveAndClose": "Захаваць і закрыць",
"ButtonSaveTracklist": "Захаваць спіс трэкаў", "ButtonSaveTracklist": "Захаваць спіс трэкаў",
"ButtonScan": "Сканаваць", "ButtonScan": "Сканаваць",
"ButtonScanLibrary": "Сканіраваць бібліятэку", "ButtonScanLibrary": "Сканіраваць бібліятэку",
@ -121,7 +121,7 @@
"HeaderAccount": "Уліковы запіс", "HeaderAccount": "Уліковы запіс",
"HeaderAddCustomMetadataProvider": "Дадаванне карыстальніцкага пастаўшчыка метаданых", "HeaderAddCustomMetadataProvider": "Дадаванне карыстальніцкага пастаўшчыка метаданых",
"HeaderAdvanced": "Дадаткова", "HeaderAdvanced": "Дадаткова",
"HeaderApiKeys": "API-ключы", "HeaderApiKeys": "Ключы API",
"HeaderAppriseNotificationSettings": "Налады апавяшчэнняў Apprise", "HeaderAppriseNotificationSettings": "Налады апавяшчэнняў Apprise",
"HeaderAudioTracks": "Аўдыятрэкі", "HeaderAudioTracks": "Аўдыятрэкі",
"HeaderAudiobookTools": "Сродкі кіравання файламі аўдыякніг", "HeaderAudiobookTools": "Сродкі кіравання файламі аўдыякніг",
@ -166,7 +166,7 @@
"HeaderMetadataOrderOfPrecedence": "Парадак прыярытэту метаданых", "HeaderMetadataOrderOfPrecedence": "Парадак прыярытэту метаданых",
"HeaderMetadataToEmbed": "Метаданыя для ўбудавання", "HeaderMetadataToEmbed": "Метаданыя для ўбудавання",
"HeaderNewAccount": "Новы ўліковы запіс", "HeaderNewAccount": "Новы ўліковы запіс",
"HeaderNewApiKey": "Новы API-ключ", "HeaderNewApiKey": "Новы ключ API",
"HeaderNewLibrary": "Новая бібліятэка", "HeaderNewLibrary": "Новая бібліятэка",
"HeaderNotificationCreate": "Стварыць апавяшчэнне", "HeaderNotificationCreate": "Стварыць апавяшчэнне",
"HeaderNotificationUpdate": "Абнавіць апавяшчэнне", "HeaderNotificationUpdate": "Абнавіць апавяшчэнне",
@ -212,7 +212,7 @@
"HeaderTableOfContents": "Змест", "HeaderTableOfContents": "Змест",
"HeaderTools": "Інструменты", "HeaderTools": "Інструменты",
"HeaderUpdateAccount": "Абнавіць уліковы запіс", "HeaderUpdateAccount": "Абнавіць уліковы запіс",
"HeaderUpdateApiKey": "Абнавіць API-ключ", "HeaderUpdateApiKey": "Абнавіць ключ API",
"HeaderUpdateAuthor": "Абнавіць аўтара", "HeaderUpdateAuthor": "Абнавіць аўтара",
"HeaderUpdateDetails": "Абнавіць падрабязнасці", "HeaderUpdateDetails": "Абнавіць падрабязнасці",
"HeaderUpdateLibrary": "Абнавіць бібліятэку", "HeaderUpdateLibrary": "Абнавіць бібліятэку",
@ -242,10 +242,10 @@
"LabelAllUsersExcludingGuests": "Усіх карыстальнікаў, акрамя гасцей", "LabelAllUsersExcludingGuests": "Усіх карыстальнікаў, акрамя гасцей",
"LabelAllUsersIncludingGuests": "Усіх карыстальнікаў, уключаючы гасцей", "LabelAllUsersIncludingGuests": "Усіх карыстальнікаў, уключаючы гасцей",
"LabelAlreadyInYourLibrary": "Ужо ў вашай бібліятэцы", "LabelAlreadyInYourLibrary": "Ужо ў вашай бібліятэцы",
"LabelApiKeyCreated": "API-ключ \"{0}\" паспяхова створаны.", "LabelApiKeyCreated": "Ключ API \"{0}\" паспяхова створаны.",
"LabelApiKeyCreatedDescription": "Пераканайцеся, што вы скапіявалі API-ключ зараз, бо паўторна яго ўбачыць не атрымаецца.", "LabelApiKeyCreatedDescription": "Абавязкова скапіюйце ключ API зараз, бо паўторна яго ўбачыць не атрымаецца.",
"LabelApiKeyUser": "Дзейнічаць ад імя карыстальніка", "LabelApiKeyUser": "Дзейнічаць ад імя карыстальніка",
"LabelApiKeyUserDescription": "Гэты API-ключ будзе мець тыя ж правы, што і карыстальнік, ад імя якога ён дзейнічае. У журналах гэта будзе выглядаць так, быццам запыт робіць сам карыстальнік.", "LabelApiKeyUserDescription": "Гэты ключ API будзе мець тыя ж правы, што і карыстальнік, ад імя якога ён дзейнічае. У журналах гэта будзе выглядаць так, быццам запыт робіць сам карыстальнік.",
"LabelApiToken": "Токен API", "LabelApiToken": "Токен API",
"LabelAppend": "Дадаць", "LabelAppend": "Дадаць",
"LabelAudioBitrate": "Бітрэйт аўдыя (напрыклад, 128к)", "LabelAudioBitrate": "Бітрэйт аўдыя (напрыклад, 128к)",
@ -284,7 +284,7 @@
"LabelChaptersFound": "раздзелаў знойдзена", "LabelChaptersFound": "раздзелаў знойдзена",
"LabelClickForMoreInfo": "Націсніце для больш падрабязнай інфармацыі", "LabelClickForMoreInfo": "Націсніце для больш падрабязнай інфармацыі",
"LabelClickToUseCurrentValue": "Націсніце, каб выкарыстоўваць бягучае значэнне", "LabelClickToUseCurrentValue": "Націсніце, каб выкарыстоўваць бягучае значэнне",
"LabelClosePlayer": "Зачыніць прайгравальнік", "LabelClosePlayer": "Закрыць прайгравальнік",
"LabelCodec": "Кодэк", "LabelCodec": "Кодэк",
"LabelCollapseSeries": "Згарнуць серыі", "LabelCollapseSeries": "Згарнуць серыі",
"LabelCollapseSubSeries": "Згарнуць падсерыі", "LabelCollapseSubSeries": "Згарнуць падсерыі",
@ -884,7 +884,7 @@
"MessageRemoveEpisodes": "Выдаліць выпускі ({0})", "MessageRemoveEpisodes": "Выдаліць выпускі ({0})",
"MessageRemoveFromPlayerQueue": "Выдаліць з чаргі прагравання", "MessageRemoveFromPlayerQueue": "Выдаліць з чаргі прагравання",
"MessageRemoveUserWarning": "Вы ўпэўнены, што хочаце назаўжды выдаліць карыстальніка \"{0}\"?", "MessageRemoveUserWarning": "Вы ўпэўнены, што хочаце назаўжды выдаліць карыстальніка \"{0}\"?",
"MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце новыя функцыі і ўдзельнічайце на", "MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце функцыі і ўносьце свой уклад на",
"MessageResetChaptersConfirm": "Вы ўпэўнены, што хочаце скінуць раздзелы і адрабіць зробленыя вамі змены?", "MessageResetChaptersConfirm": "Вы ўпэўнены, што хочаце скінуць раздзелы і адрабіць зробленыя вамі змены?",
"MessageRestoreBackupConfirm": "Вы ўпэўнены, што хочаце аднавіць рэзервовую копію, створаную", "MessageRestoreBackupConfirm": "Вы ўпэўнены, што хочаце аднавіць рэзервовую копію, створаную",
"MessageRestoreBackupWarning": "Аднаўленне рэзервовай копіі перазапіша ўсю базу даных, размешчаную ў /config, а таксама відарысы вокладкі ў /metadata/items і /metadata/authors. <br /><br /> Рэзервовыя копіі не змяняюць файлы ў папках бібліятэкі. Калі вы ўключылі налады сервера для захоўвання воклак і метаданых у папках бібліятэкі, гэтыя файлы не будуць захаваныя ў рэзервовых копіях і не зменяцца. <br /><br /> Усе кліенты, якія карыстаюцца вашым серверам, будуць аўтаматычна абноўлены.", "MessageRestoreBackupWarning": "Аднаўленне рэзервовай копіі перазапіша ўсю базу даных, размешчаную ў /config, а таксама відарысы вокладкі ў /metadata/items і /metadata/authors. <br /><br /> Рэзервовыя копіі не змяняюць файлы ў папках бібліятэкі. Калі вы ўключылі налады сервера для захоўвання воклак і метаданых у папках бібліятэкі, гэтыя файлы не будуць захаваныя ў рэзервовых копіях і не зменяцца. <br /><br /> Усе кліенты, якія карыстаюцца вашым серверам, будуць аўтаматычна абноўлены.",

View file

@ -752,7 +752,7 @@
"MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове", "MessageBookshelfNoRSSFeeds": "Няма отворени RSS feed-ове",
"MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"", "MessageBookshelfNoResultsForFilter": "Няма резултат за филтер \"{0}: {1}\"",
"MessageBookshelfNoResultsForQuery": "Няма резултати от заявката", "MessageBookshelfNoResultsForQuery": "Няма резултати от заявката",
"MessageBookshelfNoSeries": "Нямаш сеЗЙ", "MessageBookshelfNoSeries": "Нямате поредица",
"MessageBulkChapterPattern": "Колко глави искате да добавите, използвайки тази схема за номериране?", "MessageBulkChapterPattern": "Колко глави искате да добавите, използвайки тази схема за номериране?",
"MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига", "MessageChapterEndIsAfter": "Краят на главата е след края на вашата аудиокнига",
"MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0", "MessageChapterErrorFirstNotZero": "Първата глава трябва да започва от 0",
@ -1018,18 +1018,50 @@
"ToastChapterStartTimeAdjusted": "Начално време на главате е настоено с {0} секунди", "ToastChapterStartTimeAdjusted": "Начално време на главате е настоено с {0} секунди",
"ToastChaptersAllLocked": "Всички глави са заключени. Оключете някой глави за да преместите техните времена.", "ToastChaptersAllLocked": "Всички глави са заключени. Оключете някой глави за да преместите техните времена.",
"ToastChaptersHaveErrors": "Главите имат грешки", "ToastChaptersHaveErrors": "Главите имат грешки",
"ToastChaptersInvalidShiftAmountLast": "Невалидно време за преместване. Началният час на последната глава ще превиши общата продължителност на аудиокнигата.",
"ToastChaptersInvalidShiftAmountStart": "Невалидно време за преместване. Първата глава ще има нулева или отрицателна дължина и ще бъде презаписана от втората глава. Увеличете началното време на втората глава.",
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия", "ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
"ToastChaptersRemoved": "Главите са премахнати",
"ToastChaptersUpdated": "Главите са актуализирани",
"ToastCollectionItemsAddFailed": "Неуспешно добавяне на елемент(и) към колекцията",
"ToastCollectionRemoveSuccess": "Колекцията е премахната", "ToastCollectionRemoveSuccess": "Колекцията е премахната",
"ToastCollectionUpdateSuccess": "Колекцията е обновена", "ToastCollectionUpdateSuccess": "Колекцията е обновена",
"ToastConnectionNotAvailable": "Няма връзка. Моля, опитайте отново по-късно",
"ToastCoverSearchFailed": "Търсенето на корица е неуспешно",
"ToastCoverUpdateFailed": "Обновяването на корицата е неуспешно",
"ToastDateTimeInvalidOrIncomplete": "Датата и часът са невалидни или непълни",
"ToastDeleteFileFailed": "Неуспешно изтриване на файла", "ToastDeleteFileFailed": "Неуспешно изтриване на файла",
"ToastDeleteFileSuccess": "Успешно изтриване на файла", "ToastDeleteFileSuccess": "Успешно изтриване на файла",
"ToastDeviceAddFailed": "Неуспешно добавяне на устройство",
"ToastDeviceNameAlreadyExists": "Вече съществува четец с това име",
"ToastDeviceTestEmailFailed": "Неуспешно изпращане на тестов имейл",
"ToastDeviceTestEmailSuccess": "Тестовият имейл е изпратен",
"ToastEmailSettingsUpdateSuccess": "Имейл настройките са актуализирани",
"ToastEncodeCancelFailed": "Неуспешно отменяне на кодирането",
"ToastEncodeCancelSucces": "Кодирането е отменено",
"ToastEpisodeDownloadQueueClearFailed": "Неуспешно изчистване на опашката",
"ToastEpisodeDownloadQueueClearSuccess": "Опашката за изтегляне на епизоди е изчистена",
"ToastEpisodeUpdateSuccess": "{0} епизода са актуализирани",
"ToastErrorCannotShare": "Не може да се споделя директно от това устройство",
"ToastFailedToCreate": "Неуспешно създаване",
"ToastFailedToDelete": "Неуспешно изтриване",
"ToastFailedToLoadData": "Неуспешно зареждане на данни", "ToastFailedToLoadData": "Неуспешно зареждане на данни",
"ToastFailedToMatch": "Неуспешно съвпадение",
"ToastFailedToShare": "Неуспешно споделяне",
"ToastFailedToUpdate": "Неуспешно актуализиране",
"ToastInvalidImageUrl": "Невалиден URL адрес на изображение",
"ToastInvalidMaxEpisodesToDownload": "Невалиден максимален брой епизоди за изтегляне",
"ToastInvalidUrl": "Невалиден URL адрес",
"ToastInvalidUrls": "Един или повече URL адреси са невалидни",
"ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена", "ToastItemCoverUpdateSuccess": "Корицата на елемента е обновена",
"ToastItemDeletedFailed": "Неуспешно изтриване на елемента",
"ToastItemDeletedSuccess": "Елементът е изтрит",
"ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени", "ToastItemDetailsUpdateSuccess": "Детайлите на елемента са обновени",
"ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като Завършено", "ToastItemMarkedAsFinishedFailed": "Неуспешно маркиране като Завършено",
"ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен", "ToastItemMarkedAsFinishedSuccess": "Елементът е маркиран като завършен",
"ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като Незавършено", "ToastItemMarkedAsNotFinishedFailed": "Неуспешно маркиране като Незавършено",
"ToastItemMarkedAsNotFinishedSuccess": "Елементът е маркиран като незавършен", "ToastItemMarkedAsNotFinishedSuccess": "Елементът е маркиран като незавършен",
"ToastItemUpdateSuccess": "Елементът е актуализиран",
"ToastLibraryCreateFailed": "Неуспешно създаване на библиотека", "ToastLibraryCreateFailed": "Неуспешно създаване на библиотека",
"ToastLibraryCreateSuccess": "Библиотеката \"{0}\" е създадена", "ToastLibraryCreateSuccess": "Библиотеката \"{0}\" е създадена",
"ToastLibraryDeleteFailed": "Неуспешно изтриване на библиотека", "ToastLibraryDeleteFailed": "Неуспешно изтриване на библиотека",
@ -1037,28 +1069,97 @@
"ToastLibraryScanFailedToStart": "Неуспешно стартиране на сканиране", "ToastLibraryScanFailedToStart": "Неуспешно стартиране на сканиране",
"ToastLibraryScanStarted": "Сканирането на библиотеката е стартирано", "ToastLibraryScanStarted": "Сканирането на библиотеката е стартирано",
"ToastLibraryUpdateSuccess": "Библиотеката \"{0}\" е обновена", "ToastLibraryUpdateSuccess": "Библиотеката \"{0}\" е обновена",
"ToastMatchAllAuthorsFailed": "Неуспешно съвпадение на всички автори",
"ToastMetadataFilesRemovedError": "Грешка при премахване на metadata.{0} файлове",
"ToastMetadataFilesRemovedNoneFound": "Не са намерени metadata.{0} файлове в библиотеката",
"ToastMetadataFilesRemovedNoneRemoved": "Не са премахнати metadata.{0} файлове",
"ToastMetadataFilesRemovedSuccess": "Премахнати са {0} файла metadata.{1}",
"ToastMustHaveAtLeastOnePath": "Трябва да има поне един път",
"ToastNameEmailRequired": "Изискват се име и имейл",
"ToastNameRequired": "Изисква се име",
"ToastNewApiKeyUserError": "Трябва да изберете потребител",
"ToastNewEpisodesFound": "Намерени са {0} нови епизода",
"ToastNewUserCreatedFailed": "Неуспешно създаване на акаунт: „{0}“",
"ToastNewUserCreatedSuccess": "Създаден е нов акаунт",
"ToastNewUserLibraryError": "Трябва да изберете поне една библиотека",
"ToastNewUserPasswordError": "Трябва да има парола; само root потребителят може да бъде с празна парола",
"ToastNewUserTagError": "Трябва да изберете поне един етикет",
"ToastNewUserUsernameError": "Въведете потребителско име",
"ToastNoNewEpisodesFound": "Не са намерени нови епизоди",
"ToastNoRSSFeed": "Подкастът няма RSS емисия",
"ToastNoUpdatesNecessary": "Не са необходими актуализации",
"ToastNotificationCreateFailed": "Неуспешно създаване на известие",
"ToastNotificationDeleteFailed": "Неуспешно изтриване на известието",
"ToastNotificationFailedMaximum": "Максималният брой неуспешни опити трябва да бъде >= 0",
"ToastNotificationQueueMaximum": "Максималната опашка за известия трябва да бъде >= 0",
"ToastNotificationSettingsUpdateSuccess": "Настройките за известия са актуализирани",
"ToastNotificationTestTriggerFailed": "Неуспешно задействане на тестово известие",
"ToastNotificationTestTriggerSuccess": "Тестовото известие е задействано",
"ToastNotificationUpdateSuccess": "Известието е актуализирано",
"ToastPlaylistCreateFailed": "Неуспешно създаване на плейлист", "ToastPlaylistCreateFailed": "Неуспешно създаване на плейлист",
"ToastPlaylistCreateSuccess": "Плейлистът е създаден", "ToastPlaylistCreateSuccess": "Плейлистът е създаден",
"ToastPlaylistRemoveSuccess": "Плейлистът е премахнат", "ToastPlaylistRemoveSuccess": "Плейлистът е премахнат",
"ToastPlaylistUpdateSuccess": "Плейлистът е обновен", "ToastPlaylistUpdateSuccess": "Плейлистът е обновен",
"ToastPodcastCreateFailed": "Неуспешно създаване на подкаст", "ToastPodcastCreateFailed": "Неуспешно създаване на подкаст",
"ToastPodcastCreateSuccess": "Подкаст успешно създаден", "ToastPodcastCreateSuccess": "Подкаст успешно създаден",
"ToastPodcastEpisodeUpdated": "Епизодът е актуализиран",
"ToastPodcastGetFeedFailed": "Неуспешно извличане на емисията на подкаста",
"ToastPodcastNoEpisodesInFeed": "Не са намерени епизоди в RSS емисията",
"ToastPodcastNoRssFeed": "Подкастът няма RSS емисия",
"ToastProgressIsNotBeingSynced": "Напредъкът не се синхронизира, рестартирайте възпроизвеждането",
"ToastProviderCreatedFailed": "Неуспешно добавяне на доставчик",
"ToastProviderCreatedSuccess": "Добавен е нов доставчик",
"ToastProviderNameAndUrlRequired": "Изискват се име и URL адрес",
"ToastProviderRemoveSuccess": "Доставчикът е премахнат",
"ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията", "ToastRSSFeedCloseFailed": "Неуспешно затваряне на RSS емисията",
"ToastRSSFeedCloseSuccess": "RSS емисията е затворена", "ToastRSSFeedCloseSuccess": "RSS емисията е затворена",
"ToastRemoveFailed": "Неуспешно премахване",
"ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция", "ToastRemoveItemFromCollectionFailed": "Неуспешно премахване на елемент от колекция",
"ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция", "ToastRemoveItemFromCollectionSuccess": "Елементът е премахнат от колекция",
"ToastRemoveItemsWithIssuesFailed": "Неуспешно премахване на елементите от библиотеката с проблеми",
"ToastRemoveItemsWithIssuesSuccess": "Елементите от библиотеката с проблеми са премахнати",
"ToastRenameFailed": "Неуспешно преименуване",
"ToastRescanFailed": "Повторното сканиране е неуспешно за {0}",
"ToastRescanRemoved": "Повторното сканиране завърши: елементът е премахнат",
"ToastRescanUpToDate": "Повторното сканиране завърши: елементът вече е актуален",
"ToastRescanUpdated": "Повторното сканиране завърши: елементът е актуализиран",
"ToastScanFailed": "Неуспешно сканиране на елемент от библиотеката",
"ToastSelectAtLeastOneUser": "Изберете поне един потребител",
"ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство", "ToastSendEbookToDeviceFailed": "Неуспешно изпращане на електронна книга до устройство",
"ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"", "ToastSendEbookToDeviceSuccess": "Електронната книга е изпратена до устройство \"{0}\"",
"ToastSeriesSubmitFailedSameName": "Не могат да бъдат добавени два сериала с едно и също име",
"ToastSeriesUpdateFailed": "Неуспешно обновяване на серия", "ToastSeriesUpdateFailed": "Неуспешно обновяване на серия",
"ToastSeriesUpdateSuccess": "Серията е обновена", "ToastSeriesUpdateSuccess": "Серията е обновена",
"ToastServerSettingsUpdateSuccess": "Настройките на сървъра са актуализирани", "ToastServerSettingsUpdateSuccess": "Настройките на сървъра са актуализирани",
"ToastSessionCloseFailed": "Неуспешно затваряне на сесията",
"ToastSessionDeleteFailed": "Неуспешно изтриване на сесия", "ToastSessionDeleteFailed": "Неуспешно изтриване на сесия",
"ToastSessionDeleteSuccess": "Сесията е изтрита", "ToastSessionDeleteSuccess": "Сесията е изтрита",
"ToastSleepTimerDone": "Таймерът за заспиване приключи... zZzzZz",
"ToastSlugMustChange": "Краткият URL (slug) съдържа невалидни символи",
"ToastSlugRequired": "Изисква се кратък URL (slug)",
"ToastSocketConnected": "Свързан сокет", "ToastSocketConnected": "Свързан сокет",
"ToastSocketDisconnected": "Сокетът е прекъснат", "ToastSocketDisconnected": "Сокетът е прекъснат",
"ToastSocketFailedToConnect": "Неуспешно свързване на сокет", "ToastSocketFailedToConnect": "Неуспешно свързване на сокет",
"ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране", "ToastSortingPrefixesEmptyError": "Трябва да има поне 1 префикс за сортиране",
"ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)", "ToastSortingPrefixesUpdateSuccess": "Префиксите за сортиране са актуализирани ({0} елемента)",
"ToastTitleRequired": "Изисква се заглавие",
"ToastUnknownError": "Неизвестна грешка",
"ToastUnlinkOpenIdFailed": "Неуспешно прекъсване на връзката на потребителя с OpenID",
"ToastUnlinkOpenIdSuccess": "Връзката на потребителя с OpenID е прекъсната",
"ToastUploaderFilepathExistsError": "Файловият път „{0}“ вече съществува на сървъра",
"ToastUploaderItemExistsInSubdirectoryError": "Елементът „{0}“ използва поддиректория на пътя за качване.",
"ToastUserDeleteFailed": "Неуспешно изтриване на потребител", "ToastUserDeleteFailed": "Неуспешно изтриване на потребител",
"ToastUserDeleteSuccess": "Потребителят е изтрит" "ToastUserDeleteSuccess": "Потребителят е изтрит",
"ToastUserPasswordChangeSuccess": "Паролата е променена успешно",
"ToastUserPasswordMismatch": "Паролите не съвпадат",
"ToastUserPasswordMustChange": "Новата парола не може да бъде същата като старата",
"ToastUserRootRequireName": "Трябва да въведете root потребителско име",
"TooltipAddChapters": "Добавяне на глава(и)",
"TooltipAddOneSecond": "Добавяне на 1 секунда",
"TooltipAdjustChapterStart": "Кликнете за коригиране на началния час",
"TooltipLockAllChapters": "Заключване на всички глави",
"TooltipLockChapter": "Заключване на глава (Shift+клик за диапазон)",
"TooltipSubtractOneSecond": "Изваждане на 1 секунда",
"TooltipUnlockAllChapters": "Отключване на всички глави",
"TooltipUnlockChapter": "Отключване на глава (Shift+клик за диапазон)"
} }

View file

@ -16,7 +16,7 @@
"ButtonBrowseForFolder": "Mappa keresése", "ButtonBrowseForFolder": "Mappa keresése",
"ButtonCancel": "Mégse", "ButtonCancel": "Mégse",
"ButtonCancelEncode": "Kódolás megszakítása", "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", "ButtonCheckAndDownloadNewEpisodes": "Új epizódok ellenőrzése és letöltése",
"ButtonChooseAFolder": "Válassz egy mappát", "ButtonChooseAFolder": "Válassz egy mappát",
"ButtonChooseFiles": "Fájlok kiválasztása", "ButtonChooseFiles": "Fájlok kiválasztása",

File diff suppressed because it is too large Load diff

1
client/strings/lv.json Normal file
View file

@ -0,0 +1 @@
{}

View file

@ -2,7 +2,7 @@
"ButtonAdd": "Toevoegen", "ButtonAdd": "Toevoegen",
"ButtonAddApiKey": "API Key toevoegen", "ButtonAddApiKey": "API Key toevoegen",
"ButtonAddChapters": "Hoofdstukken toevoegen", "ButtonAddChapters": "Hoofdstukken toevoegen",
"ButtonAddDevice": "Toestel toevoegen", "ButtonAddDevice": "Apparaat toevoegen",
"ButtonAddLibrary": "Bibliotheek toevoegen", "ButtonAddLibrary": "Bibliotheek toevoegen",
"ButtonAddPodcasts": "Podcasts toevoegen", "ButtonAddPodcasts": "Podcasts toevoegen",
"ButtonAddUser": "Gebruiker toevoegen", "ButtonAddUser": "Gebruiker toevoegen",
@ -139,7 +139,7 @@
"HeaderCustomMetadataProviders": "Aangepaste Metadata Providers", "HeaderCustomMetadataProviders": "Aangepaste Metadata Providers",
"HeaderDetails": "Details", "HeaderDetails": "Details",
"HeaderDownloadQueue": "Download-wachtrij", "HeaderDownloadQueue": "Download-wachtrij",
"HeaderEbookFiles": "Ebook bestanden", "HeaderEbookFiles": "E-book bestanden",
"HeaderEmail": "E-mail", "HeaderEmail": "E-mail",
"HeaderEmailSettings": "E-mail instellingen", "HeaderEmailSettings": "E-mail instellingen",
"HeaderEpisodes": "Afleveringen", "HeaderEpisodes": "Afleveringen",
@ -275,7 +275,7 @@
"LabelBonus": "Bonus", "LabelBonus": "Bonus",
"LabelBooks": "Boeken", "LabelBooks": "Boeken",
"LabelButtonText": "Knop Tekst", "LabelButtonText": "Knop Tekst",
"LabelByAuthor": "Door {0}", "LabelByAuthor": "door {0}",
"LabelChangePassword": "Wachtwoord wijzigen", "LabelChangePassword": "Wachtwoord wijzigen",
"LabelChannels": "Kanalen", "LabelChannels": "Kanalen",
"LabelChapterCount": "{0} Hoofdstukken", "LabelChapterCount": "{0} Hoofdstukken",
@ -383,7 +383,7 @@
"LabelFolders": "Mappen", "LabelFolders": "Mappen",
"LabelFontBold": "Vetgedrukt", "LabelFontBold": "Vetgedrukt",
"LabelFontBoldness": "Lettertype Dikte", "LabelFontBoldness": "Lettertype Dikte",
"LabelFontFamily": "Lettertypefamilie", "LabelFontFamily": "Letterfamilie",
"LabelFontItalic": "Cursief", "LabelFontItalic": "Cursief",
"LabelFontScale": "Lettertype schaal", "LabelFontScale": "Lettertype schaal",
"LabelFontStrikethrough": "Doorgestreept", "LabelFontStrikethrough": "Doorgestreept",
@ -436,9 +436,9 @@
"LabelLibraryFilterSublistEmpty": "Nee {0}", "LabelLibraryFilterSublistEmpty": "Nee {0}",
"LabelLibraryItem": "Bibliotheekonderdeel", "LabelLibraryItem": "Bibliotheekonderdeel",
"LabelLibraryName": "Bibliotheeknaam", "LabelLibraryName": "Bibliotheeknaam",
"LabelLibrarySortByProgress": "Voortuigang geüpdatet", "LabelLibrarySortByProgress": "Voortgang: Laatst geüpdatet",
"LabelLibrarySortByProgressFinished": "Datum voltooid", "LabelLibrarySortByProgressFinished": "Voortgang: Voltooid",
"LabelLibrarySortByProgressStarted": "Datum gestart", "LabelLibrarySortByProgressStarted": "Voortgang: Gestart",
"LabelLimit": "Limiet", "LabelLimit": "Limiet",
"LabelLineSpacing": "Regelruimte", "LabelLineSpacing": "Regelruimte",
"LabelListenAgain": "Opnieuw Beluisteren", "LabelListenAgain": "Opnieuw Beluisteren",
@ -588,8 +588,8 @@
"LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken", "LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken",
"LabelSettingsChromecastSupport": "Chromecast ondersteuning", "LabelSettingsChromecastSupport": "Chromecast ondersteuning",
"LabelSettingsDateFormat": "Datumnotatie", "LabelSettingsDateFormat": "Datumnotatie",
"LabelSettingsEnableWatcher": "Bibliotheken automatisch scannen op wijzigingen", "LabelSettingsEnableWatcher": "Bibliotheken automatisch monitoren op wijzigingen",
"LabelSettingsEnableWatcherForLibrary": "Bibliotheek automatisch scannen 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", "LabelSettingsEnableWatcherHelp": "Zorgt voor het automatisch toevoegen/bijwerken van onderdelen als bestandswijzigingen worden gedetecteerd. *Vereist herstarten van server",
"LabelSettingsEpubsAllowScriptedContent": "Sta scripted content toe in epubs", "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.", "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?", "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", "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.<br /><br />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.<br /><br />Alle apparaten die je server gebruiken, worden automatisch ververst.", "MessageRestoreBackupWarning": "Een back-up herstellen zal de volledige database in /config en de omslagen in /metadata/items & /metadata/authors overschrijven.<br /><br />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.<br /><br />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}", "MessageScheduleRunEveryWeekdayAtTime": "Elke {0} uitvoeren op {1}",
"MessageSearchResultsFor": "Zoekresultaten voor", "MessageSearchResultsFor": "Zoekresultaten voor",
"MessageSelected": "{0} geselecteerd", "MessageSelected": "{0} geselecteerd",
@ -1026,6 +1026,8 @@
"ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt", "ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt",
"ToastCollectionRemoveSuccess": "Collectie verwijderd", "ToastCollectionRemoveSuccess": "Collectie verwijderd",
"ToastCollectionUpdateSuccess": "Collectie bijgewerkt", "ToastCollectionUpdateSuccess": "Collectie bijgewerkt",
"ToastConnectionNotAvailable": "Verbinding niet beschikbaar. Gelieve later opnieuw te proberen",
"ToastCoverSearchFailed": "Omslag zoeken mislukt",
"ToastCoverUpdateFailed": "Omslag bijwerken mislukt", "ToastCoverUpdateFailed": "Omslag bijwerken mislukt",
"ToastDateTimeInvalidOrIncomplete": "Datum en tijd ongeldig of onvolledig", "ToastDateTimeInvalidOrIncomplete": "Datum en tijd ongeldig of onvolledig",
"ToastDeleteFileFailed": "Bestand verwijderen mislukt", "ToastDeleteFileFailed": "Bestand verwijderen mislukt",

View file

@ -951,6 +951,11 @@
"NoteUploaderFoldersWithMediaFiles": "Foldery z plikami multimedialnymi będą traktowane jako osobne elementy w bibliotece.", "NoteUploaderFoldersWithMediaFiles": "Foldery z plikami multimedialnymi będą traktowane jako osobne elementy w bibliotece.",
"NoteUploaderOnlyAudioFiles": "Jeśli przesyłasz tylko pliki audio, każdy plik audio będzie traktowany jako osobny audiobook.", "NoteUploaderOnlyAudioFiles": "Jeśli przesyłasz tylko pliki audio, każdy plik audio będzie traktowany jako osobny audiobook.",
"NoteUploaderUnsupportedFiles": "Nieobsługiwane pliki są ignorowane. Podczas dodawania folderu, inne pliki, które nie znajdują się w folderze elementu, są ignorowane.", "NoteUploaderUnsupportedFiles": "Nieobsługiwane pliki są ignorowane. Podczas dodawania folderu, inne pliki, które nie znajdują się w folderze elementu, są ignorowane.",
"NotificationOnBackupCompletedDescription": "Wyzwalane po zakończeniu tworzenia kopii zapasowej",
"NotificationOnBackupFailedDescription": "Wyzwalane w przypadku gdy stworzenie kopii zapasowej rzuci błąd",
"NotificationOnEpisodeDownloadedDescription": "Wyzwalane, gdy odcinek podcastu zostanie automatycznie pobrany",
"NotificationOnRSSFeedDisabledDescription": "Wyzwalane, gdy automatyczne pobieranie odcinków jest wyłączone z powodu zbyt wielu nieudanych prób",
"NotificationOnRSSFeedFailedDescription": "Wyzwalane, gdy żądanie kanału RSS dotyczące automatycznego pobrania odcinka nie powiedzie się",
"NotificationOnTestDescription": "Zdarzenie używane do testowania systemu powiadomień", "NotificationOnTestDescription": "Zdarzenie używane do testowania systemu powiadomień",
"PlaceholderBulkChapterInput": "Wpisz tytuł rozdziału lub użyj numeracji (np. „Odcinek 1”, „Rozdział 10”, „1.”)", "PlaceholderBulkChapterInput": "Wpisz tytuł rozdziału lub użyj numeracji (np. „Odcinek 1”, „Rozdział 10”, „1.”)",
"PlaceholderNewCollection": "Nowa nazwa kolekcji", "PlaceholderNewCollection": "Nowa nazwa kolekcji",
@ -960,6 +965,7 @@
"PlaceholderSearchEpisode": "Szukanie odcinka..", "PlaceholderSearchEpisode": "Szukanie odcinka..",
"StatsAuthorsAdded": "dodano autorów", "StatsAuthorsAdded": "dodano autorów",
"StatsBooksAdded": "dodano książki", "StatsBooksAdded": "dodano książki",
"StatsBooksAdditional": "Niektóre dodatki obejmują…",
"StatsBooksFinished": "ukończone książki", "StatsBooksFinished": "ukończone książki",
"StatsBooksFinishedThisYear": "Wybrane książki ukończone w tym roku…", "StatsBooksFinishedThisYear": "Wybrane książki ukończone w tym roku…",
"StatsBooksListenedTo": "książki wysłuchane", "StatsBooksListenedTo": "książki wysłuchane",
@ -976,6 +982,7 @@
"StatsTotalDuration": "O sumarycznej długości…", "StatsTotalDuration": "O sumarycznej długości…",
"StatsYearInReview": "PRZEGLĄD ROKU", "StatsYearInReview": "PRZEGLĄD ROKU",
"ToastAccountUpdateSuccess": "Zaktualizowano konto", "ToastAccountUpdateSuccess": "Zaktualizowano konto",
"ToastAppriseUrlRequired": "Należy wprowadzić adres URL Apprise",
"ToastAsinRequired": "ASIN jest wymagany", "ToastAsinRequired": "ASIN jest wymagany",
"ToastAuthorImageRemoveSuccess": "Zdjęcie autora usunięte", "ToastAuthorImageRemoveSuccess": "Zdjęcie autora usunięte",
"ToastAuthorNotFound": "Autor \"{0}\" nie został znaleziony", "ToastAuthorNotFound": "Autor \"{0}\" nie został znaleziony",
@ -994,8 +1001,11 @@
"ToastBackupRestoreFailed": "Nie udało się przywrócić kopii zapasowej", "ToastBackupRestoreFailed": "Nie udało się przywrócić kopii zapasowej",
"ToastBackupUploadFailed": "Nie udało się przesłać kopii zapasowej", "ToastBackupUploadFailed": "Nie udało się przesłać kopii zapasowej",
"ToastBackupUploadSuccess": "Kopia zapasowa została przesłana", "ToastBackupUploadSuccess": "Kopia zapasowa została przesłana",
"ToastBatchApplyDetailsToItemsSuccess": "Szczegóły zastosowane do elementów",
"ToastBatchDeleteFailed": "Usuwanie zbiorcze nie powiodło się", "ToastBatchDeleteFailed": "Usuwanie zbiorcze nie powiodło się",
"ToastBatchDeleteSuccess": "Usuwanie zbiorcze powiodło się", "ToastBatchDeleteSuccess": "Usuwanie zbiorcze powiodło się",
"ToastBatchQuickMatchFailed": "Szybkie dopasowanie partii nie powiodło się!",
"ToastBatchQuickMatchStarted": "Rozpoczęto partię szybkiego dopasowania {0} książek!",
"ToastBatchUpdateFailed": "Aktualizacja zbiorcza nie powiodła się", "ToastBatchUpdateFailed": "Aktualizacja zbiorcza nie powiodła się",
"ToastBatchUpdateSuccess": "Aktualizacja zbiorcza powiodła się", "ToastBatchUpdateSuccess": "Aktualizacja zbiorcza powiodła się",
"ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki", "ToastBookmarkCreateFailed": "Nie udało się utworzyć zakładki",
@ -1033,7 +1043,14 @@
"ToastEpisodeDownloadQueueClearSuccess": "Wyczyszczono kolejkę epizodów do ściągnięcia", "ToastEpisodeDownloadQueueClearSuccess": "Wyczyszczono kolejkę epizodów do ściągnięcia",
"ToastEpisodeUpdateSuccess": "Zaktualizowano {0} odcinków", "ToastEpisodeUpdateSuccess": "Zaktualizowano {0} odcinków",
"ToastErrorCannotShare": "Nie można udostępniać natywnie na tym urządzeniu.", "ToastErrorCannotShare": "Nie można udostępniać natywnie na tym urządzeniu.",
"ToastFailedToCreate": "Nie udało się utworzyć",
"ToastFailedToDelete": "Nie udało się usunąć",
"ToastFailedToLoadData": "Nie udało się załadować danych",
"ToastFailedToMatch": "Nie udało się dopasować",
"ToastFailedToShare": "Nie udało się udostępnić",
"ToastFailedToUpdate": "Nie udało się zaktualizować",
"ToastInvalidImageUrl": "Nieprawidłowy URL obrazu", "ToastInvalidImageUrl": "Nieprawidłowy URL obrazu",
"ToastInvalidMaxEpisodesToDownload": "Nieprawidłowa maksymalna liczba odcinków do pobrania",
"ToastInvalidUrl": "Nieprawidłowy URL", "ToastInvalidUrl": "Nieprawidłowy URL",
"ToastInvalidUrls": "Jeden lub więcej URL-i są nieprawidłowe", "ToastInvalidUrls": "Jeden lub więcej URL-i są nieprawidłowe",
"ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę", "ToastItemCoverUpdateSuccess": "Zaktualizowano okładkę",
@ -1044,6 +1061,7 @@
"ToastItemMarkedAsFinishedSuccess": "Pozycja oznaczona jako ukończona", "ToastItemMarkedAsFinishedSuccess": "Pozycja oznaczona jako ukończona",
"ToastItemMarkedAsNotFinishedFailed": "Oznaczenie pozycji jako ukończonej nie powiodło się", "ToastItemMarkedAsNotFinishedFailed": "Oznaczenie pozycji jako ukończonej nie powiodło się",
"ToastItemMarkedAsNotFinishedSuccess": "Pozycja oznaczona jako nieukończona", "ToastItemMarkedAsNotFinishedSuccess": "Pozycja oznaczona jako nieukończona",
"ToastItemUpdateSuccess": "Element zaktualizowany",
"ToastLibraryCreateFailed": "Nie udało się utworzyć biblioteki", "ToastLibraryCreateFailed": "Nie udało się utworzyć biblioteki",
"ToastLibraryCreateSuccess": "Biblioteka \"{0}\" stworzona", "ToastLibraryCreateSuccess": "Biblioteka \"{0}\" stworzona",
"ToastLibraryDeleteFailed": "Nie udało się usunąć biblioteki", "ToastLibraryDeleteFailed": "Nie udało się usunąć biblioteki",
@ -1052,6 +1070,10 @@
"ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki", "ToastLibraryScanStarted": "Rozpoczęto skanowanie biblioteki",
"ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji", "ToastLibraryUpdateSuccess": "Zaktualizowano \"{0}\" pozycji",
"ToastMatchAllAuthorsFailed": "Nie udało się dopasować wszystkich autorów", "ToastMatchAllAuthorsFailed": "Nie udało się dopasować wszystkich autorów",
"ToastMetadataFilesRemovedError": "Błąd podczas usuwania metadata.{0} plików",
"ToastMetadataFilesRemovedNoneFound": "Nie znaleziono metadata.{0} plików w bibliotece",
"ToastMetadataFilesRemovedNoneRemoved": "Nie usunięto żadnego metadata.{0} pliku",
"ToastMetadataFilesRemovedSuccess": "{0} metadata.{0} plików usunięto",
"ToastMustHaveAtLeastOnePath": "Musi mieć przynajmniej jedną ścieżkę", "ToastMustHaveAtLeastOnePath": "Musi mieć przynajmniej jedną ścieżkę",
"ToastNameEmailRequired": "Nazwa i email są wymagane", "ToastNameEmailRequired": "Nazwa i email są wymagane",
"ToastNameRequired": "Imię jest wymagane", "ToastNameRequired": "Imię jest wymagane",
@ -1065,7 +1087,15 @@
"ToastNewUserUsernameError": "Wprowadź nazwę użytkownika", "ToastNewUserUsernameError": "Wprowadź nazwę użytkownika",
"ToastNoNewEpisodesFound": "Nie znaleziono nowych odcinków", "ToastNoNewEpisodesFound": "Nie znaleziono nowych odcinków",
"ToastNoRSSFeed": "Podcast nie posiada RSS Feed", "ToastNoRSSFeed": "Podcast nie posiada RSS Feed",
"ToastNoUpdatesNecessary": "Brak konieczności aktualizacji",
"ToastNotificationCreateFailed": "Nie udało się utworzyć powiadomienia",
"ToastNotificationDeleteFailed": "Nie udało się usunąć powiadomienia",
"ToastNotificationFailedMaximum": "Maks. ilość nieudanych prób musi być >= 0", "ToastNotificationFailedMaximum": "Maks. ilość nieudanych prób musi być >= 0",
"ToastNotificationQueueMaximum": "Maksymalna liczba powiadomień w kolejce musi być >= 0",
"ToastNotificationSettingsUpdateSuccess": "Zaktualizowano ustawienia powiadomień",
"ToastNotificationTestTriggerFailed": "Nie udało się wywołać powiadomienia testowego",
"ToastNotificationTestTriggerSuccess": "Wyzwolono powiadomienie testowe",
"ToastNotificationUpdateSuccess": "Powiadomienie zaktualizowane",
"ToastPlaylistCreateFailed": "Nie udało się utworzyć playlisty", "ToastPlaylistCreateFailed": "Nie udało się utworzyć playlisty",
"ToastPlaylistCreateSuccess": "Playlista utworzona", "ToastPlaylistCreateSuccess": "Playlista utworzona",
"ToastPlaylistRemoveSuccess": "Playlista usunięta", "ToastPlaylistRemoveSuccess": "Playlista usunięta",
@ -1073,8 +1103,17 @@
"ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu", "ToastPodcastCreateFailed": "Nie udało się utworzyć podcastu",
"ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony", "ToastPodcastCreateSuccess": "Podcast został pomyślnie utworzony",
"ToastPodcastEpisodeUpdated": "Zaktualizowano odcinki", "ToastPodcastEpisodeUpdated": "Zaktualizowano odcinki",
"ToastPodcastGetFeedFailed": "Nie udało się pobrać kanału podcastu",
"ToastPodcastNoEpisodesInFeed": "Nie znaleziono żadnych odcinków w kanale RSS",
"ToastPodcastNoRssFeed": "Podcast nie ma kanału RSS",
"ToastProgressIsNotBeingSynced": "Postęp nie jest synchronizowany, uruchom ponownie odtwarzanie",
"ToastProviderCreatedFailed": "Nie udało się dodać dostawcy",
"ToastProviderCreatedSuccess": "Dodano nowego dostawcę",
"ToastProviderNameAndUrlRequired": "Wymagane jest podanie nazwy i adresu URL",
"ToastProviderRemoveSuccess": "Dostawca usunięty",
"ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się", "ToastRSSFeedCloseFailed": "Zamknięcie kanału RSS nie powiodło się",
"ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS powiodło się", "ToastRSSFeedCloseSuccess": "Zamknięcie kanału RSS powiodło się",
"ToastRemoveFailed": "Nie udało się usunąć",
"ToastRemoveItemFromCollectionFailed": "Nie udało się usunąć elementu z kolekcji", "ToastRemoveItemFromCollectionFailed": "Nie udało się usunąć elementu z kolekcji",
"ToastRemoveItemFromCollectionSuccess": "Pozycja usunięta z kolekcji", "ToastRemoveItemFromCollectionSuccess": "Pozycja usunięta z kolekcji",
"ToastRemoveItemsWithIssuesFailed": "Nie udało się usunąć wadliwych elementów z biblioteki", "ToastRemoveItemsWithIssuesFailed": "Nie udało się usunąć wadliwych elementów z biblioteki",
@ -1096,16 +1135,25 @@
"ToastSessionDeleteFailed": "Nie udało się usunąć sesji", "ToastSessionDeleteFailed": "Nie udało się usunąć sesji",
"ToastSessionDeleteSuccess": "Sesja usunięta", "ToastSessionDeleteSuccess": "Sesja usunięta",
"ToastSleepTimerDone": "Słodkich snów... zZzzZz", "ToastSleepTimerDone": "Słodkich snów... zZzzZz",
"ToastSlugMustChange": "Slug zawiera nieprawidłowe znaki",
"ToastSlugRequired": "Slug jest wymagany",
"ToastSocketConnected": "Nawiązano połączenie z serwerem", "ToastSocketConnected": "Nawiązano połączenie z serwerem",
"ToastSocketDisconnected": "Połączenie z serwerem zostało zamknięte", "ToastSocketDisconnected": "Połączenie z serwerem zostało zamknięte",
"ToastSocketFailedToConnect": "Poączenie z serwerem nie powiodło się", "ToastSocketFailedToConnect": "Poączenie z serwerem nie powiodło się",
"ToastSortingPrefixesEmptyError": "Musi mieć co najmniej 1 prefiks sortowania",
"ToastSortingPrefixesUpdateSuccess": "Zaktualizowano prefiksy sortowania ({0} elementów)",
"ToastTitleRequired": "Tytuł jest wymagany", "ToastTitleRequired": "Tytuł jest wymagany",
"ToastUnknownError": "Nieznany błąd", "ToastUnknownError": "Nieznany błąd",
"ToastUnlinkOpenIdFailed": "Nie udało się odpiąć użytkownika z OpenID", "ToastUnlinkOpenIdFailed": "Nie udało się odpiąć użytkownika z OpenID",
"ToastUnlinkOpenIdSuccess": "Użytkownik odpięty z OpenID", "ToastUnlinkOpenIdSuccess": "Użytkownik odpięty z OpenID",
"ToastUploaderFilepathExistsError": "Ścieżka \"{0}\" już istnieje na serwerze", "ToastUploaderFilepathExistsError": "Ścieżka \"{0}\" już istnieje na serwerze",
"ToastUploaderItemExistsInSubdirectoryError": "Element \"{0}\" używa podkatalogu ścieżki przesyłania.",
"ToastUserDeleteFailed": "Nie udało się usunąć użytkownika", "ToastUserDeleteFailed": "Nie udało się usunąć użytkownika",
"ToastUserDeleteSuccess": "Użytkownik usunięty", "ToastUserDeleteSuccess": "Użytkownik usunięty",
"ToastUserPasswordChangeSuccess": "Hasło zostało pomyślnie zmienione",
"ToastUserPasswordMismatch": "Hasła nie są zgodne",
"ToastUserPasswordMustChange": "Nowe hasło nie może być takie samo jak stare hasło",
"ToastUserRootRequireName": "Należy wprowadzić nazwę użytkownika root",
"TooltipAddChapters": "Dodaj rozdział(y)", "TooltipAddChapters": "Dodaj rozdział(y)",
"TooltipAddOneSecond": "Dodaj sekundę", "TooltipAddOneSecond": "Dodaj sekundę",
"TooltipAdjustChapterStart": "Kliknij, aby skorygować czas początkowy", "TooltipAdjustChapterStart": "Kliknij, aby skorygować czas początkowy",

4
package-lock.json generated
View file

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

View file

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

View file

@ -3,6 +3,7 @@ const Logger = require('./Logger')
const Database = require('./Database') const Database = require('./Database')
const TokenManager = require('./auth/TokenManager') const TokenManager = require('./auth/TokenManager')
const CoverSearchManager = require('./managers/CoverSearchManager') const CoverSearchManager = require('./managers/CoverSearchManager')
const { LogLevel } = require('./utils/constants')
/** /**
* @typedef SocketClient * @typedef SocketClient
@ -85,6 +86,14 @@ class SocketAuthority {
} }
} }
requireAdminSocket(socket, eventName) {
const client = this.clients[socket.id]
if (client?.user?.isAdminOrUp) return true
Logger.warn(`[SocketAuthority] Unauthorized ${eventName} socket event from socket ${socket.id}`)
return false
}
/** /**
* Emits event with library item to all clients that can access the library item * Emits event with library item to all clients that can access the library item
* Note: Emits toOldJSONExpanded() * Note: Emits toOldJSONExpanded()
@ -179,14 +188,25 @@ class SocketAuthority {
socket.on('auth', (token) => this.authenticateSocket(socket, token)) socket.on('auth', (token) => this.authenticateSocket(socket, token))
// Scanning // Scanning
socket.on('cancel_scan', (libraryId) => this.cancelScan(libraryId)) socket.on('cancel_scan', (libraryId) => {
if (!this.requireAdminSocket(socket, 'cancel_scan')) return
this.cancelScan(libraryId)
})
// Cover search streaming // Cover search streaming
socket.on('search_covers', (payload) => this.handleCoverSearch(socket, payload)) socket.on('search_covers', (payload) => this.handleCoverSearch(socket, payload))
socket.on('cancel_cover_search', (requestId) => this.handleCancelCoverSearch(socket, requestId)) socket.on('cancel_cover_search', (requestId) => this.handleCancelCoverSearch(socket, requestId))
// Logs // Logs
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level)) socket.on('set_log_listener', (level) => {
if (!this.requireAdminSocket(socket, 'set_log_listener')) return
if (!Number.isInteger(level) || !Object.values(LogLevel).includes(level)) {
Logger.warn(`[SocketAuthority] Invalid set_log_listener level from socket ${socket.id}`)
return
}
Logger.addSocketListener(socket, level)
})
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id)) socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
// Sent automatically from socket.io clients // Sent automatically from socket.io clients

View file

@ -1,4 +1,5 @@
const { Op } = require('sequelize') const { Op } = require('sequelize')
const uuid = require('uuid')
const Database = require('../Database') const Database = require('../Database')
const Logger = require('../Logger') const Logger = require('../Logger')
@ -115,6 +116,7 @@ class TokenManager {
const payload = { const payload = {
userId: user.id, userId: user.id,
username: user.username, username: user.username,
jti: uuid.v4(),
type: 'access' type: 'access'
} }
const options = { const options = {
@ -138,6 +140,7 @@ class TokenManager {
const payload = { const payload = {
userId: user.id, userId: user.id,
username: user.username, username: user.username,
jti: uuid.v4(),
type: 'refresh' type: 'refresh'
} }
const options = { const options = {
@ -183,20 +186,56 @@ class TokenManager {
* @param {import('../models/User')} user * @param {import('../models/User')} user
* @param {import('express').Request} req * @param {import('express').Request} req
* @param {import('express').Response} res * @param {import('express').Response} res
* @param {boolean} gracePeriod - whether to use the grace period
* @returns {Promise<{ accessToken:string, refreshToken:string }>} * @returns {Promise<{ accessToken:string, refreshToken:string }>}
*/ */
async rotateTokensForSession(session, user, req, res) { async rotateTokensForSession(session, user, req, res, gracePeriod = true) {
// Generate new tokens const previousRefreshToken = session.refreshToken
const newAccessToken = this.generateTempAccessToken(user) const newAccessToken = this.generateTempAccessToken(user)
const newRefreshToken = this.generateRefreshToken(user) let newRefreshToken = this.generateRefreshToken(user)
// Calculate new expiration time
const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
// Update the session with the new refresh token and expiration let lastRefreshToken = null
session.refreshToken = newRefreshToken let lastRefreshTokenExpiresAt = null
session.expiresAt = newExpiresAt if (gracePeriod) {
await session.save() // Set grace period of old refresh token in case of race condition in token rotation.
// This grace period may need to be longer if fetching the user data takes longer due to large progress objects
lastRefreshToken = previousRefreshToken
lastRefreshTokenExpiresAt = new Date(Date.now() + 60 * 1000) // 1 minute grace period
}
// Only update if this session row still has the refresh token we read
const [numUpdated] = await Database.sessionModel.update(
{
refreshToken: newRefreshToken,
expiresAt: newExpiresAt,
lastRefreshToken,
lastRefreshTokenExpiresAt
},
{
where: {
id: session.id,
refreshToken: previousRefreshToken
}
}
)
if (numUpdated === 0) {
Logger.debug(`[TokenManager] Race condition in rotateTokensForSession for user ${user.id}, getting new token`)
const updatedSession = await Database.sessionModel.findOne({ where: { id: session.id } })
newRefreshToken = updatedSession.refreshToken
session.refreshToken = updatedSession.refreshToken
session.expiresAt = updatedSession.expiresAt
session.lastRefreshToken = updatedSession.lastRefreshToken
session.lastRefreshTokenExpiresAt = updatedSession.lastRefreshTokenExpiresAt
} else {
session.refreshToken = newRefreshToken
session.expiresAt = newExpiresAt
session.lastRefreshToken = lastRefreshToken
session.lastRefreshTokenExpiresAt = lastRefreshTokenExpiresAt
}
// Set new refresh token cookie // Set new refresh token cookie
this.setRefreshTokenCookie(req, res, newRefreshToken) this.setRefreshTokenCookie(req, res, newRefreshToken)
@ -294,23 +333,40 @@ class TokenManager {
} }
} }
const session = await Database.sessionModel.findOne({ let session = await Database.sessionModel.findOne({
where: { refreshToken: refreshToken } where: {
[Op.or]: [{ refreshToken: refreshToken }, { lastRefreshToken: refreshToken }]
}
}) })
if (!session) { if (!session) {
Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`) Logger.error(`[TokenManager] Failed to refresh token. Session not found`)
return { return {
error: 'Invalid refresh token' error: 'Invalid refresh token'
} }
} }
// Check if session is expired in database let isGracePeriod = false
if (session.expiresAt < new Date()) { if (session.refreshToken !== refreshToken) {
Logger.info(`[TokenManager] Session expired in database, cleaning up`) // Token matched lastRefreshToken
await session.destroy() if (session.lastRefreshTokenExpiresAt && session.lastRefreshTokenExpiresAt > new Date()) {
return { isGracePeriod = true
error: 'Refresh token expired' Logger.debug(`[TokenManager] Grace period hit for user ${session.userId}`)
} else {
Logger.debug(`[TokenManager] Grace period expired for user ${session.userId}`)
return {
error: 'Invalid refresh token'
}
}
} else {
// Token matched current refreshToken
// Check if session is expired in database
if (session.expiresAt < new Date()) {
Logger.info(`[TokenManager] Session expired in database, cleaning up`)
await session.destroy()
return {
error: 'Refresh token expired'
}
} }
} }
@ -322,6 +378,20 @@ class TokenManager {
} }
} }
if (isGracePeriod) {
// Return the already rotated refresh token store in the database,
// and generate a new access token without changing the refresh token
// again
const accessToken = this.generateTempAccessToken(user)
this.setRefreshTokenCookie(req, res, session.refreshToken)
return {
accessToken,
refreshToken: session.refreshToken,
user
}
}
const newTokens = await this.rotateTokensForSession(session, user, req, res) const newTokens = await this.rotateTokensForSession(session, user, req, res)
return { return {
accessToken: newTokens.accessToken, accessToken: newTokens.accessToken,
@ -375,7 +445,7 @@ class TokenManager {
// So rotate token for current session // So rotate token for current session
const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } }) const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } })
if (currentSession) { if (currentSession) {
const newTokens = await this.rotateTokensForSession(currentSession, user, req, res) const newTokens = await this.rotateTokensForSession(currentSession, user, req, res, false)
// Invalidate all sessions for the user except the current one // Invalidate all sessions for the user except the current one
await Database.sessionModel.destroy({ await Database.sessionModel.destroy({
@ -389,7 +459,7 @@ class TokenManager {
return newTokens.accessToken return newTokens.accessToken
} else { } else {
Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`) Logger.error(`[TokenManager] No session found to rotate tokens`)
} }
} }
@ -413,7 +483,7 @@ class TokenManager {
try { try {
const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } }) const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } })
Logger.info(`[TokenManager] Refresh token ${refreshToken} invalidated, ${numDeleted} sessions deleted`) Logger.info(`[TokenManager] Refresh token invalidated, ${numDeleted} sessions deleted`)
return true return true
} catch (error) { } catch (error) {
Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`) Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`)

View file

@ -149,7 +149,7 @@ class AuthorController {
}) })
if (libraryItems.length) { if (libraryItems.length) {
await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor await Database.bookAuthorModel.removeByIds(req.author.id) // Remove all old BookAuthor
await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate) // Create all new BookAuthor await Database.bookAuthorModel.bulkCreate(bookAuthorsToCreate, { ignoreDuplicates: true }) // Create all new unique BookAuthor
for (const libraryItem of libraryItems) { for (const libraryItem of libraryItems) {
await libraryItem.saveMetadataFile() await libraryItem.saveMetadataFile()
} }

View file

@ -41,6 +41,10 @@ class CollectionController {
if (reqBody.description && typeof reqBody.description !== 'string') { if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid collection description') 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') const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string')
if (!libraryItemIds.length) { if (!libraryItemIds.length) {
return res.status(400).send('Invalid collection data. No books') return res.status(400).send('Invalid collection data. No books')
@ -109,8 +113,9 @@ class CollectionController {
*/ */
async findAll(req, res) { async findAll(req, res) {
const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user) const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user)
const accessibleCollections = collectionsExpanded.filter((c) => req.user.checkCanAccessLibrary(c.libraryId))
res.json({ res.json({
collections: collectionsExpanded collections: accessibleCollections
}) })
} }
@ -431,6 +436,10 @@ class CollectionController {
if (!collection) { if (!collection) {
return res.status(404).send('Collection not found') 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 req.collection = collection
} }

View file

@ -1,6 +1,7 @@
const { Request, Response, NextFunction } = require('express') const { Request, Response, NextFunction } = require('express')
const Path = require('path') const Path = require('path')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const cron = require('../libs/nodeCron')
const uaParserJs = require('../libs/uaParser') const uaParserJs = require('../libs/uaParser')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
@ -36,6 +37,24 @@ const ShareManager = require('../managers/ShareManager')
* @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile * @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 { class LibraryItemController {
constructor() {} constructor() {}
@ -202,6 +221,11 @@ class LibraryItemController {
} else if (mediaPayload.autoDownloadSchedule !== undefined && req.libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) { } else if (mediaPayload.autoDownloadSchedule !== undefined && req.libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
isPodcastAutoDownloadUpdated = true 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 let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
@ -547,7 +571,13 @@ class LibraryItemController {
return res.sendStatus(404) return res.sendStatus(404)
} }
// Ensure user has permission to delete these library items
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, itemsToDelete)) {
return
}
const libraryId = itemsToDelete[0].libraryId const libraryId = itemsToDelete[0].libraryId
for (const libraryItem of itemsToDelete) { for (const libraryItem of itemsToDelete) {
const libraryItemPath = libraryItem.path const libraryItemPath = libraryItem.path
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.title}" with id "${libraryItem.id}"`) Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.title}" with id "${libraryItem.id}"`)
@ -581,6 +611,7 @@ class LibraryItemController {
} }
await Database.resetLibraryIssuesFilterData(libraryId) await Database.resetLibraryIssuesFilterData(libraryId)
res.sendStatus(200) res.sendStatus(200)
} }
@ -593,6 +624,11 @@ class LibraryItemController {
* @param {Response} res * @param {Response} res
*/ */
async batchUpdate(req, 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 const updatePayloads = req.body
if (!Array.isArray(updatePayloads) || !updatePayloads.length) { if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`) Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)
@ -615,6 +651,11 @@ class LibraryItemController {
return res.sendStatus(404) return res.sendStatus(404)
} }
// Ensure user has permission to update these library items
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems)) {
return
}
let itemsUpdated = 0 let itemsUpdated = 0
const seriesIdsRemoved = [] const seriesIdsRemoved = []
@ -624,6 +665,11 @@ class LibraryItemController {
const mediaPayload = updatePayload.mediaPayload const mediaPayload = updatePayload.mediaPayload
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id) 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) let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) { if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
@ -695,6 +741,10 @@ class LibraryItemController {
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
id: libraryItemIds id: libraryItemIds
}) })
// Ensure user has permission to access these library items
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems)) {
return
}
res.json({ res.json({
libraryItems: libraryItems.map((li) => li.toOldJSONExpanded()) libraryItems: libraryItems.map((li) => li.toOldJSONExpanded())
}) })

View file

@ -8,7 +8,7 @@ const Database = require('../Database')
const Watcher = require('../Watcher') const Watcher = require('../Watcher')
const libraryItemFilters = require('../utils/queries/libraryItemFilters') 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 { isObject, getTitleIgnorePrefix } = require('../utils/index')
const { sanitizeFilename } = require('../utils/fileUtils') const { sanitizeFilename } = require('../utils/fileUtils')
@ -605,13 +605,11 @@ class MiscController {
return res.sendStatus(400) return res.sendStatus(400)
} }
try { if (!cron.validate(expression)) {
patternValidation(expression) Logger.warn(`[MiscController] Invalid cron expression ${expression}`)
res.sendStatus(200) return res.status(400).send('Invalid cron expression')
} catch (error) {
Logger.warn(`[MiscController] Invalid cron expression ${expression}`, error.message)
res.status(400).send(error.message)
} }
res.sendStatus(200)
} }
/** /**

View file

@ -37,6 +37,10 @@ class PlaylistController {
if (reqBody.description && typeof reqBody.description !== 'string') { if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid playlist description') 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 items = reqBody.items || []
const isPodcast = items.some((i) => i.episodeId) const isPodcast = items.some((i) => i.episodeId)
const libraryItemIds = new Set() const libraryItemIds = new Set()
@ -133,8 +137,9 @@ class PlaylistController {
*/ */
async findAllForUser(req, res) { async findAllForUser(req, res) {
const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id) const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id)
const accessiblePlaylists = playlistsForUser.filter((p) => req.user.checkCanAccessLibrary(p.libraryId))
res.json({ res.json({
playlists: playlistsForUser playlists: accessiblePlaylists
}) })
} }
@ -508,6 +513,10 @@ class PlaylistController {
if (!collection) { if (!collection) {
return res.status(404).send('Collection not found') 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 // Expand collection to get library items
const collectionExpanded = await collection.getOldJsonExpanded(req.user) const collectionExpanded = await collection.getOldJsonExpanded(req.user)
if (!collectionExpanded) { if (!collectionExpanded) {
@ -573,6 +582,10 @@ class PlaylistController {
Logger.warn(`[PlaylistController] Playlist ${req.params.id} requested by user ${req.user.id} that is not the owner`) Logger.warn(`[PlaylistController] Playlist ${req.params.id} requested by user ${req.user.id} that is not the owner`)
return res.sendStatus(403) 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 req.playlist = playlist
} }

View file

@ -5,6 +5,7 @@ const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const cron = require('../libs/nodeCron')
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils') const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
const { getFileTimestampsWithIno, filePathToPOSIX, isSameOrSubPath } = require('../utils/fileUtils') const { getFileTimestampsWithIno, filePathToPOSIX, isSameOrSubPath } = require('../utils/fileUtils')
@ -46,6 +47,11 @@ class PodcastController {
return res.status(400).send('Invalid request body. "media" and "media.metadata" are required') 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) const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId)
if (!library) { if (!library) {
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`) Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)

View file

@ -53,6 +53,10 @@ class ShareController {
if (playbackSession) { if (playbackSession) {
if (mediaItemShare.id === playbackSession.mediaItemShareId) { if (mediaItemShare.id === playbackSession.mediaItemShareId) {
Logger.debug(`[ShareController] Found share playback session ${req.cookies.share_session_id}`) 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() mediaItemShare.playbackSession = playbackSession.toJSONForClient()
return res.json(mediaItemShare) return res.json(mediaItemShare)
} else { } else {

View file

@ -42,11 +42,14 @@ class ApiCacheManager {
} }
clearUserProgressSlices(modelName, hook) { 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 const removedMe = this.modelsInvalidatingMe.has(modelName) ? this.clearByUrlPattern(/^\/me(\/|\?|$)/) : 0
Logger.debug( Logger.debug(`[ApiCacheManager] ${modelName}.${hook}: cleared user-progress cache slices (personalized=${removedPersonalized}, recentEpisodes=${removedRecentEpisodes}, me=${removedMe})`)
`[ApiCacheManager] ${modelName}.${hook}: cleared user-progress cache slices (personalized=${removedPersonalized}, me=${removedMe})`
)
} }
clear(model, hook) { clear(model, hook) {

View file

@ -153,6 +153,11 @@ class CronManager {
startPodcastCron(expression, libraryItemIds) { startPodcastCron(expression, libraryItemIds) {
try { 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)`) Logger.debug(`[CronManager] Scheduling podcast episode check cron "${expression}" for ${libraryItemIds.length} item(s)`)
const task = cron.schedule(expression, () => { const task = cron.schedule(expression, () => {
if (this.podcastCronExpressionsExecuting.includes(expression)) { if (this.podcastCronExpressionsExecuting.includes(expression)) {
@ -167,7 +172,7 @@ class CronManager {
task task
}) })
} catch (error) { } catch (error) {
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error) Logger.error(`[PodcastManager] Failed to schedule podcast cron ${expression}`, error)
} }
} }

View file

@ -2,6 +2,7 @@ const { Request, Response } = require('express')
const Path = require('path') const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
const { getAudioMimeTypeFromExtname } = require('../utils/fileUtils')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
@ -216,6 +217,11 @@ class RssFeedManager {
res.sendStatus(404) res.sendStatus(404)
return return
} }
// Express does not set the correct mimetype for m4b files so use our defined mimetypes if available
const audioMimeType = getAudioMimeTypeFromExtname(Path.extname(episodePath))
if (audioMimeType) {
res.setHeader('Content-Type', audioMimeType)
}
res.sendFile(episodePath) res.sendFile(episodePath)
} }

View file

@ -0,0 +1,84 @@
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
const migrationVersion = '2.35.0'
const migrationName = `${migrationVersion}-add-last-refresh-token`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This migration script adds lastRefreshToken and lastRefreshTokenExpiresAt columns to the sessions table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
if (await queryInterface.tableExists('sessions')) {
const tableDescription = await queryInterface.describeTable('sessions')
if (!tableDescription.lastRefreshToken) {
logger.info(`${loggerPrefix} Adding lastRefreshToken column to sessions table`)
await queryInterface.addColumn('sessions', 'lastRefreshToken', {
type: queryInterface.sequelize.Sequelize.DataTypes.STRING,
allowNull: true
})
} else {
logger.info(`${loggerPrefix} lastRefreshToken column already exists in sessions table`)
}
if (!tableDescription.lastRefreshTokenExpiresAt) {
logger.info(`${loggerPrefix} Adding lastRefreshTokenExpiresAt column to sessions table`)
await queryInterface.addColumn('sessions', 'lastRefreshTokenExpiresAt', {
type: queryInterface.sequelize.Sequelize.DataTypes.DATE,
allowNull: true
})
} else {
logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column already exists in sessions table`)
}
} else {
logger.info(`${loggerPrefix} sessions table does not exist`)
}
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This migration script removes the lastRefreshToken and lastRefreshTokenExpiresAt columns from the sessions table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
if (await queryInterface.tableExists('sessions')) {
const tableDescription = await queryInterface.describeTable('sessions')
if (tableDescription.lastRefreshToken) {
logger.info(`${loggerPrefix} Removing lastRefreshToken column from sessions table`)
await queryInterface.removeColumn('sessions', 'lastRefreshToken')
} else {
logger.info(`${loggerPrefix} lastRefreshToken column does not exist in sessions table`)
}
if (tableDescription.lastRefreshTokenExpiresAt) {
logger.info(`${loggerPrefix} Removing lastRefreshTokenExpiresAt column from sessions table`)
await queryInterface.removeColumn('sessions', 'lastRefreshTokenExpiresAt')
} else {
logger.info(`${loggerPrefix} lastRefreshTokenExpiresAt column does not exist in sessions table`)
}
} else {
logger.info(`${loggerPrefix} sessions table does not exist`)
}
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
module.exports = { up, down }

View file

@ -78,6 +78,7 @@ class Podcast extends Model {
*/ */
static async createFromRequest(payload, transaction) { static async createFromRequest(payload, transaction) {
const title = typeof payload.metadata.title === 'string' ? payload.metadata.title : null 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 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 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 : [] 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( return this.create(
{ {
title, title,
@ -97,7 +101,7 @@ class Podcast extends Model {
releaseDate: typeof payload.metadata.releaseDate === 'string' ? payload.metadata.releaseDate : null, releaseDate: typeof payload.metadata.releaseDate === 'string' ? payload.metadata.releaseDate : null,
feedURL: typeof payload.metadata.feedUrl === 'string' ? payload.metadata.feedUrl : null, feedURL: typeof payload.metadata.feedUrl === 'string' ? payload.metadata.feedUrl : null,
imageURL: typeof payload.metadata.imageUrl === 'string' ? payload.metadata.imageUrl : 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, itunesPageURL: typeof payload.metadata.itunesPageUrl === 'string' ? payload.metadata.itunesPageUrl : null,
itunesId: typeof payload.metadata.itunesId === 'string' ? payload.metadata.itunesId : null, itunesId: typeof payload.metadata.itunesId === 'string' ? payload.metadata.itunesId : null,
itunesArtistId: typeof payload.metadata.itunesArtistId === 'string' ? payload.metadata.itunesArtistId : null, itunesArtistId: typeof payload.metadata.itunesArtistId === 'string' ? payload.metadata.itunesArtistId : null,
@ -270,6 +274,7 @@ class Podcast extends Model {
hasUpdates = true hasUpdates = true
} }
if (typeof payload.autoDownloadSchedule === 'string' && payload.autoDownloadSchedule !== this.autoDownloadSchedule) { if (typeof payload.autoDownloadSchedule === 'string' && payload.autoDownloadSchedule !== this.autoDownloadSchedule) {
// cron expression validated in controller
this.autoDownloadSchedule = payload.autoDownloadSchedule this.autoDownloadSchedule = payload.autoDownloadSchedule
hasUpdates = true hasUpdates = true
} }

View file

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

View file

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

View file

@ -7,6 +7,7 @@ const parseNameString = require('../utils/parsers/parseNameString')
const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata') const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata')
const globals = require('../utils/globals') const globals = require('../utils/globals')
const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils') const { readTextFile, filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
const htmlSanitizer = require('../utils/htmlSanitizer')
const AudioFileScanner = require('./AudioFileScanner') const AudioFileScanner = require('./AudioFileScanner')
const Database = require('../Database') const Database = require('../Database')
@ -688,6 +689,10 @@ class BookScanner {
bookMetadata.titleIgnorePrefix = getTitleIgnorePrefix(bookMetadata.title) bookMetadata.titleIgnorePrefix = getTitleIgnorePrefix(bookMetadata.title)
if (typeof bookMetadata.description === 'string' && bookMetadata.description) {
bookMetadata.description = htmlSanitizer.sanitize(bookMetadata.description)
}
return bookMetadata return bookMetadata
} }

View file

@ -11,6 +11,7 @@ const LibraryFile = require('../objects/files/LibraryFile')
const fsExtra = require('../libs/fsExtra') const fsExtra = require('../libs/fsExtra')
const PodcastEpisode = require('../models/PodcastEpisode') const PodcastEpisode = require('../models/PodcastEpisode')
const AbsMetadataFileScanner = require('./AbsMetadataFileScanner') const AbsMetadataFileScanner = require('./AbsMetadataFileScanner')
const htmlSanitizer = require('../utils/htmlSanitizer')
/** /**
* Metadata for podcasts pulled from files * Metadata for podcasts pulled from files
@ -398,6 +399,10 @@ class PodcastScanner {
podcastMetadata.titleIgnorePrefix = getTitleIgnorePrefix(podcastMetadata.title) podcastMetadata.titleIgnorePrefix = getTitleIgnorePrefix(podcastMetadata.title)
if (typeof podcastMetadata.description === 'string' && podcastMetadata.description) {
podcastMetadata.description = htmlSanitizer.sanitize(podcastMetadata.description)
}
return podcastMetadata return podcastMetadata
} }

View file

@ -1,4 +1,5 @@
const axios = require('axios') const axios = require('axios')
const ssrfFilter = require('ssrf-req-filter')
const Ffmpeg = require('../libs/fluentFfmpeg') const Ffmpeg = require('../libs/fluentFfmpeg')
const ffmpgegUtils = require('../libs/fluentFfmpeg/utils') const ffmpgegUtils = require('../libs/fluentFfmpeg/utils')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
@ -97,6 +98,8 @@ async function resizeImage(filePath, outputPath, width, height) {
module.exports.resizeImage = resizeImage module.exports.resizeImage = resizeImage
/** /**
* Download podcast episode
* Uses SSRF filter to prevent internal URLs
* *
* @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload * @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload
* @returns {Promise<{success: boolean, isRequestError?: boolean}>} * @returns {Promise<{success: boolean, isRequestError?: boolean}>}
@ -121,7 +124,9 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
Accept: '*/*', Accept: '*/*',
'User-Agent': userAgent '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}`) Logger.debug(`[ffmpegHelpers] Successfully connected with User-Agent: ${userAgent}`)

View file

@ -123,7 +123,9 @@ describe('LibraryItemController', () => {
const fakeReq = { const fakeReq = {
query: {}, query: {},
user: { user: {
canDelete: true username: 'test',
canDelete: true,
checkCanAccessLibraryItem: () => true
}, },
body: { body: {
libraryItemIds: [libraryItem1Id] libraryItemIds: [libraryItem1Id]
@ -199,4 +201,102 @@ describe('LibraryItemController', () => {
expect(series2Exists).to.be.true 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
})
})
}) })

View file

@ -1,6 +1,7 @@
// Import dependencies and modules for testing // Import dependencies and modules for testing
const { expect } = require('chai') const { expect } = require('chai')
const sinon = require('sinon') const sinon = require('sinon')
const { LRUCache } = require('lru-cache')
const ApiCacheManager = require('../../../server/managers/ApiCacheManager') const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
describe('ApiCacheManager', () => { describe('ApiCacheManager', () => {
@ -94,4 +95,17 @@ describe('ApiCacheManager', () => {
expect(res.originalSend.calledWith(body)).to.be.true 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
})
})
}) })