Merge branch 'advplyr:master' into auto-generate-chapters-from-timestamps

This commit is contained in:
Harry 2026-04-29 12:08:12 +01:00 committed by GitHub
commit 95fb522e8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1202 additions and 87 deletions

View file

@ -158,6 +158,8 @@ export default {
this.isProcessing = true this.isProcessing = true
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => { var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => {
console.error('Failed to update', error) console.error('Failed to update', error)
const errorMessage = typeof error?.response?.data === 'string' ? error?.response?.data : null
this.$toast.error(errorMessage || this.$strings.ToastFailedToUpdate)
return false return false
}) })
this.isProcessing = false this.isProcessing = false

View file

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

View file

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

View file

@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.33.2", "version": "2.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",

View file

@ -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",

View file

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

View file

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

View file

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

View file

@ -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 /> Усе кліенты, якія карыстаюцца вашым серверам, будуць аўтаматычна абноўлены.",

View file

@ -16,7 +16,7 @@
"ButtonBrowseForFolder": "Mappa keresése", "ButtonBrowseForFolder": "Mappa keresése",
"ButtonCancel": "Mégse", "ButtonCancel": "Mégse",
"ButtonCancelEncode": "Kódolás megszakítása", "ButtonCancelEncode": "Kódolás megszakítása",
"ButtonChangeRootPassword": "Gyökérjelszó megváltoztatása", "ButtonChangeRootPassword": "Root jelszó megváltoztatása",
"ButtonCheckAndDownloadNewEpisodes": "Új epizódok ellenőrzése és letöltése", "ButtonCheckAndDownloadNewEpisodes": "Új epizódok ellenőrzése és letöltése",
"ButtonChooseAFolder": "Válassz egy mappát", "ButtonChooseAFolder": "Válassz egy mappát",
"ButtonChooseFiles": "Fájlok kiválasztása", "ButtonChooseFiles": "Fájlok kiválasztása",

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
"ButtonAdd": "Toevoegen", "ButtonAdd": "Toevoegen",
"ButtonAddApiKey": "API Key toevoegen", "ButtonAddApiKey": "API Key toevoegen",
"ButtonAddChapters": "Hoofdstukken toevoegen", "ButtonAddChapters": "Hoofdstukken toevoegen",
"ButtonAddDevice": "Toestel toevoegen", "ButtonAddDevice": "Apparaat toevoegen",
"ButtonAddLibrary": "Bibliotheek toevoegen", "ButtonAddLibrary": "Bibliotheek toevoegen",
"ButtonAddPodcasts": "Podcasts toevoegen", "ButtonAddPodcasts": "Podcasts toevoegen",
"ButtonAddUser": "Gebruiker toevoegen", "ButtonAddUser": "Gebruiker toevoegen",
@ -139,7 +139,7 @@
"HeaderCustomMetadataProviders": "Aangepaste Metadata Providers", "HeaderCustomMetadataProviders": "Aangepaste Metadata Providers",
"HeaderDetails": "Details", "HeaderDetails": "Details",
"HeaderDownloadQueue": "Download-wachtrij", "HeaderDownloadQueue": "Download-wachtrij",
"HeaderEbookFiles": "Ebook bestanden", "HeaderEbookFiles": "E-book bestanden",
"HeaderEmail": "E-mail", "HeaderEmail": "E-mail",
"HeaderEmailSettings": "E-mail instellingen", "HeaderEmailSettings": "E-mail instellingen",
"HeaderEpisodes": "Afleveringen", "HeaderEpisodes": "Afleveringen",
@ -275,7 +275,7 @@
"LabelBonus": "Bonus", "LabelBonus": "Bonus",
"LabelBooks": "Boeken", "LabelBooks": "Boeken",
"LabelButtonText": "Knop Tekst", "LabelButtonText": "Knop Tekst",
"LabelByAuthor": "Door {0}", "LabelByAuthor": "door {0}",
"LabelChangePassword": "Wachtwoord wijzigen", "LabelChangePassword": "Wachtwoord wijzigen",
"LabelChannels": "Kanalen", "LabelChannels": "Kanalen",
"LabelChapterCount": "{0} Hoofdstukken", "LabelChapterCount": "{0} Hoofdstukken",
@ -383,7 +383,7 @@
"LabelFolders": "Mappen", "LabelFolders": "Mappen",
"LabelFontBold": "Vetgedrukt", "LabelFontBold": "Vetgedrukt",
"LabelFontBoldness": "Lettertype Dikte", "LabelFontBoldness": "Lettertype Dikte",
"LabelFontFamily": "Lettertypefamilie", "LabelFontFamily": "Letterfamilie",
"LabelFontItalic": "Cursief", "LabelFontItalic": "Cursief",
"LabelFontScale": "Lettertype schaal", "LabelFontScale": "Lettertype schaal",
"LabelFontStrikethrough": "Doorgestreept", "LabelFontStrikethrough": "Doorgestreept",
@ -436,9 +436,9 @@
"LabelLibraryFilterSublistEmpty": "Nee {0}", "LabelLibraryFilterSublistEmpty": "Nee {0}",
"LabelLibraryItem": "Bibliotheekonderdeel", "LabelLibraryItem": "Bibliotheekonderdeel",
"LabelLibraryName": "Bibliotheeknaam", "LabelLibraryName": "Bibliotheeknaam",
"LabelLibrarySortByProgress": "Voortuigang geüpdatet", "LabelLibrarySortByProgress": "Voortgang: Laatst geüpdatet",
"LabelLibrarySortByProgressFinished": "Datum voltooid", "LabelLibrarySortByProgressFinished": "Voortgang: Voltooid",
"LabelLibrarySortByProgressStarted": "Datum gestart", "LabelLibrarySortByProgressStarted": "Voortgang: Gestart",
"LabelLimit": "Limiet", "LabelLimit": "Limiet",
"LabelLineSpacing": "Regelruimte", "LabelLineSpacing": "Regelruimte",
"LabelListenAgain": "Opnieuw Beluisteren", "LabelListenAgain": "Opnieuw Beluisteren",
@ -588,8 +588,8 @@
"LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken", "LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken",
"LabelSettingsChromecastSupport": "Chromecast ondersteuning", "LabelSettingsChromecastSupport": "Chromecast ondersteuning",
"LabelSettingsDateFormat": "Datumnotatie", "LabelSettingsDateFormat": "Datumnotatie",
"LabelSettingsEnableWatcher": "Bibliotheken automatisch scannen op wijzigingen", "LabelSettingsEnableWatcher": "Bibliotheken automatisch monitoren op wijzigingen",
"LabelSettingsEnableWatcherForLibrary": "Bibliotheek automatisch scannen op wijzigingen", "LabelSettingsEnableWatcherForLibrary": "Bibliotheek automatisch monitoren op wijzigingen",
"LabelSettingsEnableWatcherHelp": "Zorgt voor het automatisch toevoegen/bijwerken van onderdelen als bestandswijzigingen worden gedetecteerd. *Vereist herstarten van server", "LabelSettingsEnableWatcherHelp": "Zorgt voor het automatisch toevoegen/bijwerken van onderdelen als bestandswijzigingen worden gedetecteerd. *Vereist herstarten van server",
"LabelSettingsEpubsAllowScriptedContent": "Sta scripted content toe in epubs", "LabelSettingsEpubsAllowScriptedContent": "Sta scripted content toe in epubs",
"LabelSettingsEpubsAllowScriptedContentHelp": "Sta toe dat epub-bestanden scripts uitvoeren. Het wordt aanbevolen om deze instelling uitgeschakeld te houden, tenzij u de bron van de epub-bestanden vertrouwt.", "LabelSettingsEpubsAllowScriptedContentHelp": "Sta toe dat epub-bestanden scripts uitvoeren. Het wordt aanbevolen om deze instelling uitgeschakeld te houden, tenzij u de bron van de epub-bestanden vertrouwt.",
@ -888,7 +888,7 @@
"MessageResetChaptersConfirm": "Weet je zeker dat je de hoofdstukken wil resetten en de wijzigingen die je gemaakt hebt ongedaan wil maken?", "MessageResetChaptersConfirm": "Weet je zeker dat je de hoofdstukken wil resetten en de wijzigingen die je gemaakt hebt ongedaan wil maken?",
"MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op", "MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op",
"MessageRestoreBackupWarning": "Een back-up herstellen zal de volledige database in /config en de omslagen in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om omslagen en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle apparaten die je server gebruiken, worden automatisch ververst.", "MessageRestoreBackupWarning": "Een back-up herstellen zal de volledige database in /config en de omslagen in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om omslagen en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle apparaten die je server gebruiken, worden automatisch ververst.",
"MessageScheduleLibraryScanNote": "Voor de meeste gebruikers is het raadzaam om deze functie uitgeschakeld te laten en de folder watcher-instelling ingeschakeld te houden. De folder watcher detecteert automatisch wijzigingen in uw bibliotheekmappen. De folder watcher werkt niet voor elk bestandssysteem (zoals NFS), dus geplande bibliotheekscans kunnen in plaats daarvan worden gebruikt.", "MessageScheduleLibraryScanNote": "Voor de meeste gebruikers is het aangeraden om deze functie uitgeschakeld te laten en de \"Bibliotheek automatisch monitoren op wijzigingen\" instelling ingeschakeld te houden - deze detecteert automatisch wijzigingen in uw bibliotheekmappen. Activeer deze instelling als \"Bibliotheek automatisch monitoren op wijzigingen\" niet werkt voor uw bestandssysteem (zoals NFS).",
"MessageScheduleRunEveryWeekdayAtTime": "Elke {0} uitvoeren op {1}", "MessageScheduleRunEveryWeekdayAtTime": "Elke {0} uitvoeren op {1}",
"MessageSearchResultsFor": "Zoekresultaten voor", "MessageSearchResultsFor": "Zoekresultaten voor",
"MessageSelected": "{0} geselecteerd", "MessageSelected": "{0} geselecteerd",
@ -1026,6 +1026,8 @@
"ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt", "ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt",
"ToastCollectionRemoveSuccess": "Collectie verwijderd", "ToastCollectionRemoveSuccess": "Collectie verwijderd",
"ToastCollectionUpdateSuccess": "Collectie bijgewerkt", "ToastCollectionUpdateSuccess": "Collectie bijgewerkt",
"ToastConnectionNotAvailable": "Verbinding niet beschikbaar. Gelieve later opnieuw te proberen",
"ToastCoverSearchFailed": "Omslag zoeken mislukt",
"ToastCoverUpdateFailed": "Omslag bijwerken mislukt", "ToastCoverUpdateFailed": "Omslag bijwerken mislukt",
"ToastDateTimeInvalidOrIncomplete": "Datum en tijd ongeldig of onvolledig", "ToastDateTimeInvalidOrIncomplete": "Datum en tijd ongeldig of onvolledig",
"ToastDeleteFileFailed": "Bestand verwijderen mislukt", "ToastDeleteFileFailed": "Bestand verwijderen mislukt",

4
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -41,6 +41,10 @@ class CollectionController {
if (reqBody.description && typeof reqBody.description !== 'string') { if (reqBody.description && typeof reqBody.description !== 'string') {
return res.status(400).send('Invalid collection description') return res.status(400).send('Invalid collection description')
} }
if (!req.user.checkCanAccessLibrary(reqBody.libraryId)) {
Logger.warn(`[CollectionController] User "${req.user.username}" attempted to create collection in inaccessible library ${reqBody.libraryId}`)
return res.sendStatus(403)
}
const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string') const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string')
if (!libraryItemIds.length) { if (!libraryItemIds.length) {
return res.status(400).send('Invalid collection data. No books') return res.status(400).send('Invalid collection data. No books')
@ -109,8 +113,9 @@ class CollectionController {
*/ */
async findAll(req, res) { async findAll(req, res) {
const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user) const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user)
const accessibleCollections = collectionsExpanded.filter((c) => req.user.checkCanAccessLibrary(c.libraryId))
res.json({ res.json({
collections: collectionsExpanded collections: accessibleCollections
}) })
} }
@ -431,6 +436,10 @@ class CollectionController {
if (!collection) { if (!collection) {
return res.status(404).send('Collection not found') return res.status(404).send('Collection not found')
} }
if (!req.user.checkCanAccessLibrary(collection.libraryId)) {
Logger.warn(`[CollectionController] User "${req.user.username}" attempted to access collection ${collection.id} in inaccessible library ${collection.libraryId}`)
return res.status(404).send('Collection not found')
}
req.collection = collection req.collection = collection
} }

View file

@ -1,6 +1,7 @@
const { Request, Response, NextFunction } = require('express') const { Request, Response, NextFunction } = require('express')
const Path = require('path') const Path = require('path')
const fs = require('../libs/fsExtra') const fs = require('../libs/fsExtra')
const cron = require('../libs/nodeCron')
const uaParserJs = require('../libs/uaParser') const uaParserJs = require('../libs/uaParser')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
@ -36,6 +37,24 @@ const ShareManager = require('../managers/ShareManager')
* @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile * @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile
*/ */
/**
* Enforce per-item access for batch item routes
*
* @param {RequestWithUser} req
* @param {Response} res
* @param {import('../models/LibraryItem')[]} libraryItems
* @returns {boolean} true if the user may access every item; false if 403 was sent
*/
function ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems) {
for (const libraryItem of libraryItems) {
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
res.sendStatus(403)
return false
}
}
return true
}
class LibraryItemController { class LibraryItemController {
constructor() {} constructor() {}
@ -202,6 +221,11 @@ class LibraryItemController {
} else if (mediaPayload.autoDownloadSchedule !== undefined && req.libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) { } else if (mediaPayload.autoDownloadSchedule !== undefined && req.libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
isPodcastAutoDownloadUpdated = true isPodcastAutoDownloadUpdated = true
} }
if (mediaPayload.autoDownloadSchedule && !cron.validate(mediaPayload.autoDownloadSchedule)) {
Logger.error(`[LibraryItemController] Invalid auto download schedule cron expression "${mediaPayload.autoDownloadSchedule}" for library item "${req.libraryItem.media.title}"`)
return res.status(400).send('Invalid auto download schedule cron expression')
}
} }
let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
@ -547,7 +571,13 @@ class LibraryItemController {
return res.sendStatus(404) return res.sendStatus(404)
} }
// Ensure user has permission to delete these library items
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, itemsToDelete)) {
return
}
const libraryId = itemsToDelete[0].libraryId const libraryId = itemsToDelete[0].libraryId
for (const libraryItem of itemsToDelete) { for (const libraryItem of itemsToDelete) {
const libraryItemPath = libraryItem.path const libraryItemPath = libraryItem.path
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.title}" with id "${libraryItem.id}"`) Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.title}" with id "${libraryItem.id}"`)
@ -581,6 +611,7 @@ class LibraryItemController {
} }
await Database.resetLibraryIssuesFilterData(libraryId) await Database.resetLibraryIssuesFilterData(libraryId)
res.sendStatus(200) res.sendStatus(200)
} }
@ -593,6 +624,11 @@ class LibraryItemController {
* @param {Response} res * @param {Response} res
*/ */
async batchUpdate(req, res) { async batchUpdate(req, res) {
if (!req.user.canUpdate) {
Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to batch update without permission`)
return res.sendStatus(403)
}
const updatePayloads = req.body const updatePayloads = req.body
if (!Array.isArray(updatePayloads) || !updatePayloads.length) { if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`) Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)
@ -615,6 +651,11 @@ class LibraryItemController {
return res.sendStatus(404) return res.sendStatus(404)
} }
// Ensure user has permission to update these library items
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems)) {
return
}
let itemsUpdated = 0 let itemsUpdated = 0
const seriesIdsRemoved = [] const seriesIdsRemoved = []
@ -624,6 +665,11 @@ class LibraryItemController {
const mediaPayload = updatePayload.mediaPayload const mediaPayload = updatePayload.mediaPayload
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id) const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
if (libraryItem.isPodcast && mediaPayload.autoDownloadSchedule && !cron.validate(mediaPayload.autoDownloadSchedule)) {
Logger.warn(`[LibraryItemController] Invalid auto download schedule cron expression "${mediaPayload.autoDownloadSchedule}" for library item "${libraryItem.media.title}" - skipping update`)
continue
}
let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload) let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) { if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
@ -695,6 +741,10 @@ class LibraryItemController {
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({ const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
id: libraryItemIds id: libraryItemIds
}) })
// Ensure user has permission to access these library items
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems)) {
return
}
res.json({ res.json({
libraryItems: libraryItems.map((li) => li.toOldJSONExpanded()) libraryItems: libraryItems.map((li) => li.toOldJSONExpanded())
}) })

View file

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

View file

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

View file

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

View file

@ -53,6 +53,10 @@ class ShareController {
if (playbackSession) { if (playbackSession) {
if (mediaItemShare.id === playbackSession.mediaItemShareId) { if (mediaItemShare.id === playbackSession.mediaItemShareId) {
Logger.debug(`[ShareController] Found share playback session ${req.cookies.share_session_id}`) Logger.debug(`[ShareController] Found share playback session ${req.cookies.share_session_id}`)
// If ?t was provided, override the cached currentTime
if (startTime > 0 && startTime < playbackSession.duration) {
playbackSession.currentTime = startTime
}
mediaItemShare.playbackSession = playbackSession.toJSONForClient() mediaItemShare.playbackSession = playbackSession.toJSONForClient()
return res.json(mediaItemShare) return res.json(mediaItemShare)
} else { } else {

View file

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

View file

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

View file

@ -78,6 +78,7 @@ class Podcast extends Model {
*/ */
static async createFromRequest(payload, transaction) { static async createFromRequest(payload, transaction) {
const title = typeof payload.metadata.title === 'string' ? payload.metadata.title : null const title = typeof payload.metadata.title === 'string' ? payload.metadata.title : null
// cron expression validated in controller
const autoDownloadSchedule = typeof payload.autoDownloadSchedule === 'string' ? payload.autoDownloadSchedule : null const autoDownloadSchedule = typeof payload.autoDownloadSchedule === 'string' ? payload.autoDownloadSchedule : null
const genres = Array.isArray(payload.metadata.genres) && payload.metadata.genres.every((g) => typeof g === 'string' && g.length) ? payload.metadata.genres : [] const genres = Array.isArray(payload.metadata.genres) && payload.metadata.genres.every((g) => typeof g === 'string' && g.length) ? payload.metadata.genres : []
const tags = Array.isArray(payload.tags) && payload.tags.every((t) => typeof t === 'string' && t.length) ? payload.tags : [] const tags = Array.isArray(payload.tags) && payload.tags.every((t) => typeof t === 'string' && t.length) ? payload.tags : []
@ -89,6 +90,9 @@ class Podcast extends Model {
} }
}) })
const rawDescription = typeof payload.metadata.description === 'string' ? payload.metadata.description : null
const description = rawDescription ? htmlSanitizer.sanitize(rawDescription) : null
return this.create( return this.create(
{ {
title, title,
@ -97,7 +101,7 @@ class Podcast extends Model {
releaseDate: typeof payload.metadata.releaseDate === 'string' ? payload.metadata.releaseDate : null, releaseDate: typeof payload.metadata.releaseDate === 'string' ? payload.metadata.releaseDate : null,
feedURL: typeof payload.metadata.feedUrl === 'string' ? payload.metadata.feedUrl : null, feedURL: typeof payload.metadata.feedUrl === 'string' ? payload.metadata.feedUrl : null,
imageURL: typeof payload.metadata.imageUrl === 'string' ? payload.metadata.imageUrl : null, imageURL: typeof payload.metadata.imageUrl === 'string' ? payload.metadata.imageUrl : null,
description: typeof payload.metadata.description === 'string' ? payload.metadata.description : null, description,
itunesPageURL: typeof payload.metadata.itunesPageUrl === 'string' ? payload.metadata.itunesPageUrl : null, itunesPageURL: typeof payload.metadata.itunesPageUrl === 'string' ? payload.metadata.itunesPageUrl : null,
itunesId: typeof payload.metadata.itunesId === 'string' ? payload.metadata.itunesId : null, itunesId: typeof payload.metadata.itunesId === 'string' ? payload.metadata.itunesId : null,
itunesArtistId: typeof payload.metadata.itunesArtistId === 'string' ? payload.metadata.itunesArtistId : null, itunesArtistId: typeof payload.metadata.itunesArtistId === 'string' ? payload.metadata.itunesArtistId : null,
@ -270,6 +274,7 @@ class Podcast extends Model {
hasUpdates = true hasUpdates = true
} }
if (typeof payload.autoDownloadSchedule === 'string' && payload.autoDownloadSchedule !== this.autoDownloadSchedule) { if (typeof payload.autoDownloadSchedule === 'string' && payload.autoDownloadSchedule !== this.autoDownloadSchedule) {
// cron expression validated in controller
this.autoDownloadSchedule = payload.autoDownloadSchedule this.autoDownloadSchedule = payload.autoDownloadSchedule
hasUpdates = true hasUpdates = true
} }

View file

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

View file

@ -123,7 +123,9 @@ describe('LibraryItemController', () => {
const fakeReq = { const fakeReq = {
query: {}, query: {},
user: { user: {
canDelete: true username: 'test',
canDelete: true,
checkCanAccessLibraryItem: () => true
}, },
body: { body: {
libraryItemIds: [libraryItem1Id] libraryItemIds: [libraryItem1Id]
@ -199,4 +201,102 @@ describe('LibraryItemController', () => {
expect(series2Exists).to.be.true expect(series2Exists).to.be.true
}) })
}) })
describe('batch item access control', () => {
let lib1Id
let itemLib1Id
let itemLib2Id
beforeEach(async () => {
const lib1 = await Database.libraryModel.create({ name: 'Lib 1', mediaType: 'book' })
const folder1 = await Database.libraryFolderModel.create({ path: '/l1', libraryId: lib1.id })
const book1 = await Database.bookModel.create({ title: 'B1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
const li1 = await Database.libraryItemModel.create({
libraryFiles: [],
mediaId: book1.id,
mediaType: 'book',
libraryId: lib1.id,
libraryFolderId: folder1.id
})
lib1Id = lib1.id
itemLib1Id = li1.id
const lib2 = await Database.libraryModel.create({ name: 'Lib 2', mediaType: 'book' })
const folder2 = await Database.libraryFolderModel.create({ path: '/l2', libraryId: lib2.id })
const book2 = await Database.bookModel.create({ title: 'B2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
const li2 = await Database.libraryItemModel.create({
libraryFiles: [],
mediaId: book2.id,
mediaType: 'book',
libraryId: lib2.id,
libraryFolderId: folder2.id
})
itemLib2Id = li2.id
})
const userLimitedToLib1 = () => ({
username: 'limited',
canDelete: true,
canUpdate: true,
checkCanAccessLibraryItem(li) {
return li.libraryId === lib1Id
}
})
it('batchGet returns 403 for a library item the user cannot access', async () => {
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
const fakeReq = {
body: { libraryItemIds: [itemLib2Id] },
user: userLimitedToLib1()
}
await LibraryItemController.batchGet.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
})
it('batchGet returns items when the user can access them', async () => {
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
const fakeReq = {
body: { libraryItemIds: [itemLib1Id] },
user: userLimitedToLib1()
}
await LibraryItemController.batchGet.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.json.calledOnce).to.be.true
const payload = fakeRes.json.firstCall.args[0]
expect(payload.libraryItems).to.have.length(1)
expect(payload.libraryItems[0].id).to.equal(itemLib1Id)
})
it('batchUpdate returns 403 for a library item the user cannot access', async () => {
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
const fakeReq = {
user: userLimitedToLib1(),
body: [{ id: itemLib2Id, mediaPayload: {} }]
}
await LibraryItemController.batchUpdate.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
})
it('batchUpdate returns 403 when the user lacks canUpdate', async () => {
const u = userLimitedToLib1()
u.canUpdate = false
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
const fakeReq = {
user: u,
body: [{ id: itemLib1Id, mediaPayload: {} }]
}
await LibraryItemController.batchUpdate.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
})
it('batchDelete returns 403 for a library item the user cannot access', async () => {
const fakeRes = { sendStatus: sinon.spy() }
const fakeReq = {
query: {},
user: userLimitedToLib1(),
body: { libraryItemIds: [itemLib2Id] }
}
await LibraryItemController.batchDelete.bind(apiRouter)(fakeReq, fakeRes)
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
})
})
}) })

View file

@ -1,6 +1,7 @@
// Import dependencies and modules for testing // Import dependencies and modules for testing
const { expect } = require('chai') const { expect } = require('chai')
const sinon = require('sinon') const sinon = require('sinon')
const { LRUCache } = require('lru-cache')
const ApiCacheManager = require('../../../server/managers/ApiCacheManager') const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
describe('ApiCacheManager', () => { describe('ApiCacheManager', () => {
@ -94,4 +95,17 @@ describe('ApiCacheManager', () => {
expect(res.originalSend.calledWith(body)).to.be.true expect(res.originalSend.calledWith(body)).to.be.true
}) })
}) })
describe('clear on mediaProgress', () => {
it('should remove recent-episodes cache entries', () => {
const key = JSON.stringify({ user: 'u', url: '/libraries/abc-123/recent-episodes?limit=50&page=0' })
const cache = new LRUCache({ max: 10 })
cache.set(key, { body: '[]', headers: {}, statusCode: 200 })
const manager = new ApiCacheManager(cache)
manager.clear({ name: 'mediaProgress' }, 'afterUpdate')
expect(cache.get(key)).to.be.undefined
})
})
}) })