mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-05-18 01:11:30 +00:00
Merge branch 'advplyr:master' into auto-generate-chapters-from-timestamps
This commit is contained in:
commit
95fb522e8d
26 changed files with 1202 additions and 87 deletions
|
|
@ -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 {}
|
||||||
|
|
|
||||||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.33.2",
|
"version": "2.34.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.33.2",
|
"version": "2.34.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxtjs/axios": "^5.13.6",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "2.33.2",
|
"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))
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"ButtonAdd": "Дадаць",
|
"ButtonAdd": "Дадаць",
|
||||||
"ButtonAddApiKey": "Дадаць API-ключ",
|
"ButtonAddApiKey": "Дадаць ключ API",
|
||||||
"ButtonAddChapters": "Дадаць раздзелы",
|
"ButtonAddChapters": "Дадаць раздзелы",
|
||||||
"ButtonAddDevice": "Дадаць прыладу",
|
"ButtonAddDevice": "Дадаць прыладу",
|
||||||
"ButtonAddLibrary": "Дадаць бібліятэку",
|
"ButtonAddLibrary": "Дадаць бібліятэку",
|
||||||
|
|
@ -121,7 +121,7 @@
|
||||||
"HeaderAccount": "Уліковы запіс",
|
"HeaderAccount": "Уліковы запіс",
|
||||||
"HeaderAddCustomMetadataProvider": "Дадаванне карыстальніцкага пастаўшчыка метаданых",
|
"HeaderAddCustomMetadataProvider": "Дадаванне карыстальніцкага пастаўшчыка метаданых",
|
||||||
"HeaderAdvanced": "Дадаткова",
|
"HeaderAdvanced": "Дадаткова",
|
||||||
"HeaderApiKeys": "API-ключы",
|
"HeaderApiKeys": "Ключы API",
|
||||||
"HeaderAppriseNotificationSettings": "Налады апавяшчэнняў Apprise",
|
"HeaderAppriseNotificationSettings": "Налады апавяшчэнняў Apprise",
|
||||||
"HeaderAudioTracks": "Аўдыятрэкі",
|
"HeaderAudioTracks": "Аўдыятрэкі",
|
||||||
"HeaderAudiobookTools": "Сродкі кіравання файламі аўдыякніг",
|
"HeaderAudiobookTools": "Сродкі кіравання файламі аўдыякніг",
|
||||||
|
|
@ -166,7 +166,7 @@
|
||||||
"HeaderMetadataOrderOfPrecedence": "Парадак прыярытэту метаданых",
|
"HeaderMetadataOrderOfPrecedence": "Парадак прыярытэту метаданых",
|
||||||
"HeaderMetadataToEmbed": "Метаданыя для ўбудавання",
|
"HeaderMetadataToEmbed": "Метаданыя для ўбудавання",
|
||||||
"HeaderNewAccount": "Новы ўліковы запіс",
|
"HeaderNewAccount": "Новы ўліковы запіс",
|
||||||
"HeaderNewApiKey": "Новы API-ключ",
|
"HeaderNewApiKey": "Новы ключ API",
|
||||||
"HeaderNewLibrary": "Новая бібліятэка",
|
"HeaderNewLibrary": "Новая бібліятэка",
|
||||||
"HeaderNotificationCreate": "Стварыць апавяшчэнне",
|
"HeaderNotificationCreate": "Стварыць апавяшчэнне",
|
||||||
"HeaderNotificationUpdate": "Абнавіць апавяшчэнне",
|
"HeaderNotificationUpdate": "Абнавіць апавяшчэнне",
|
||||||
|
|
@ -212,7 +212,7 @@
|
||||||
"HeaderTableOfContents": "Змест",
|
"HeaderTableOfContents": "Змест",
|
||||||
"HeaderTools": "Інструменты",
|
"HeaderTools": "Інструменты",
|
||||||
"HeaderUpdateAccount": "Абнавіць уліковы запіс",
|
"HeaderUpdateAccount": "Абнавіць уліковы запіс",
|
||||||
"HeaderUpdateApiKey": "Абнавіць API-ключ",
|
"HeaderUpdateApiKey": "Абнавіць ключ API",
|
||||||
"HeaderUpdateAuthor": "Абнавіць аўтара",
|
"HeaderUpdateAuthor": "Абнавіць аўтара",
|
||||||
"HeaderUpdateDetails": "Абнавіць падрабязнасці",
|
"HeaderUpdateDetails": "Абнавіць падрабязнасці",
|
||||||
"HeaderUpdateLibrary": "Абнавіць бібліятэку",
|
"HeaderUpdateLibrary": "Абнавіць бібліятэку",
|
||||||
|
|
@ -242,10 +242,10 @@
|
||||||
"LabelAllUsersExcludingGuests": "Усіх карыстальнікаў, акрамя гасцей",
|
"LabelAllUsersExcludingGuests": "Усіх карыстальнікаў, акрамя гасцей",
|
||||||
"LabelAllUsersIncludingGuests": "Усіх карыстальнікаў, уключаючы гасцей",
|
"LabelAllUsersIncludingGuests": "Усіх карыстальнікаў, уключаючы гасцей",
|
||||||
"LabelAlreadyInYourLibrary": "Ужо ў вашай бібліятэцы",
|
"LabelAlreadyInYourLibrary": "Ужо ў вашай бібліятэцы",
|
||||||
"LabelApiKeyCreated": "API-ключ \"{0}\" паспяхова створаны.",
|
"LabelApiKeyCreated": "Ключ API \"{0}\" паспяхова створаны.",
|
||||||
"LabelApiKeyCreatedDescription": "Пераканайцеся, што вы скапіявалі API-ключ зараз, бо паўторна яго ўбачыць не атрымаецца.",
|
"LabelApiKeyCreatedDescription": "Абавязкова скапіюйце ключ API зараз, бо паўторна яго ўбачыць не атрымаецца.",
|
||||||
"LabelApiKeyUser": "Дзейнічаць ад імя карыстальніка",
|
"LabelApiKeyUser": "Дзейнічаць ад імя карыстальніка",
|
||||||
"LabelApiKeyUserDescription": "Гэты API-ключ будзе мець тыя ж правы, што і карыстальнік, ад імя якога ён дзейнічае. У журналах гэта будзе выглядаць так, быццам запыт робіць сам карыстальнік.",
|
"LabelApiKeyUserDescription": "Гэты ключ API будзе мець тыя ж правы, што і карыстальнік, ад імя якога ён дзейнічае. У журналах гэта будзе выглядаць так, быццам запыт робіць сам карыстальнік.",
|
||||||
"LabelApiToken": "Токен API",
|
"LabelApiToken": "Токен API",
|
||||||
"LabelAppend": "Дадаць",
|
"LabelAppend": "Дадаць",
|
||||||
"LabelAudioBitrate": "Бітрэйт аўдыя (напрыклад, 128к)",
|
"LabelAudioBitrate": "Бітрэйт аўдыя (напрыклад, 128к)",
|
||||||
|
|
@ -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 /> Усе кліенты, якія карыстаюцца вашым серверам, будуць аўтаматычна абноўлены.",
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
"ButtonBrowseForFolder": "Mappa keresése",
|
"ButtonBrowseForFolder": "Mappa keresése",
|
||||||
"ButtonCancel": "Mégse",
|
"ButtonCancel": "Mégse",
|
||||||
"ButtonCancelEncode": "Kódolás megszakítása",
|
"ButtonCancelEncode": "Kódolás megszakítása",
|
||||||
"ButtonChangeRootPassword": "Gyökérjelszó megváltoztatása",
|
"ButtonChangeRootPassword": "Root jelszó megváltoztatása",
|
||||||
"ButtonCheckAndDownloadNewEpisodes": "Új epizódok ellenőrzése és letöltése",
|
"ButtonCheckAndDownloadNewEpisodes": "Új epizódok ellenőrzése és letöltése",
|
||||||
"ButtonChooseAFolder": "Válassz egy mappát",
|
"ButtonChooseAFolder": "Válassz egy mappát",
|
||||||
"ButtonChooseFiles": "Fájlok kiválasztása",
|
"ButtonChooseFiles": "Fájlok kiválasztása",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||||
|
|
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.33.2",
|
"version": "2.34.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.33.2",
|
"version": "2.34.0",
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "2.33.2",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const { Request, Response, NextFunction } = require('express')
|
const { Request, Response, NextFunction } = require('express')
|
||||||
const Path = require('path')
|
const Path = require('path')
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
|
const cron = require('../libs/nodeCron')
|
||||||
const uaParserJs = require('../libs/uaParser')
|
const uaParserJs = require('../libs/uaParser')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const SocketAuthority = require('../SocketAuthority')
|
const SocketAuthority = require('../SocketAuthority')
|
||||||
|
|
@ -36,6 +37,24 @@ const ShareManager = require('../managers/ShareManager')
|
||||||
* @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile
|
* @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce per-item access for batch item routes
|
||||||
|
*
|
||||||
|
* @param {RequestWithUser} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {import('../models/LibraryItem')[]} libraryItems
|
||||||
|
* @returns {boolean} true if the user may access every item; false if 403 was sent
|
||||||
|
*/
|
||||||
|
function ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems) {
|
||||||
|
for (const libraryItem of libraryItems) {
|
||||||
|
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||||
|
res.sendStatus(403)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
class LibraryItemController {
|
class LibraryItemController {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
|
|
@ -202,6 +221,11 @@ class LibraryItemController {
|
||||||
} else if (mediaPayload.autoDownloadSchedule !== undefined && req.libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
|
} else if (mediaPayload.autoDownloadSchedule !== undefined && req.libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
|
||||||
isPodcastAutoDownloadUpdated = true
|
isPodcastAutoDownloadUpdated = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mediaPayload.autoDownloadSchedule && !cron.validate(mediaPayload.autoDownloadSchedule)) {
|
||||||
|
Logger.error(`[LibraryItemController] Invalid auto download schedule cron expression "${mediaPayload.autoDownloadSchedule}" for library item "${req.libraryItem.media.title}"`)
|
||||||
|
return res.status(400).send('Invalid auto download schedule cron expression')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
|
let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
|
||||||
|
|
@ -547,7 +571,13 @@ class LibraryItemController {
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure user has permission to delete these library items
|
||||||
|
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, itemsToDelete)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const libraryId = itemsToDelete[0].libraryId
|
const libraryId = itemsToDelete[0].libraryId
|
||||||
|
|
||||||
for (const libraryItem of itemsToDelete) {
|
for (const libraryItem of itemsToDelete) {
|
||||||
const libraryItemPath = libraryItem.path
|
const libraryItemPath = libraryItem.path
|
||||||
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.title}" with id "${libraryItem.id}"`)
|
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.title}" with id "${libraryItem.id}"`)
|
||||||
|
|
@ -581,6 +611,7 @@ class LibraryItemController {
|
||||||
}
|
}
|
||||||
|
|
||||||
await Database.resetLibraryIssuesFilterData(libraryId)
|
await Database.resetLibraryIssuesFilterData(libraryId)
|
||||||
|
|
||||||
res.sendStatus(200)
|
res.sendStatus(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -593,6 +624,11 @@ class LibraryItemController {
|
||||||
* @param {Response} res
|
* @param {Response} res
|
||||||
*/
|
*/
|
||||||
async batchUpdate(req, res) {
|
async batchUpdate(req, res) {
|
||||||
|
if (!req.user.canUpdate) {
|
||||||
|
Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to batch update without permission`)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
|
||||||
const updatePayloads = req.body
|
const updatePayloads = req.body
|
||||||
if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
|
if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
|
||||||
Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)
|
Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)
|
||||||
|
|
@ -615,6 +651,11 @@ class LibraryItemController {
|
||||||
return res.sendStatus(404)
|
return res.sendStatus(404)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure user has permission to update these library items
|
||||||
|
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
let itemsUpdated = 0
|
let itemsUpdated = 0
|
||||||
|
|
||||||
const seriesIdsRemoved = []
|
const seriesIdsRemoved = []
|
||||||
|
|
@ -624,6 +665,11 @@ class LibraryItemController {
|
||||||
const mediaPayload = updatePayload.mediaPayload
|
const mediaPayload = updatePayload.mediaPayload
|
||||||
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
|
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
|
||||||
|
|
||||||
|
if (libraryItem.isPodcast && mediaPayload.autoDownloadSchedule && !cron.validate(mediaPayload.autoDownloadSchedule)) {
|
||||||
|
Logger.warn(`[LibraryItemController] Invalid auto download schedule cron expression "${mediaPayload.autoDownloadSchedule}" for library item "${libraryItem.media.title}" - skipping update`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
|
let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
|
||||||
|
|
||||||
if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
|
if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
|
||||||
|
|
@ -695,6 +741,10 @@ class LibraryItemController {
|
||||||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
|
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
|
||||||
id: libraryItemIds
|
id: libraryItemIds
|
||||||
})
|
})
|
||||||
|
// Ensure user has permission to access these library items
|
||||||
|
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
res.json({
|
res.json({
|
||||||
libraryItems: libraryItems.map((li) => li.toOldJSONExpanded())
|
libraryItems: libraryItems.map((li) => li.toOldJSONExpanded())
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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,6 +5,7 @@ const SocketAuthority = require('../SocketAuthority')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
|
|
||||||
const fs = require('../libs/fsExtra')
|
const fs = require('../libs/fsExtra')
|
||||||
|
const cron = require('../libs/nodeCron')
|
||||||
|
|
||||||
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
|
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
|
||||||
const { getFileTimestampsWithIno, filePathToPOSIX, isSameOrSubPath } = require('../utils/fileUtils')
|
const { getFileTimestampsWithIno, filePathToPOSIX, isSameOrSubPath } = require('../utils/fileUtils')
|
||||||
|
|
@ -46,6 +47,11 @@ class PodcastController {
|
||||||
return res.status(400).send('Invalid request body. "media" and "media.metadata" are required')
|
return res.status(400).send('Invalid request body. "media" and "media.metadata" are required')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.media.autoDownloadSchedule && !cron.validate(payload.media.autoDownloadSchedule)) {
|
||||||
|
Logger.error(`[PodcastController] Invalid auto download schedule cron expression "${payload.media.autoDownloadSchedule}"`)
|
||||||
|
return res.status(400).send('Invalid auto download schedule cron expression')
|
||||||
|
}
|
||||||
|
|
||||||
const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId)
|
const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId)
|
||||||
if (!library) {
|
if (!library) {
|
||||||
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
|
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue