mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-06 10:42:44 +02:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47457ee1e7 | |||
| cb6ff9eedf | |||
| 5dc01261c1 | |||
| cbc103cf05 | |||
| e79256d0fb | |||
| f8ef56c6bc | |||
| 62d7097e23 | |||
| 92df92ec99 | |||
| 1c229e0627 | |||
| f8a71cc514 | |||
| 63de5bb2d5 | |||
| 2c3108a1fa | |||
| 928051744a | |||
| 3ccdcaec1a | |||
| f47bbc7886 | |||
| 7c0ca44727 | |||
| d6a2e5596b | |||
| a5362de9cc | |||
| 9ab35ef418 | |||
| 79cc9765cf | |||
| 5b2a788cfc | |||
| 80b39abaa2 | |||
| b41db23994 | |||
| 125f265f55 | |||
| aa4a191567 | |||
| e431ea0472 | |||
| e3388d4446 | |||
| 88879f1409 | |||
| 3e0099e8d9 | |||
| f558182d94 | |||
| a30fe15b10 | |||
| 0bbf8bde5c | |||
| 0e2cdde731 | |||
| bc6bfbe804 | |||
| 2755204168 | |||
| 2d4df273f0 | |||
| d73b64a19c | |||
| b7e8a0474a | |||
| 39adefb632 | |||
| 24cab79c66 | |||
| b27f21fd95 | |||
| 09fa0b38f5 | |||
| 455e605162 | |||
| 88667d00a1 | |||
| 94c426bd97 | |||
| 522b9735e2 | |||
| 5a6b3d8e61 | |||
| 64cbf59609 | |||
| fda1a6ea9b | |||
| c4c8b8d0f2 | |||
| ab3bd6f4a1 | |||
| 093124aac6 | |||
| 5de92d08f9 | |||
| d9355ac3aa | |||
| a9e12657f5 |
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.33.1",
|
"version": "2.34.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.33.1",
|
"version": "2.34.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.33.1",
|
"version": "2.34.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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -46,7 +46,20 @@ export default class LocalAudioPlayer extends EventEmitter {
|
|||||||
this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this))
|
this.player.addEventListener('loadedmetadata', this.evtLoadedMetadata.bind(this))
|
||||||
this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this))
|
this.player.addEventListener('timeupdate', this.evtTimeupdate.bind(this))
|
||||||
|
|
||||||
var mimeTypes = ['audio/flac', 'audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/aac', 'audio/x-ms-wma', 'audio/x-aiff', 'audio/webm']
|
var mimeTypes = [
|
||||||
|
'audio/flac',
|
||||||
|
'audio/mpeg',
|
||||||
|
'audio/mp4',
|
||||||
|
'audio/ogg',
|
||||||
|
'audio/aac',
|
||||||
|
'audio/x-ms-wma',
|
||||||
|
'audio/x-aiff',
|
||||||
|
'audio/webm',
|
||||||
|
// `audio/matroska` is the correct mimetype, but the server still uses `audio/x-matroska`
|
||||||
|
// ref: https://www.iana.org/assignments/media-types/media-types.xhtml
|
||||||
|
'audio/matroska',
|
||||||
|
'audio/x-matroska'
|
||||||
|
]
|
||||||
var mimeTypeCanPlayMap = {}
|
var mimeTypeCanPlayMap = {}
|
||||||
mimeTypes.forEach((mt) => {
|
mimeTypes.forEach((mt) => {
|
||||||
var canPlay = this.player.canPlayType(mt)
|
var canPlay = this.player.canPlayType(mt)
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
+17
-17
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"ButtonAdd": "Дадаць",
|
"ButtonAdd": "Дадаць",
|
||||||
"ButtonAddApiKey": "Дадаць API-ключ",
|
"ButtonAddApiKey": "Дадаць ключ API",
|
||||||
"ButtonAddChapters": "Дадаць раздзелы",
|
"ButtonAddChapters": "Дадаць раздзелы",
|
||||||
"ButtonAddDevice": "Дадаць прыладу",
|
"ButtonAddDevice": "Дадаць прыладу",
|
||||||
"ButtonAddLibrary": "Дадаць бібліятэку",
|
"ButtonAddLibrary": "Дадаць бібліятэку",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"ButtonBrowseForFolder": "Агляд папак",
|
"ButtonBrowseForFolder": "Агляд папак",
|
||||||
"ButtonCancel": "Скасаваць",
|
"ButtonCancel": "Скасаваць",
|
||||||
"ButtonCancelEncode": "Скасаваць кадзіраванне",
|
"ButtonCancelEncode": "Скасаваць кадзіраванне",
|
||||||
"ButtonChangeRootPassword": "Зменіце Root пароль",
|
"ButtonChangeRootPassword": "Змяніць пароль root",
|
||||||
"ButtonCheckAndDownloadNewEpisodes": "Праверыць і спампаваць новыя выпускі",
|
"ButtonCheckAndDownloadNewEpisodes": "Праверыць і спампаваць новыя выпускі",
|
||||||
"ButtonChooseAFolder": "Выбраць папку",
|
"ButtonChooseAFolder": "Выбраць папку",
|
||||||
"ButtonChooseFiles": "Выбраць файлы",
|
"ButtonChooseFiles": "Выбраць файлы",
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
"ButtonRemove": "Выдаліць",
|
"ButtonRemove": "Выдаліць",
|
||||||
"ButtonRemoveAll": "Выдаліць усе",
|
"ButtonRemoveAll": "Выдаліць усе",
|
||||||
"ButtonRemoveAllLibraryItems": "Выдаліць усе элементы бібліятэкі",
|
"ButtonRemoveAllLibraryItems": "Выдаліць усе элементы бібліятэкі",
|
||||||
"ButtonRemoveFromContinueListening": "Выдаліць з Працягнуць праслухоўванне",
|
"ButtonRemoveFromContinueListening": "Выдаліць з Працяг праслухоўвання",
|
||||||
"ButtonRemoveFromContinueReading": "Выдаліць з Працягваць чытанне",
|
"ButtonRemoveFromContinueReading": "Выдаліць з Працягваць чытанне",
|
||||||
"ButtonRemoveSeriesFromContinueSeries": "Выдаліць серыю з Працягваць серыю",
|
"ButtonRemoveSeriesFromContinueSeries": "Выдаліць серыю з Працягваць серыю",
|
||||||
"ButtonReset": "Скінуць",
|
"ButtonReset": "Скінуць",
|
||||||
@@ -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,18 +242,18 @@
|
|||||||
"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к)",
|
||||||
"LabelAudioChannels": "Аўдыяканалы (1 або 2)",
|
"LabelAudioChannels": "Аўдыяканалы (1 або 2)",
|
||||||
"LabelAudioCodec": "Аўдыякодэк",
|
"LabelAudioCodec": "Аўдыякодэк",
|
||||||
"LabelAuthor": "Аўтар",
|
"LabelAuthor": "Аўтар",
|
||||||
"LabelAuthorFirstLast": "Аўтар (Імя Прозвішча)",
|
"LabelAuthorFirstLast": "Аўтар (імя, прозвішча)",
|
||||||
"LabelAuthorLastFirst": "Аўтар (Прозвішча, Імя)",
|
"LabelAuthorLastFirst": "Аўтар (прозвішча, імя)",
|
||||||
"LabelAuthors": "Аўтары",
|
"LabelAuthors": "Аўтары",
|
||||||
"LabelAutoDownloadEpisodes": "Аўтаматычна спампоўваць выпускі",
|
"LabelAutoDownloadEpisodes": "Аўтаматычна спампоўваць выпускі",
|
||||||
"LabelAutoFetchMetadata": "Аўтаматычнае атрыманне метаданых",
|
"LabelAutoFetchMetadata": "Аўтаматычнае атрыманне метаданых",
|
||||||
@@ -292,7 +292,7 @@
|
|||||||
"LabelCollections": "Калекцыі",
|
"LabelCollections": "Калекцыі",
|
||||||
"LabelComplete": "Завяршыць",
|
"LabelComplete": "Завяршыць",
|
||||||
"LabelConfirmPassword": "Пацвердзіце пароль",
|
"LabelConfirmPassword": "Пацвердзіце пароль",
|
||||||
"LabelContinueListening": "Працягнуць праслухоўванне",
|
"LabelContinueListening": "Працяг праслухоўвання",
|
||||||
"LabelContinueReading": "Працягнуць чытанне",
|
"LabelContinueReading": "Працягнуць чытанне",
|
||||||
"LabelContinueSeries": "Працягнуць серыі",
|
"LabelContinueSeries": "Працягнуць серыі",
|
||||||
"LabelCorsAllowed": "Дазволеныя крыніцы CORS",
|
"LabelCorsAllowed": "Дазволеныя крыніцы CORS",
|
||||||
@@ -424,7 +424,7 @@
|
|||||||
"LabelLastBookAdded": "Апошняя дададзеная кніга",
|
"LabelLastBookAdded": "Апошняя дададзеная кніга",
|
||||||
"LabelLastBookUpdated": "Апошняя абноўленая кніга",
|
"LabelLastBookUpdated": "Апошняя абноўленая кніга",
|
||||||
"LabelLastProgressDate": "Апошні прагрэс: {0}",
|
"LabelLastProgressDate": "Апошні прагрэс: {0}",
|
||||||
"LabelLastSeen": "Апошні прагляд",
|
"LabelLastSeen": "Апошняя актыўнасць",
|
||||||
"LabelLastTime": "Апошні раз",
|
"LabelLastTime": "Апошні раз",
|
||||||
"LabelLastUpdate": "Апошняе абнаўленне",
|
"LabelLastUpdate": "Апошняе абнаўленне",
|
||||||
"LabelLayout": "Знешні выгляд",
|
"LabelLayout": "Знешні выгляд",
|
||||||
@@ -545,7 +545,7 @@
|
|||||||
"LabelRSSFeedSlug": "Ідэнтыфікатар RSS-стужкі",
|
"LabelRSSFeedSlug": "Ідэнтыфікатар RSS-стужкі",
|
||||||
"LabelRSSFeedURL": "URL RSS-стужкі",
|
"LabelRSSFeedURL": "URL RSS-стужкі",
|
||||||
"LabelRandomly": "Выпадкова",
|
"LabelRandomly": "Выпадкова",
|
||||||
"LabelReAddSeriesToContinueListening": "Дадаць серыю зноў у Працягнуць праслухоўванне",
|
"LabelReAddSeriesToContinueListening": "Дадаць серыю зноў у Працяг праслухоўвання",
|
||||||
"LabelRead": "Чытаць",
|
"LabelRead": "Чытаць",
|
||||||
"LabelReadAgain": "Чытаць зноў",
|
"LabelReadAgain": "Чытаць зноў",
|
||||||
"LabelReadEbookWithoutProgress": "Чытаць электронную кнігу без захавання прагрэсу",
|
"LabelReadEbookWithoutProgress": "Чытаць электронную кнігу без захавання прагрэсу",
|
||||||
@@ -634,12 +634,12 @@
|
|||||||
"LabelSortAscending": "Па ўзрастанні",
|
"LabelSortAscending": "Па ўзрастанні",
|
||||||
"LabelSortDescending": "Па ўбыванні",
|
"LabelSortDescending": "Па ўбыванні",
|
||||||
"LabelSortPubDate": "Сартаваць па даце публікацыі",
|
"LabelSortPubDate": "Сартаваць па даце публікацыі",
|
||||||
"LabelStart": "Пачаць",
|
"LabelStart": "Пачатак",
|
||||||
"LabelStartTime": "Час пачатку",
|
"LabelStartTime": "Час пачатку",
|
||||||
"LabelStarted": "Пачата",
|
"LabelStarted": "Пачата",
|
||||||
"LabelStartedAt": "Пачата ў",
|
"LabelStartedAt": "Пачата ў",
|
||||||
"LabelStartedDate": "Пачата {0}",
|
"LabelStartedDate": "Пачата {0}",
|
||||||
"LabelStatsAudioTracks": "Аўдыятрэкаў",
|
"LabelStatsAudioTracks": "Аўдыятрэкі",
|
||||||
"LabelStatsAuthors": "Аўтараў",
|
"LabelStatsAuthors": "Аўтараў",
|
||||||
"LabelStatsBestDay": "Найлепшы дзень",
|
"LabelStatsBestDay": "Найлепшы дзень",
|
||||||
"LabelStatsDailyAverage": "У сярэднім за дзень",
|
"LabelStatsDailyAverage": "У сярэднім за дзень",
|
||||||
@@ -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 /> Усе кліенты, якія карыстаюцца вашым серверам, будуць аўтаматычна абноўлены.",
|
||||||
|
|||||||
+36
-2
@@ -436,7 +436,7 @@
|
|||||||
"LabelLibraryFilterSublistEmpty": "Не {0}",
|
"LabelLibraryFilterSublistEmpty": "Не {0}",
|
||||||
"LabelLibraryItem": "Елемент на Библиотека",
|
"LabelLibraryItem": "Елемент на Библиотека",
|
||||||
"LabelLibraryName": "Име на Библиотека",
|
"LabelLibraryName": "Име на Библиотека",
|
||||||
"LabelLibrarySortByProgress": "Прогрес: Последно Обновен",
|
"LabelLibrarySortByProgress": "Прогрес: Последно обновление",
|
||||||
"LabelLibrarySortByProgressFinished": "Прогрес: Приключено",
|
"LabelLibrarySortByProgressFinished": "Прогрес: Приключено",
|
||||||
"LabelLibrarySortByProgressStarted": "Прогрес: Започнато",
|
"LabelLibrarySortByProgressStarted": "Прогрес: Започнато",
|
||||||
"LabelLimit": "Лимит",
|
"LabelLimit": "Лимит",
|
||||||
@@ -892,7 +892,7 @@
|
|||||||
"MessageScheduleRunEveryWeekdayAtTime": "Изпълни всеки {0} в {1}",
|
"MessageScheduleRunEveryWeekdayAtTime": "Изпълни всеки {0} в {1}",
|
||||||
"MessageSearchResultsFor": "Резултати от търсенето за",
|
"MessageSearchResultsFor": "Резултати от търсенето за",
|
||||||
"MessageSelected": "{0} избрани",
|
"MessageSelected": "{0} избрани",
|
||||||
"MessageSeriesSequenceCannotContainSpaces": "Подредбата в серия не може да съдържа шпации.",
|
"MessageSeriesSequenceCannotContainSpaces": "Подредбата в серия не може да съдържа шпации",
|
||||||
"MessageServerCouldNotBeReached": "Сървърът не може да бъде достигнат",
|
"MessageServerCouldNotBeReached": "Сървърът не може да бъде достигнат",
|
||||||
"MessageSetChaptersFromTracksDescription": "Задайте глави, като използвате всеки аудио файл като глава и заглавие на главата като име на аудио файла",
|
"MessageSetChaptersFromTracksDescription": "Задайте глави, като използвате всеки аудио файл като глава и заглавие на главата като име на аудио файла",
|
||||||
"MessageShareExpirationWillBe": "Изтичането ще бъде на <strong>{0}</strong>",
|
"MessageShareExpirationWillBe": "Изтичането ще бъде на <strong>{0}</strong>",
|
||||||
@@ -956,6 +956,8 @@
|
|||||||
"NotificationOnEpisodeDownloadedDescription": "Изпълнява се при автоматично изтегляне на подкаст епизод",
|
"NotificationOnEpisodeDownloadedDescription": "Изпълнява се при автоматично изтегляне на подкаст епизод",
|
||||||
"NotificationOnRSSFeedDisabledDescription": "Изпълнява се, когато автоматичното изтегляне на епизодите е деактивирано, поради твърде много неуспешни опити",
|
"NotificationOnRSSFeedDisabledDescription": "Изпълнява се, когато автоматичното изтегляне на епизодите е деактивирано, поради твърде много неуспешни опити",
|
||||||
"NotificationOnRSSFeedFailedDescription": "Пуска се когато заявката за RSS фийд е неуспешна за автоматично сваляне на епизод",
|
"NotificationOnRSSFeedFailedDescription": "Пуска се когато заявката за RSS фийд е неуспешна за автоматично сваляне на епизод",
|
||||||
|
"NotificationOnTestDescription": "Event за тестване на системата за нотификации",
|
||||||
|
"PlaceholderBulkChapterInput": "Въведете име на глава или използвайте номериране (прим. 'Епизод 1', 'Глава 10', '1.')",
|
||||||
"PlaceholderNewCollection": "Ново име на колекцията",
|
"PlaceholderNewCollection": "Ново име на колекцията",
|
||||||
"PlaceholderNewFolderPath": "Нов път на папката",
|
"PlaceholderNewFolderPath": "Нов път на папката",
|
||||||
"PlaceholderNewPlaylist": "Ново име на плейлиста",
|
"PlaceholderNewPlaylist": "Ново име на плейлиста",
|
||||||
@@ -963,26 +965,58 @@
|
|||||||
"PlaceholderSearchEpisode": "Търсене на Епизоди...",
|
"PlaceholderSearchEpisode": "Търсене на Епизоди...",
|
||||||
"StatsAuthorsAdded": "добаврени автори",
|
"StatsAuthorsAdded": "добаврени автори",
|
||||||
"StatsBooksAdded": "добавени книги",
|
"StatsBooksAdded": "добавени книги",
|
||||||
|
"StatsBooksAdditional": "Някой от вкючените добавки…",
|
||||||
"StatsBooksFinished": "завършени книги",
|
"StatsBooksFinished": "завършени книги",
|
||||||
|
"StatsBooksFinishedThisYear": "Някой от книгите приключени тази година…",
|
||||||
|
"StatsBooksListenedTo": "слушани книги",
|
||||||
|
"StatsCollectionGrewTo": "Твоята книжна колекция израсна до…",
|
||||||
|
"StatsSessions": "сесии",
|
||||||
|
"StatsSpentListening": "прекарано в слушане",
|
||||||
|
"StatsTopAuthor": "ТОП АВТОР",
|
||||||
|
"StatsTopAuthors": "ТОП АВТОРИ",
|
||||||
|
"StatsTopGenre": "ТОП ЖАНР",
|
||||||
|
"StatsTopGenres": "ТОП ЖАНРА",
|
||||||
|
"StatsTopMonth": "ТОП МЕСЕЦ",
|
||||||
|
"StatsTopNarrator": "ТОП РАЗКАЗВАЧ",
|
||||||
|
"StatsTopNarrators": "ТОП РАЗКАЗВАЧИ",
|
||||||
|
"StatsTotalDuration": "С пълно времетраене…",
|
||||||
|
"StatsYearInReview": "ГОДИНАТА В ПРЕГЛЕД",
|
||||||
"ToastAccountUpdateSuccess": "Успешно обновяване на акаунта",
|
"ToastAccountUpdateSuccess": "Успешно обновяване на акаунта",
|
||||||
|
"ToastAppriseUrlRequired": "Трябва да въведете Apprise URL",
|
||||||
|
"ToastAsinRequired": "ASIN-а е задължителен",
|
||||||
"ToastAuthorImageRemoveSuccess": "Авторската снимка е премахната",
|
"ToastAuthorImageRemoveSuccess": "Авторската снимка е премахната",
|
||||||
|
"ToastAuthorNotFound": "Автор \"{0}\" не е намерен",
|
||||||
|
"ToastAuthorRemoveSuccess": "Арторът е премахнат",
|
||||||
|
"ToastAuthorSearchNotFound": "Авторът не е намерен",
|
||||||
"ToastAuthorUpdateMerged": "Обновяване на автора сливано",
|
"ToastAuthorUpdateMerged": "Обновяване на автора сливано",
|
||||||
"ToastAuthorUpdateSuccess": "Автора обновен",
|
"ToastAuthorUpdateSuccess": "Автора обновен",
|
||||||
"ToastAuthorUpdateSuccessNoImageFound": "Автор обновен (не е намерена снимка)",
|
"ToastAuthorUpdateSuccessNoImageFound": "Автор обновен (не е намерена снимка)",
|
||||||
|
"ToastBackupAppliedSuccess": "Архивът е приложен",
|
||||||
"ToastBackupCreateFailed": "Неуспешно създаване на архив",
|
"ToastBackupCreateFailed": "Неуспешно създаване на архив",
|
||||||
"ToastBackupCreateSuccess": "Архивът е създаден",
|
"ToastBackupCreateSuccess": "Архивът е създаден",
|
||||||
"ToastBackupDeleteFailed": "Неуспешно изтриване на архив",
|
"ToastBackupDeleteFailed": "Неуспешно изтриване на архив",
|
||||||
"ToastBackupDeleteSuccess": "Архивът е изтрит",
|
"ToastBackupDeleteSuccess": "Архивът е изтрит",
|
||||||
|
"ToastBackupInvalidMaxKeep": "Невалиден брой за архиви за запазване",
|
||||||
|
"ToastBackupInvalidMaxSize": "Невалиден максимален рамер на архив",
|
||||||
"ToastBackupRestoreFailed": "Неуспешно възстановяване на архив",
|
"ToastBackupRestoreFailed": "Неуспешно възстановяване на архив",
|
||||||
"ToastBackupUploadFailed": "Неуспешно качване на архив",
|
"ToastBackupUploadFailed": "Неуспешно качване на архив",
|
||||||
"ToastBackupUploadSuccess": "Архивът е качен",
|
"ToastBackupUploadSuccess": "Архивът е качен",
|
||||||
|
"ToastBatchApplyDetailsToItemsSuccess": "Детайли приложени на предмети",
|
||||||
|
"ToastBatchDeleteFailed": "Груповото изтриване се провали",
|
||||||
|
"ToastBatchDeleteSuccess": "Успешно групово изтриване",
|
||||||
|
"ToastBatchQuickMatchFailed": "Груповото Бързо Съвпадение се провали!",
|
||||||
|
"ToastBatchQuickMatchStarted": "Груповото Бързо Съвпадение на {0} книги започна!",
|
||||||
"ToastBatchUpdateFailed": "Неуспешно групово актуализиране",
|
"ToastBatchUpdateFailed": "Неуспешно групово актуализиране",
|
||||||
"ToastBatchUpdateSuccess": "Успешно групово актуализиране",
|
"ToastBatchUpdateSuccess": "Успешно групово актуализиране",
|
||||||
"ToastBookmarkCreateFailed": "Неуспешно създаване на отметка",
|
"ToastBookmarkCreateFailed": "Неуспешно създаване на отметка",
|
||||||
"ToastBookmarkCreateSuccess": "Отметката е създадена",
|
"ToastBookmarkCreateSuccess": "Отметката е създадена",
|
||||||
"ToastBookmarkRemoveSuccess": "Отметката е премахната",
|
"ToastBookmarkRemoveSuccess": "Отметката е премахната",
|
||||||
|
"ToastBulkChapterInvalidCount": "Въведете число между 1 и 150",
|
||||||
"ToastCachePurgeFailed": "Неуспешно изчистване на кеша",
|
"ToastCachePurgeFailed": "Неуспешно изчистване на кеша",
|
||||||
"ToastCachePurgeSuccess": "Успешно изчистване на кеша",
|
"ToastCachePurgeSuccess": "Успешно изчистване на кеша",
|
||||||
|
"ToastChapterLocked": "Главата е заключена.",
|
||||||
|
"ToastChapterStartTimeAdjusted": "Начално време на главате е настоено с {0} секунди",
|
||||||
|
"ToastChaptersAllLocked": "Всички глави са заключени. Оключете някой глави за да преместите техните времена.",
|
||||||
"ToastChaptersHaveErrors": "Главите имат грешки",
|
"ToastChaptersHaveErrors": "Главите имат грешки",
|
||||||
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
|
"ToastChaptersMustHaveTitles": "Главите трябва да имат заглавия",
|
||||||
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
|
"ToastCollectionRemoveSuccess": "Колекцията е премахната",
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
"ButtonViewAll": "Alles anzeigen",
|
"ButtonViewAll": "Alles anzeigen",
|
||||||
"ButtonYes": "Ja",
|
"ButtonYes": "Ja",
|
||||||
"ErrorUploadFetchMetadataAPI": "Fehler beim Abrufen der Metadaten",
|
"ErrorUploadFetchMetadataAPI": "Fehler beim Abrufen der Metadaten",
|
||||||
"ErrorUploadFetchMetadataNoResults": "Metadaten konnten nicht abgerufen werden. Versuche, den Titel und/oder den Autor zu aktualisieren.",
|
"ErrorUploadFetchMetadataNoResults": "Metadaten konnten nicht abgerufen werden - versuche den Titel und/oder den Autor zu aktualisieren",
|
||||||
"ErrorUploadLacksTitle": "Es muss ein Titel eingegeben werden",
|
"ErrorUploadLacksTitle": "Es muss ein Titel eingegeben werden",
|
||||||
"HeaderAccount": "Konto",
|
"HeaderAccount": "Konto",
|
||||||
"HeaderAddCustomMetadataProvider": "Benutzerdefinierten Metadatenanbieter hinzufügen",
|
"HeaderAddCustomMetadataProvider": "Benutzerdefinierten Metadatenanbieter hinzufügen",
|
||||||
@@ -622,7 +622,7 @@
|
|||||||
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet",
|
"LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet",
|
||||||
"LabelSettingsTimeFormat": "Zeitformat",
|
"LabelSettingsTimeFormat": "Zeitformat",
|
||||||
"LabelShare": "Freigeben",
|
"LabelShare": "Freigeben",
|
||||||
"LabelShareDownloadableHelp": "Erlaubt es einem Nutzer, mit dem Link, die Dateien des Mediums als ZIP herunterzuladen.",
|
"LabelShareDownloadableHelp": "Erlaubt es einem Nutzer, mit dem Link die Dateien des Mediums als ZIP herunterzuladen.",
|
||||||
"LabelShareOpen": "Freigeben",
|
"LabelShareOpen": "Freigeben",
|
||||||
"LabelShareURL": "Freigabe URL",
|
"LabelShareURL": "Freigabe URL",
|
||||||
"LabelShowAll": "Alles anzeigen",
|
"LabelShowAll": "Alles anzeigen",
|
||||||
@@ -737,7 +737,7 @@
|
|||||||
"MessageAddToPlayerQueue": "Zur Abspielwarteliste hinzufügen",
|
"MessageAddToPlayerQueue": "Zur Abspielwarteliste hinzufügen",
|
||||||
"MessageAppriseDescription": "Um diese Funktion nutzen zu können, musst du eine Instanz von <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann. <br />Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter <code>http://192.168.1.1:8337</code> läuft, würdest du <code>http://192.168.1.1:8337/notify</code> eingeben.",
|
"MessageAppriseDescription": "Um diese Funktion nutzen zu können, musst du eine Instanz von <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> laufen haben oder eine API verwenden welche dieselbe Anfragen bearbeiten kann. <br />Die Apprise API Url muss der vollständige URL-Pfad sein, an den die Benachrichtigung gesendet werden soll, z.B. wenn Ihre API-Instanz unter <code>http://192.168.1.1:8337</code> läuft, würdest du <code>http://192.168.1.1:8337/notify</code> eingeben.",
|
||||||
"MessageAsinCheck": "Stelle sicher, dass die ASIN aus der richtigen Audible Region verwendet wird, nicht Amazon.",
|
"MessageAsinCheck": "Stelle sicher, dass die ASIN aus der richtigen Audible Region verwendet wird, nicht Amazon.",
|
||||||
"MessageAuthenticationLegacyTokenWarning": "Alte API-Token werden in Zukunft entfernt. Benutze stattdessen <a href=\"/config/api-keys\">API Keys</a>.",
|
"MessageAuthenticationLegacyTokenWarning": "Nicht mehr unterstützte API tokens werden in der Zukunft entfernt. Nutze stattdessen <a href=\"/config/api-keys\">API Schlüssel</a>.",
|
||||||
"MessageAuthenticationOIDCChangesRestart": "Nach dem Speichern muss der Server neugestartet werden um die OIDC Änderungen zu übernehmen.",
|
"MessageAuthenticationOIDCChangesRestart": "Nach dem Speichern muss der Server neugestartet werden um die OIDC Änderungen zu übernehmen.",
|
||||||
"MessageAuthenticationSecurityMessage": "Die Anmeldung wurde abgesichert. Benutzersitzungen werden getrennt, alle Benutzer müssen sich erneut anmelden.",
|
"MessageAuthenticationSecurityMessage": "Die Anmeldung wurde abgesichert. Benutzersitzungen werden getrennt, alle Benutzer müssen sich erneut anmelden.",
|
||||||
"MessageBackupsDescription": "In einer Sicherung werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder welche in <code>/metadata/items</code> & <code>/metadata/authors</code> gespeichert sind gespeichert. Sicherungen enthalten keine Dateien welche in den einzelnen Bibliotheksordnern (Medien-Ordnern) gespeichert sind.",
|
"MessageBackupsDescription": "In einer Sicherung werden Benutzer, Benutzerfortschritte, Details zu den Bibliotheksobjekten, Servereinstellungen und Bilder welche in <code>/metadata/items</code> & <code>/metadata/authors</code> gespeichert sind gespeichert. Sicherungen enthalten keine Dateien welche in den einzelnen Bibliotheksordnern (Medien-Ordnern) gespeichert sind.",
|
||||||
@@ -816,7 +816,7 @@
|
|||||||
"MessageFeedURLWillBe": "Feed-URL wird {0} sein",
|
"MessageFeedURLWillBe": "Feed-URL wird {0} sein",
|
||||||
"MessageFetching": "Wird abgerufen …",
|
"MessageFetching": "Wird abgerufen …",
|
||||||
"MessageForceReScanDescription": "Durchsucht alle Dateien erneut, wie bei einem frischen Scan. ID3-Tags von Audiodateien, OPF-Dateien und Textdateien werden neu durchsucht.",
|
"MessageForceReScanDescription": "Durchsucht alle Dateien erneut, wie bei einem frischen Scan. ID3-Tags von Audiodateien, OPF-Dateien und Textdateien werden neu durchsucht.",
|
||||||
"MessageHeatmapListeningTimeTooltip": "<strong>{0} </strong> auf {1} gehört",
|
"MessageHeatmapListeningTimeTooltip": "<strong>{0} gehört</strong> auf {1}",
|
||||||
"MessageHeatmapNoListeningSessions": "Keine Hörsitzungen am {0}",
|
"MessageHeatmapNoListeningSessions": "Keine Hörsitzungen am {0}",
|
||||||
"MessageImportantNotice": "Wichtiger Hinweis!",
|
"MessageImportantNotice": "Wichtiger Hinweis!",
|
||||||
"MessageInsertChapterBelow": "Kapitel unten einfügen",
|
"MessageInsertChapterBelow": "Kapitel unten einfügen",
|
||||||
@@ -1103,7 +1103,7 @@
|
|||||||
"ToastPodcastCreateFailed": "Podcast konnte nicht erstellt werden",
|
"ToastPodcastCreateFailed": "Podcast konnte nicht erstellt werden",
|
||||||
"ToastPodcastCreateSuccess": "Podcast erstellt",
|
"ToastPodcastCreateSuccess": "Podcast erstellt",
|
||||||
"ToastPodcastEpisodeUpdated": "Podcast-Folge aktualisiert",
|
"ToastPodcastEpisodeUpdated": "Podcast-Folge aktualisiert",
|
||||||
"ToastPodcastGetFeedFailed": "Fehler beim abrufen des Podcast-Feeds",
|
"ToastPodcastGetFeedFailed": "Fehler beim Abrufen des Podcast Feeds",
|
||||||
"ToastPodcastNoEpisodesInFeed": "Keine Episoden in RSS Feed gefunden",
|
"ToastPodcastNoEpisodesInFeed": "Keine Episoden in RSS Feed gefunden",
|
||||||
"ToastPodcastNoRssFeed": "Podcast enthält keinen RSS Feed",
|
"ToastPodcastNoRssFeed": "Podcast enthält keinen RSS Feed",
|
||||||
"ToastProgressIsNotBeingSynced": "Fortschritt wird nicht synchronisiert, Wiedergabe wird neu gestartet",
|
"ToastProgressIsNotBeingSynced": "Fortschritt wird nicht synchronisiert, Wiedergabe wird neu gestartet",
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
"ButtonFullPath": "Ruta completa",
|
"ButtonFullPath": "Ruta completa",
|
||||||
"ButtonHide": "Ocultar",
|
"ButtonHide": "Ocultar",
|
||||||
"ButtonHome": "Inicio",
|
"ButtonHome": "Inicio",
|
||||||
"ButtonIssues": "Cuestiones",
|
"ButtonIssues": "Incidencias",
|
||||||
"ButtonJumpBackward": "Retroceder",
|
"ButtonJumpBackward": "Retroceder",
|
||||||
"ButtonJumpForward": "Adelantar",
|
"ButtonJumpForward": "Adelantar",
|
||||||
"ButtonLatest": "Más recientes",
|
"ButtonLatest": "Más recientes",
|
||||||
@@ -850,7 +850,7 @@
|
|||||||
"MessageNoEpisodes": "Ningún episodio",
|
"MessageNoEpisodes": "Ningún episodio",
|
||||||
"MessageNoFoldersAvailable": "Ninguna carpeta disponible",
|
"MessageNoFoldersAvailable": "Ninguna carpeta disponible",
|
||||||
"MessageNoGenres": "Ningún género",
|
"MessageNoGenres": "Ningún género",
|
||||||
"MessageNoIssues": "Ningún número",
|
"MessageNoIssues": "Sin incidencias",
|
||||||
"MessageNoItems": "Ningún elemento",
|
"MessageNoItems": "Ningún elemento",
|
||||||
"MessageNoItemsFound": "Ningún elemento encontrado",
|
"MessageNoItemsFound": "Ningún elemento encontrado",
|
||||||
"MessageNoListeningSessions": "Ninguna sesión de escucha",
|
"MessageNoListeningSessions": "Ninguna sesión de escucha",
|
||||||
@@ -1116,8 +1116,8 @@
|
|||||||
"ToastRemoveFailed": "Error al eliminar",
|
"ToastRemoveFailed": "Error al eliminar",
|
||||||
"ToastRemoveItemFromCollectionFailed": "Error al eliminar el elemento de la colección",
|
"ToastRemoveItemFromCollectionFailed": "Error al eliminar el elemento de la colección",
|
||||||
"ToastRemoveItemFromCollectionSuccess": "Elemento eliminado de la colección",
|
"ToastRemoveItemFromCollectionSuccess": "Elemento eliminado de la colección",
|
||||||
"ToastRemoveItemsWithIssuesFailed": "Error en la eliminación de artículos de biblioteca incorrectos",
|
"ToastRemoveItemsWithIssuesFailed": "Error en la eliminación de artículos de biblioteca con incidencias",
|
||||||
"ToastRemoveItemsWithIssuesSuccess": "Se eliminaron artículos de biblioteca incorrectos",
|
"ToastRemoveItemsWithIssuesSuccess": "Se eliminaron artículos de biblioteca con incidencias",
|
||||||
"ToastRenameFailed": "Error al cambiar el nombre",
|
"ToastRenameFailed": "Error al cambiar el nombre",
|
||||||
"ToastRescanFailed": "Error al volver a escanear para {0}",
|
"ToastRescanFailed": "Error al volver a escanear para {0}",
|
||||||
"ToastRescanRemoved": "Se eliminó el elemento reescaneado",
|
"ToastRescanRemoved": "Se eliminó el elemento reescaneado",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+12
-12
@@ -34,7 +34,7 @@
|
|||||||
"ButtonEditChapters": "Modifica Capitoli",
|
"ButtonEditChapters": "Modifica Capitoli",
|
||||||
"ButtonEditPodcast": "Modifica Podcast",
|
"ButtonEditPodcast": "Modifica Podcast",
|
||||||
"ButtonEnable": "Abilita",
|
"ButtonEnable": "Abilita",
|
||||||
"ButtonFireAndFail": "Fire and Fail",
|
"ButtonFireAndFail": "Centro e fallimento",
|
||||||
"ButtonFireOnTest": "Fire onTest event",
|
"ButtonFireOnTest": "Fire onTest event",
|
||||||
"ButtonForceReScan": "Forza Re-Scan",
|
"ButtonForceReScan": "Forza Re-Scan",
|
||||||
"ButtonFullPath": "Percorso Completo",
|
"ButtonFullPath": "Percorso Completo",
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
"HeaderPlaylist": "Playlist",
|
"HeaderPlaylist": "Playlist",
|
||||||
"HeaderPlaylistItems": "Elementi della playlist",
|
"HeaderPlaylistItems": "Elementi della playlist",
|
||||||
"HeaderPodcastsToAdd": "Podcasts da Aggiungere",
|
"HeaderPodcastsToAdd": "Podcasts da Aggiungere",
|
||||||
"HeaderPresets": "Presets",
|
"HeaderPresets": "Preimpostazioni",
|
||||||
"HeaderPreviewCover": "Anteprima Cover",
|
"HeaderPreviewCover": "Anteprima Cover",
|
||||||
"HeaderRSSFeedGeneral": "Dettagli RSS",
|
"HeaderRSSFeedGeneral": "Dettagli RSS",
|
||||||
"HeaderRSSFeedIsOpen": "RSS Feed è aperto",
|
"HeaderRSSFeedIsOpen": "RSS Feed è aperto",
|
||||||
@@ -306,7 +306,7 @@
|
|||||||
"LabelCustomCronExpression": "Espressione Cron personalizzata:",
|
"LabelCustomCronExpression": "Espressione Cron personalizzata:",
|
||||||
"LabelDatetime": "Data & Ora",
|
"LabelDatetime": "Data & Ora",
|
||||||
"LabelDays": "Giorni",
|
"LabelDays": "Giorni",
|
||||||
"LabelDeleteFromFileSystemCheckbox": "Elimina dal file system (togli la spunta per eliminarla solo dal DB)",
|
"LabelDeleteFromFileSystemCheckbox": "Elimina dal file system (despunta per rimuoverla solo dal database)",
|
||||||
"LabelDescription": "Descrizione",
|
"LabelDescription": "Descrizione",
|
||||||
"LabelDeselectAll": "Deseleziona Tutto",
|
"LabelDeselectAll": "Deseleziona Tutto",
|
||||||
"LabelDetectedPattern": "Trovato pattern:",
|
"LabelDetectedPattern": "Trovato pattern:",
|
||||||
@@ -436,9 +436,9 @@
|
|||||||
"LabelLibraryFilterSublistEmpty": "Nessuno {0}",
|
"LabelLibraryFilterSublistEmpty": "Nessuno {0}",
|
||||||
"LabelLibraryItem": "Elementi della biblioteca",
|
"LabelLibraryItem": "Elementi della biblioteca",
|
||||||
"LabelLibraryName": "Nome della biblioteca",
|
"LabelLibraryName": "Nome della biblioteca",
|
||||||
"LabelLibrarySortByProgress": "Progressi: Ultimi aggiornamenti",
|
"LabelLibrarySortByProgress": "Progresso: ultimo aggiornamento",
|
||||||
"LabelLibrarySortByProgressFinished": "Progressi: Completati",
|
"LabelLibrarySortByProgressFinished": "Progresso: finito",
|
||||||
"LabelLibrarySortByProgressStarted": "Progressi: Iniziati",
|
"LabelLibrarySortByProgressStarted": "Progresso: iniziato",
|
||||||
"LabelLimit": "Limiti",
|
"LabelLimit": "Limiti",
|
||||||
"LabelLineSpacing": "Interlinea",
|
"LabelLineSpacing": "Interlinea",
|
||||||
"LabelListenAgain": "Ascolta ancora",
|
"LabelListenAgain": "Ascolta ancora",
|
||||||
@@ -497,7 +497,7 @@
|
|||||||
"LabelNumberOfBooks": "Numero di libri",
|
"LabelNumberOfBooks": "Numero di libri",
|
||||||
"LabelNumberOfChapters": "Numero di capitoli:",
|
"LabelNumberOfChapters": "Numero di capitoli:",
|
||||||
"LabelNumberOfEpisodes": "Numero di episodi",
|
"LabelNumberOfEpisodes": "Numero di episodi",
|
||||||
"LabelOpenIDAdvancedPermsClaimDescription": "Nome dell'attestazione OpenID che contiene autorizzazioni avanzate per le azioni dell'utente all'interno dell'applicazione che verranno applicate ai ruoli non amministratori (<b>se configurato</b>). Se il reclamo manca nella risposta, l'accesso ad ABS verrà negato. Se manca una singola opzione, verrà trattata come<code>falsa</code>. Assicurati che l'attestazione del provider di identità corrisponda alla struttura prevista:",
|
"LabelOpenIDAdvancedPermsClaimDescription": "Nome dell'attestazione OpenID che contiene autorizzazioni avanzate per le azioni dell'utente all'interno dell'applicazione che verranno applicate ai ruoli non amministrativi (<b>se configurato</b>). Se il reclamo manca nella risposta, l'accesso ad ABS verrà negato. Se manca una singola opzione, verrà trattata come <code>falso</code>. Assicurati che l'attestazione del provider di identità corrisponda alla struttura prevista:",
|
||||||
"LabelOpenIDClaims": "Lasciare vuote le seguenti opzioni per disabilitare l'assegnazione avanzata di gruppi e autorizzazioni, assegnando quindi automaticamente il gruppo \"Utente\".",
|
"LabelOpenIDClaims": "Lasciare vuote le seguenti opzioni per disabilitare l'assegnazione avanzata di gruppi e autorizzazioni, assegnando quindi automaticamente il gruppo \"Utente\".",
|
||||||
"LabelOpenIDGroupClaimDescription": "Nome dell'attestazione OpenID che contiene un elenco dei gruppi dell'utente. Comunemente indicato come <code>gruppo</code>. <b>se configurato</b>, l'applicazione assegnerà automaticamente i ruoli in base alle appartenenze ai gruppi dell'utente, a condizione che tali gruppi siano denominati \"admin\", \"utente\" o \"ospite\" senza distinzione tra maiuscole e minuscole nell'attestazione. L'attestazione deve contenere un elenco e, se un utente appartiene a più gruppi, l'applicazione assegnerà il ruolo corrispondente al livello di accesso più alto. Se nessun gruppo corrisponde, l'accesso verrà negato.",
|
"LabelOpenIDGroupClaimDescription": "Nome dell'attestazione OpenID che contiene un elenco dei gruppi dell'utente. Comunemente indicato come <code>gruppo</code>. <b>se configurato</b>, l'applicazione assegnerà automaticamente i ruoli in base alle appartenenze ai gruppi dell'utente, a condizione che tali gruppi siano denominati \"admin\", \"utente\" o \"ospite\" senza distinzione tra maiuscole e minuscole nell'attestazione. L'attestazione deve contenere un elenco e, se un utente appartiene a più gruppi, l'applicazione assegnerà il ruolo corrispondente al livello di accesso più alto. Se nessun gruppo corrisponde, l'accesso verrà negato.",
|
||||||
"LabelOpenRSSFeed": "Apri RSS Feed",
|
"LabelOpenRSSFeed": "Apri RSS Feed",
|
||||||
@@ -530,7 +530,7 @@
|
|||||||
"LabelPrimaryEbook": "Libro principale",
|
"LabelPrimaryEbook": "Libro principale",
|
||||||
"LabelProgress": "Cominciati",
|
"LabelProgress": "Cominciati",
|
||||||
"LabelProvider": "Fornitore",
|
"LabelProvider": "Fornitore",
|
||||||
"LabelProviderAuthorizationValue": "Authorization Header Value",
|
"LabelProviderAuthorizationValue": "Valore intestazione di autorizzazione",
|
||||||
"LabelPubDate": "Data di pubblicazione",
|
"LabelPubDate": "Data di pubblicazione",
|
||||||
"LabelPublishYear": "Anno di pubblicazione",
|
"LabelPublishYear": "Anno di pubblicazione",
|
||||||
"LabelPublishedDate": "Pubblicati {0}",
|
"LabelPublishedDate": "Pubblicati {0}",
|
||||||
@@ -674,7 +674,7 @@
|
|||||||
"LabelTimeDurationXMinutes": "{0} minuti",
|
"LabelTimeDurationXMinutes": "{0} minuti",
|
||||||
"LabelTimeDurationXSeconds": "{0} secondi",
|
"LabelTimeDurationXSeconds": "{0} secondi",
|
||||||
"LabelTimeInMinutes": "Tempo in minuti",
|
"LabelTimeInMinutes": "Tempo in minuti",
|
||||||
"LabelTimeLeft": "{0} sinistra",
|
"LabelTimeLeft": "{0} rimasti",
|
||||||
"LabelTimeListened": "Tempo di Ascolto",
|
"LabelTimeListened": "Tempo di Ascolto",
|
||||||
"LabelTimeListenedToday": "Tempo di Ascolto Oggi",
|
"LabelTimeListenedToday": "Tempo di Ascolto Oggi",
|
||||||
"LabelTimeRemaining": "{0} rimanente",
|
"LabelTimeRemaining": "{0} rimanente",
|
||||||
@@ -682,7 +682,7 @@
|
|||||||
"LabelTitle": "Titolo",
|
"LabelTitle": "Titolo",
|
||||||
"LabelToolsEmbedMetadata": "Incorpora Metadata",
|
"LabelToolsEmbedMetadata": "Incorpora Metadata",
|
||||||
"LabelToolsEmbedMetadataDescription": "Incorpora i metadati nei file audio, inclusi l'immagine di copertina e i capitoli.",
|
"LabelToolsEmbedMetadataDescription": "Incorpora i metadati nei file audio, inclusi l'immagine di copertina e i capitoli.",
|
||||||
"LabelToolsM4bEncoder": "M4B Encoder",
|
"LabelToolsM4bEncoder": "Codificatore M4B",
|
||||||
"LabelToolsMakeM4b": "Crea un file M4B",
|
"LabelToolsMakeM4b": "Crea un file M4B",
|
||||||
"LabelToolsMakeM4bDescription": "Genera un file audiolibro M4B con metadati incorporati, immagine di copertina e capitoli.",
|
"LabelToolsMakeM4bDescription": "Genera un file audiolibro M4B con metadati incorporati, immagine di copertina e capitoli.",
|
||||||
"LabelToolsSplitM4b": "Converti M4B in MP3",
|
"LabelToolsSplitM4b": "Converti M4B in MP3",
|
||||||
@@ -854,7 +854,7 @@
|
|||||||
"MessageNoItems": "Nessun oggetto",
|
"MessageNoItems": "Nessun oggetto",
|
||||||
"MessageNoItemsFound": "Nessun oggetto trovato",
|
"MessageNoItemsFound": "Nessun oggetto trovato",
|
||||||
"MessageNoListeningSessions": "Nessuna sessione di ascolto",
|
"MessageNoListeningSessions": "Nessuna sessione di ascolto",
|
||||||
"MessageNoLogs": "Nessun Log",
|
"MessageNoLogs": "Nessun rapporto",
|
||||||
"MessageNoMediaProgress": "Nessun progresso multimediale",
|
"MessageNoMediaProgress": "Nessun progresso multimediale",
|
||||||
"MessageNoNotifications": "Nessuna notifica",
|
"MessageNoNotifications": "Nessuna notifica",
|
||||||
"MessageNoPodcastFeed": "Podcast non valido: nessun feed",
|
"MessageNoPodcastFeed": "Podcast non valido: nessun feed",
|
||||||
@@ -1109,7 +1109,7 @@
|
|||||||
"ToastProgressIsNotBeingSynced": "L'avanzamento non è sincronizzato, riavviare la riproduzione",
|
"ToastProgressIsNotBeingSynced": "L'avanzamento non è sincronizzato, riavviare la riproduzione",
|
||||||
"ToastProviderCreatedFailed": "Impossibile aggiungere il provider",
|
"ToastProviderCreatedFailed": "Impossibile aggiungere il provider",
|
||||||
"ToastProviderCreatedSuccess": "Aggiunto nuovo provider",
|
"ToastProviderCreatedSuccess": "Aggiunto nuovo provider",
|
||||||
"ToastProviderNameAndUrlRequired": "Nome e URL richiesti",
|
"ToastProviderNameAndUrlRequired": "Nome e Url richiesti",
|
||||||
"ToastProviderRemoveSuccess": "Provider rimosso",
|
"ToastProviderRemoveSuccess": "Provider rimosso",
|
||||||
"ToastRSSFeedCloseFailed": "Errore chiusura flusso RSS",
|
"ToastRSSFeedCloseFailed": "Errore chiusura flusso RSS",
|
||||||
"ToastRSSFeedCloseSuccess": "Flusso RSS chiuso",
|
"ToastRSSFeedCloseSuccess": "Flusso RSS chiuso",
|
||||||
|
|||||||
+930
-36
File diff suppressed because it is too large
Load Diff
+12
-10
@@ -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",
|
||||||
|
|||||||
@@ -392,7 +392,7 @@
|
|||||||
"LabelGenre": "Жанр",
|
"LabelGenre": "Жанр",
|
||||||
"LabelGenres": "Жанры",
|
"LabelGenres": "Жанры",
|
||||||
"LabelHardDeleteFile": "Жесткое удаление файла",
|
"LabelHardDeleteFile": "Жесткое удаление файла",
|
||||||
"LabelHasEbook": "Есть e-книга",
|
"LabelHasEbook": "Есть электронная книга",
|
||||||
"LabelHasSupplementaryEbook": "Есть дополнительная e-книга",
|
"LabelHasSupplementaryEbook": "Есть дополнительная e-книга",
|
||||||
"LabelHideSubtitles": "Скрыть серии",
|
"LabelHideSubtitles": "Скрыть серии",
|
||||||
"LabelHighestPriority": "Наивысший приоритет",
|
"LabelHighestPriority": "Наивысший приоритет",
|
||||||
@@ -437,8 +437,8 @@
|
|||||||
"LabelLibraryItem": "Элемент библиотеки",
|
"LabelLibraryItem": "Элемент библиотеки",
|
||||||
"LabelLibraryName": "Имя библиотеки",
|
"LabelLibraryName": "Имя библиотеки",
|
||||||
"LabelLibrarySortByProgress": "Прогресс: Последнее обновление",
|
"LabelLibrarySortByProgress": "Прогресс: Последнее обновление",
|
||||||
"LabelLibrarySortByProgressFinished": "Прогресс: Завершено",
|
"LabelLibrarySortByProgressFinished": "Прогресс: Закончена",
|
||||||
"LabelLibrarySortByProgressStarted": "Прогресс: Начато",
|
"LabelLibrarySortByProgressStarted": "Прогресс: Начата",
|
||||||
"LabelLimit": "Лимит",
|
"LabelLimit": "Лимит",
|
||||||
"LabelLineSpacing": "Межстрочный интервал",
|
"LabelLineSpacing": "Межстрочный интервал",
|
||||||
"LabelListenAgain": "Послушать снова",
|
"LabelListenAgain": "Послушать снова",
|
||||||
|
|||||||
@@ -275,7 +275,7 @@
|
|||||||
"LabelBonus": "Bonus",
|
"LabelBonus": "Bonus",
|
||||||
"LabelBooks": "Knihy",
|
"LabelBooks": "Knihy",
|
||||||
"LabelButtonText": "Text tlačidla",
|
"LabelButtonText": "Text tlačidla",
|
||||||
"LabelByAuthor": "od",
|
"LabelByAuthor": "od {0}",
|
||||||
"LabelChangePassword": "Zmeniť heslo",
|
"LabelChangePassword": "Zmeniť heslo",
|
||||||
"LabelChannels": "Kanály",
|
"LabelChannels": "Kanály",
|
||||||
"LabelChapterCount": "{0} kapitol",
|
"LabelChapterCount": "{0} kapitol",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.33.1",
|
"version": "2.34.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.33.1",
|
"version": "2.34.0",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.33.1",
|
"version": "2.34.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",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const CacheManager = require('../managers/CacheManager')
|
|||||||
const CoverManager = require('../managers/CoverManager')
|
const CoverManager = require('../managers/CoverManager')
|
||||||
const AuthorFinder = require('../finders/AuthorFinder')
|
const AuthorFinder = require('../finders/AuthorFinder')
|
||||||
|
|
||||||
const { reqSupportsWebp, isValidASIN } = require('../utils/index')
|
const { reqSupportsWebp, isValidASIN, clampPositiveInt } = require('../utils/index')
|
||||||
|
|
||||||
const naturalSort = createNewSortInstance({
|
const naturalSort = createNewSortInstance({
|
||||||
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
|
||||||
@@ -412,8 +412,8 @@ class AuthorController {
|
|||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
|
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
|
||||||
height: height ? parseInt(height) : null,
|
height: clampPositiveInt(height ? parseInt(height) : null, 4096),
|
||||||
width: width ? parseInt(width) : null
|
width: clampPositiveInt(width ? parseInt(width) : null, 4096)
|
||||||
}
|
}
|
||||||
return CacheManager.handleAuthorCache(res, authorId, options)
|
return CacheManager.handleAuthorCache(res, authorId, options)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ class FileSystemController {
|
|||||||
filepath = fileUtils.filePathToPOSIX(filepath)
|
filepath = fileUtils.filePathToPOSIX(filepath)
|
||||||
|
|
||||||
// Ensure filepath is inside library folder (prevents directory traversal)
|
// Ensure filepath is inside library folder (prevents directory traversal)
|
||||||
if (!filepath.startsWith(libraryFolder.path)) {
|
if (!fileUtils.isSameOrSubPath(libraryFolder.path, filepath)) {
|
||||||
Logger.error(`[FileSystemController] Filepath is not inside library folder: ${filepath}`)
|
Logger.error(`[FileSystemController] Filepath is not inside library folder: ${filepath}`)
|
||||||
return res.sendStatus(400)
|
return res.sendStatus(400)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ class LibraryController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`)
|
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`)
|
||||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authorIds.length) {
|
if (authorIds.length) {
|
||||||
@@ -563,7 +563,7 @@ class LibraryController {
|
|||||||
mediaItemIds.push(libraryItem.mediaId)
|
mediaItemIds.push(libraryItem.mediaId)
|
||||||
}
|
}
|
||||||
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`)
|
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`)
|
||||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set PlaybackSessions libraryId to null
|
// Set PlaybackSessions libraryId to null
|
||||||
@@ -714,7 +714,7 @@ class LibraryController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`)
|
Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`)
|
||||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, req.library.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authorIds.length) {
|
if (authorIds.length) {
|
||||||
@@ -1435,10 +1435,15 @@ class LibraryController {
|
|||||||
const libraryItems = await Database.libraryItemModel.findAll({
|
const libraryItems = await Database.libraryItemModel.findAll({
|
||||||
attributes: ['id', 'libraryId', 'path', 'isFile'],
|
attributes: ['id', 'libraryId', 'path', 'isFile'],
|
||||||
where: {
|
where: {
|
||||||
id: itemIds
|
id: itemIds,
|
||||||
|
libraryId: req.library.id
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (libraryItems.length < itemIds.length) {
|
||||||
|
Logger.warn(`[LibraryController] User "${req.user.username}" requested ${itemIds.length} items but only ${libraryItems.length} are in library "${req.library.id}"`)
|
||||||
|
}
|
||||||
|
|
||||||
Logger.info(`[LibraryController] User "${req.user.username}" requested download for items "${itemIds}"`)
|
Logger.info(`[LibraryController] User "${req.user.username}" requested download for items "${itemIds}"`)
|
||||||
|
|
||||||
const filename = `LibraryItems-${Date.now()}.zip`
|
const filename = `LibraryItems-${Date.now()}.zip`
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
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')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
|
|
||||||
const zipHelpers = require('../utils/zipHelpers')
|
const zipHelpers = require('../utils/zipHelpers')
|
||||||
const { reqSupportsWebp } = require('../utils/index')
|
const { reqSupportsWebp, clampPositiveInt } = require('../utils/index')
|
||||||
const { ScanResult, AudioMimeType } = require('../utils/constants')
|
const { ScanResult, AudioMimeType } = require('../utils/constants')
|
||||||
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
|
const { getAudioMimeTypeFromExtname, encodeUriPath } = require('../utils/fileUtils')
|
||||||
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
|
const LibraryItemScanner = require('../scanner/LibraryItemScanner')
|
||||||
@@ -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() {}
|
||||||
|
|
||||||
@@ -111,7 +130,7 @@ class LibraryItemController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds)
|
await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds, req.libraryItem.libraryId)
|
||||||
if (hardDelete) {
|
if (hardDelete) {
|
||||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||||
await fs.remove(libraryItemPath).catch((error) => {
|
await fs.remove(libraryItemPath).catch((error) => {
|
||||||
@@ -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
|
||||||
@@ -398,8 +422,8 @@ class LibraryItemController {
|
|||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
|
format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'),
|
||||||
height: height ? parseInt(height) : null,
|
height: clampPositiveInt(height ? parseInt(height) : null, 4096),
|
||||||
width: width ? parseInt(width) : null
|
width: clampPositiveInt(width ? parseInt(width) : null, 4096)
|
||||||
}
|
}
|
||||||
return CacheManager.handleCoverCache(res, libraryItemId, options)
|
return CacheManager.handleCoverCache(res, libraryItemId, options)
|
||||||
}
|
}
|
||||||
@@ -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}"`)
|
||||||
@@ -565,7 +595,7 @@ class LibraryItemController {
|
|||||||
authorIds.push(...libraryItem.media.authors.map((au) => au.id))
|
authorIds.push(...libraryItem.media.authors.map((au) => au.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
|
await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds, libraryItem.libraryId)
|
||||||
if (hardDelete) {
|
if (hardDelete) {
|
||||||
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
|
||||||
await fs.remove(libraryItemPath).catch((error) => {
|
await fs.remove(libraryItemPath).catch((error) => {
|
||||||
@@ -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())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ 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 } = require('../utils/fileUtils')
|
const { getFileTimestampsWithIno, filePathToPOSIX, isSameOrSubPath } = require('../utils/fileUtils')
|
||||||
const { validateUrl } = require('../utils/index')
|
const { validateUrl } = require('../utils/index')
|
||||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||||
|
|
||||||
@@ -46,6 +47,11 @@ class PodcastController {
|
|||||||
return res.status(400).send('Invalid request body. "media" and "media.metadata" are required')
|
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}"`)
|
||||||
@@ -58,8 +64,18 @@ class PodcastController {
|
|||||||
return res.status(404).send('Folder not found')
|
return res.status(404).send('Folder not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof payload.path !== 'string' || !payload.path.trim()) {
|
||||||
|
return res.status(400).send('Invalid request body. "path" must be a non-empty string')
|
||||||
|
}
|
||||||
|
|
||||||
|
const libraryFolderPath = filePathToPOSIX(folder.path)
|
||||||
const podcastPath = filePathToPOSIX(payload.path)
|
const podcastPath = filePathToPOSIX(payload.path)
|
||||||
|
|
||||||
|
if (!isSameOrSubPath(libraryFolderPath, podcastPath)) {
|
||||||
|
Logger.error(`[PodcastController] Create: Podcast path is outside library folder "${libraryFolderPath}": "${podcastPath}"`)
|
||||||
|
return res.status(400).send('Podcast path must be inside the selected library folder')
|
||||||
|
}
|
||||||
|
|
||||||
// Check if a library item with this podcast folder exists already
|
// Check if a library item with this podcast folder exists already
|
||||||
const existingLibraryItem =
|
const existingLibraryItem =
|
||||||
(await Database.libraryItemModel.count({
|
(await Database.libraryItemModel.count({
|
||||||
@@ -83,7 +99,7 @@ class PodcastController {
|
|||||||
|
|
||||||
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
|
const libraryItemFolderStats = await getFileTimestampsWithIno(podcastPath)
|
||||||
|
|
||||||
let relPath = payload.path.replace(folder.fullPath, '')
|
let relPath = podcastPath.replace(libraryFolderPath, '')
|
||||||
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||||
|
|
||||||
let newLibraryItem = null
|
let newLibraryItem = null
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -126,13 +126,31 @@ class BackupManager {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Not a valid zip file
|
// Not a valid zip file
|
||||||
Logger.error('[BackupManager] Failed to read backup file - backup might not be a valid .zip file', tempPath, error)
|
Logger.error('[BackupManager] Failed to read backup file - backup might not be a valid .zip file', tempPath, error)
|
||||||
|
await zip.close().catch(() => {})
|
||||||
|
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
|
||||||
return res.status(400).send('Failed to read backup file - backup might not be a valid .zip file')
|
return res.status(400).send('Failed to read backup file - backup might not be a valid .zip file')
|
||||||
}
|
}
|
||||||
if (!Object.keys(entries).includes('absdatabase.sqlite')) {
|
if (!entries['absdatabase.sqlite']) {
|
||||||
Logger.error(`[BackupManager] Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.`)
|
Logger.error(`[BackupManager] Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.`)
|
||||||
|
await zip.close().catch(() => {})
|
||||||
|
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
|
||||||
return res.status(500).send('Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.')
|
return res.status(500).send('Invalid backup with no absdatabase.sqlite file - might be a backup created on an old Audiobookshelf server.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const detailsEntry = entries['details']
|
||||||
|
if (!detailsEntry) {
|
||||||
|
Logger.error('[BackupManager] Invalid backup - missing details entry')
|
||||||
|
await zip.close().catch(() => {})
|
||||||
|
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
|
||||||
|
return res.status(400).send('Invalid backup file - missing details entry')
|
||||||
|
}
|
||||||
|
if (detailsEntry.size > 1024 * 1024) {
|
||||||
|
Logger.error(`[BackupManager] Backup details entry too large: ${detailsEntry.size} bytes`)
|
||||||
|
await zip.close().catch(() => {})
|
||||||
|
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
|
||||||
|
return res.status(400).send('Invalid backup file - details entry too large')
|
||||||
|
}
|
||||||
|
|
||||||
const data = await zip.entryData('details')
|
const data = await zip.entryData('details')
|
||||||
const details = data.toString('utf8').split('\n')
|
const details = data.toString('utf8').split('\n')
|
||||||
|
|
||||||
@@ -140,9 +158,13 @@ class BackupManager {
|
|||||||
|
|
||||||
if (!backup.serverVersion) {
|
if (!backup.serverVersion) {
|
||||||
Logger.error(`[BackupManager] Invalid backup with no server version - might be a backup created before version 2.0.0`)
|
Logger.error(`[BackupManager] Invalid backup with no server version - might be a backup created before version 2.0.0`)
|
||||||
|
await zip.close().catch(() => {})
|
||||||
|
await fs.remove(tempPath).catch((err) => Logger.error(`[BackupManager] Failed to remove rejected backup file "${tempPath}"`, err))
|
||||||
return res.status(500).send('Invalid backup. Might be a backup created before version 2.0.0.')
|
return res.status(500).send('Invalid backup. Might be a backup created before version 2.0.0.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await zip.close().catch(() => {})
|
||||||
|
|
||||||
backup.fileSize = await getFileSize(backup.fullPath)
|
backup.fileSize = await getFileSize(backup.fullPath)
|
||||||
|
|
||||||
const existingBackupIndex = this.backups.findIndex((b) => b.id === backup.id)
|
const existingBackupIndex = this.backups.findIndex((b) => b.id === backup.id)
|
||||||
@@ -257,9 +279,24 @@ class BackupManager {
|
|||||||
let data = null
|
let data = null
|
||||||
try {
|
try {
|
||||||
zip = new StreamZip.async({ file: fullFilePath })
|
zip = new StreamZip.async({ file: fullFilePath })
|
||||||
|
const entries = await zip.entries()
|
||||||
|
|
||||||
|
const detailsEntry = entries['details']
|
||||||
|
if (!detailsEntry) {
|
||||||
|
Logger.error(`[BackupManager] Backup "${fullFilePath}" missing details entry - skipping`)
|
||||||
|
await zip.close().catch(() => {})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (detailsEntry.size > 1024 * 1024) {
|
||||||
|
Logger.error(`[BackupManager] Backup "${fullFilePath}" details entry too large (${detailsEntry.size} bytes) - skipping`)
|
||||||
|
await zip.close().catch(() => {})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
data = await zip.entryData('details')
|
data = await zip.entryData('details')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`[BackupManager] Failed to unzip backup "${fullFilePath}"`, error)
|
Logger.error(`[BackupManager] Failed to unzip backup "${fullFilePath}"`, error)
|
||||||
|
if (zip) await zip.close().catch(() => {})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,16 +111,17 @@ class Author extends Model {
|
|||||||
*
|
*
|
||||||
* @param {string} name
|
* @param {string} name
|
||||||
* @param {string} libraryId
|
* @param {string} libraryId
|
||||||
* @returns {Promise<Author>}
|
* @returns {Promise<{ author: Author, created: boolean }>}
|
||||||
*/
|
*/
|
||||||
static async findOrCreateByNameAndLibrary(name, libraryId) {
|
static async findOrCreateByNameAndLibrary(name, libraryId) {
|
||||||
const author = await this.getByNameAndLibrary(name, libraryId)
|
const author = await this.getByNameAndLibrary(name, libraryId)
|
||||||
if (author) return author
|
if (author) return { author, created: false }
|
||||||
return this.create({
|
const newAuthor = await this.create({
|
||||||
name,
|
name,
|
||||||
lastFirst: this.getLastFirst(name),
|
lastFirst: this.getLastFirst(name),
|
||||||
libraryId
|
libraryId
|
||||||
})
|
})
|
||||||
|
return { author: newAuthor, created: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+12
-1
@@ -4,6 +4,7 @@ const { getTitlePrefixAtEnd, getTitleIgnorePrefix } = require('../utils')
|
|||||||
const parseNameString = require('../utils/parsers/parseNameString')
|
const parseNameString = require('../utils/parsers/parseNameString')
|
||||||
const htmlSanitizer = require('../utils/htmlSanitizer')
|
const htmlSanitizer = require('../utils/htmlSanitizer')
|
||||||
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
|
||||||
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef EBookFileObject
|
* @typedef EBookFileObject
|
||||||
@@ -470,13 +471,23 @@ class Book extends Model {
|
|||||||
|
|
||||||
for (const author of authorsRemoved) {
|
for (const author of authorsRemoved) {
|
||||||
await bookAuthorModel.removeByIds(author.id, this.id)
|
await bookAuthorModel.removeByIds(author.id, this.id)
|
||||||
|
const numBooks = await bookAuthorModel.getCountForAuthor(author.id)
|
||||||
|
if (numBooks > 0) {
|
||||||
|
SocketAuthority.emitter('author_updated', author.toOldJSONExpanded(numBooks))
|
||||||
|
}
|
||||||
Logger.debug(`[Book] "${this.title}" Removed author "${author.name}"`)
|
Logger.debug(`[Book] "${this.title}" Removed author "${author.name}"`)
|
||||||
this.authors = this.authors.filter((au) => au.id !== author.id)
|
this.authors = this.authors.filter((au) => au.id !== author.id)
|
||||||
}
|
}
|
||||||
const authorsAdded = []
|
const authorsAdded = []
|
||||||
for (const authorName of newAuthorNames) {
|
for (const authorName of newAuthorNames) {
|
||||||
const author = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId)
|
const { author, created } = await authorModel.findOrCreateByNameAndLibrary(authorName, libraryId)
|
||||||
await bookAuthorModel.create({ bookId: this.id, authorId: author.id })
|
await bookAuthorModel.create({ bookId: this.id, authorId: author.id })
|
||||||
|
if (created) {
|
||||||
|
SocketAuthority.emitter('author_added', author.toOldJSON())
|
||||||
|
} else {
|
||||||
|
const numBooks = await bookAuthorModel.getCountForAuthor(author.id)
|
||||||
|
SocketAuthority.emitter('author_updated', author.toOldJSONExpanded(numBooks))
|
||||||
|
}
|
||||||
Logger.debug(`[Book] "${this.title}" Added author "${author.name}"`)
|
Logger.debug(`[Book] "${this.title}" Added author "${author.name}"`)
|
||||||
this.authors.push(author)
|
this.authors.push(author)
|
||||||
authorsAdded.push(author)
|
authorsAdded.push(author)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,8 @@ class PlaybackSession {
|
|||||||
startedAt: this.startedAt,
|
startedAt: this.startedAt,
|
||||||
updatedAt: this.updatedAt,
|
updatedAt: this.updatedAt,
|
||||||
audioTracks: this.audioTracks.map((at) => at.toJSON?.() || { ...at }),
|
audioTracks: this.audioTracks.map((at) => at.toJSON?.() || { ...at }),
|
||||||
libraryItem: libraryItem?.toOldJSONExpanded() || null
|
libraryItem: libraryItem?.toOldJSONExpanded() || null,
|
||||||
|
coverAspectRatio: this.coverAspectRatio !== null ? this.coverAspectRatio : undefined // Used for share sessions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class Stream extends EventEmitter {
|
|||||||
return [AudioMimeType.FLAC, AudioMimeType.OPUS, AudioMimeType.WMA, AudioMimeType.AIFF, AudioMimeType.WEBM, AudioMimeType.WEBMA, AudioMimeType.AWB, AudioMimeType.CAF]
|
return [AudioMimeType.FLAC, AudioMimeType.OPUS, AudioMimeType.WMA, AudioMimeType.AIFF, AudioMimeType.WEBM, AudioMimeType.WEBMA, AudioMimeType.AWB, AudioMimeType.CAF]
|
||||||
}
|
}
|
||||||
get codecsToForceAAC() {
|
get codecsToForceAAC() {
|
||||||
return ['alac', 'ac3', 'eac3']
|
return ['alac', 'ac3', 'eac3', 'opus']
|
||||||
}
|
}
|
||||||
get userToken() {
|
get userToken() {
|
||||||
return this.user.token
|
return this.user.token
|
||||||
|
|||||||
@@ -363,8 +363,9 @@ class ApiRouter {
|
|||||||
* Remove library item and associated entities
|
* Remove library item and associated entities
|
||||||
* @param {string} libraryItemId
|
* @param {string} libraryItemId
|
||||||
* @param {string[]} mediaItemIds array of bookId or podcastEpisodeId
|
* @param {string[]} mediaItemIds array of bookId or podcastEpisodeId
|
||||||
|
* @param {string} libraryId
|
||||||
*/
|
*/
|
||||||
async handleDeleteLibraryItem(libraryItemId, mediaItemIds) {
|
async handleDeleteLibraryItem(libraryItemId, mediaItemIds, libraryId) {
|
||||||
const numProgressRemoved = await Database.mediaProgressModel.destroy({
|
const numProgressRemoved = await Database.mediaProgressModel.destroy({
|
||||||
where: {
|
where: {
|
||||||
mediaItemId: mediaItemIds
|
mediaItemId: mediaItemIds
|
||||||
@@ -395,7 +396,8 @@ class ApiRouter {
|
|||||||
await Database.libraryItemModel.removeById(libraryItemId)
|
await Database.libraryItemModel.removeById(libraryItemId)
|
||||||
|
|
||||||
SocketAuthority.emitter('item_removed', {
|
SocketAuthority.emitter('item_removed', {
|
||||||
id: libraryItemId
|
id: libraryItemId,
|
||||||
|
libraryId
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ module.exports.AudioMimeType = {
|
|||||||
AIF: 'audio/x-aiff',
|
AIF: 'audio/x-aiff',
|
||||||
WEBM: 'audio/webm',
|
WEBM: 'audio/webm',
|
||||||
WEBMA: 'audio/webm',
|
WEBMA: 'audio/webm',
|
||||||
|
// TODO: Switch to `audio/matroska`? marked as deprecated in IANA registry
|
||||||
|
// ref: https://datatracker.ietf.org/doc/html/rfc9559
|
||||||
MKA: 'audio/x-matroska',
|
MKA: 'audio/x-matroska',
|
||||||
AWB: 'audio/amr-wb',
|
AWB: 'audio/amr-wb',
|
||||||
CAF: 'audio/x-caf',
|
CAF: 'audio/x-caf',
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ module.exports.isNullOrNaN = (num) => {
|
|||||||
return num === null || isNaN(num)
|
return num === null || isNaN(num)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number|null|undefined} value
|
||||||
|
* @param {number} max
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
module.exports.clampPositiveInt = (value, max) => {
|
||||||
|
if (value == null || !Number.isFinite(value) || value <= 0) return null
|
||||||
|
return Math.min(Math.floor(value), max)
|
||||||
|
}
|
||||||
|
|
||||||
const xmlToJSON = (xml) => {
|
const xmlToJSON = (xml) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
parseString(xml, (err, results) => {
|
parseString(xml, (err, results) => {
|
||||||
|
|||||||
@@ -217,6 +217,10 @@ function extractEpisodeData(item) {
|
|||||||
episode[cleanKey] = extractFirstArrayItemString(item, key)
|
episode[cleanKey] = extractFirstArrayItemString(item, key)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (episode.subtitle) {
|
||||||
|
episode.subtitle = htmlSanitizer.sanitize(episode.subtitle.trim())
|
||||||
|
}
|
||||||
|
|
||||||
// Extract psc:chapters if duration is set
|
// Extract psc:chapters if duration is set
|
||||||
episode.durationSeconds = episode.duration ? timestampToSeconds(episode.duration) : null
|
episode.durationSeconds = episode.duration ? timestampToSeconds(episode.duration) : null
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user