mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-05-12 22:41:29 +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
|
||||
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, updatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
const errorMessage = typeof error?.response?.data === 'string' ? error?.response?.data : null
|
||||
this.$toast.error(errorMessage || this.$strings.ToastFailedToUpdate)
|
||||
return false
|
||||
})
|
||||
this.isProcessing = false
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<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">
|
||||
<span class="material-symbols text-gray-300 cursor-pointer" style="font-size: 1.1rem" @click.stop.prevent="clear">close</span>
|
||||
</div>
|
||||
|
|
@ -41,7 +41,8 @@ export default {
|
|||
step: [String, Number],
|
||||
min: [String, Number],
|
||||
customInputClass: String,
|
||||
trimWhitespace: Boolean
|
||||
trimWhitespace: Boolean,
|
||||
autocomplete: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
|
||||
</label>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
|
@ -26,7 +26,8 @@ export default {
|
|||
disabled: Boolean,
|
||||
inputClass: String,
|
||||
showCopy: Boolean,
|
||||
trimWhitespace: Boolean
|
||||
trimWhitespace: Boolean,
|
||||
autocomplete: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
|
|
|||
4
client/package-lock.json
generated
4
client/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.33.2",
|
||||
"version": "2.34.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.33.2",
|
||||
"version": "2.34.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.33.2",
|
||||
"version": "2.34.0",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@
|
|||
|
||||
<form @submit.prevent="submitServerSetup">
|
||||
<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="newRoot.password" label="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" :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" 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" 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>
|
||||
<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">
|
||||
<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>
|
||||
<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">
|
||||
<ui-btn type="submit" :disabled="processing" color="bg-primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -364,6 +364,7 @@ export default {
|
|||
}
|
||||
|
||||
const startTime = this.playbackSession.currentTime || 0
|
||||
|
||||
this.localAudioPlayer.set(null, this.audioTracks, false, startTime, false)
|
||||
this.localAudioPlayer.on('stateChange', this.playerStateChange.bind(this))
|
||||
this.localAudioPlayer.on('timeupdate', this.playerTimeUpdate.bind(this))
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const languageCodeMap = {
|
|||
he: { label: 'עברית', dateFnsLocale: 'he' },
|
||||
hr: { label: 'Hrvatski', dateFnsLocale: 'hr' },
|
||||
it: { label: 'Italiano', dateFnsLocale: 'it' },
|
||||
ja: { label: '日本語', dateFnsLocale: 'ja' },
|
||||
lt: { label: 'Lietuvių', dateFnsLocale: 'lt' },
|
||||
hu: { label: 'Magyar', dateFnsLocale: 'hu' },
|
||||
ko: { label: '한국어', dateFnsLocale: 'ko' },
|
||||
|
|
@ -60,6 +61,7 @@ const podcastSearchRegionMap = {
|
|||
hr: { label: 'Hrvatska' },
|
||||
il: { label: 'ישראל / إسرائيل' },
|
||||
it: { label: 'Italia' },
|
||||
jp: { label: '日本' },
|
||||
lu: { label: 'Luxembourg / Luxemburg / Lëtezebuerg' },
|
||||
hu: { label: 'Magyarország' },
|
||||
nl: { label: 'Nederland' },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"ButtonAdd": "Дадаць",
|
||||
"ButtonAddApiKey": "Дадаць API-ключ",
|
||||
"ButtonAddApiKey": "Дадаць ключ API",
|
||||
"ButtonAddChapters": "Дадаць раздзелы",
|
||||
"ButtonAddDevice": "Дадаць прыладу",
|
||||
"ButtonAddLibrary": "Дадаць бібліятэку",
|
||||
|
|
@ -121,7 +121,7 @@
|
|||
"HeaderAccount": "Уліковы запіс",
|
||||
"HeaderAddCustomMetadataProvider": "Дадаванне карыстальніцкага пастаўшчыка метаданых",
|
||||
"HeaderAdvanced": "Дадаткова",
|
||||
"HeaderApiKeys": "API-ключы",
|
||||
"HeaderApiKeys": "Ключы API",
|
||||
"HeaderAppriseNotificationSettings": "Налады апавяшчэнняў Apprise",
|
||||
"HeaderAudioTracks": "Аўдыятрэкі",
|
||||
"HeaderAudiobookTools": "Сродкі кіравання файламі аўдыякніг",
|
||||
|
|
@ -166,7 +166,7 @@
|
|||
"HeaderMetadataOrderOfPrecedence": "Парадак прыярытэту метаданых",
|
||||
"HeaderMetadataToEmbed": "Метаданыя для ўбудавання",
|
||||
"HeaderNewAccount": "Новы ўліковы запіс",
|
||||
"HeaderNewApiKey": "Новы API-ключ",
|
||||
"HeaderNewApiKey": "Новы ключ API",
|
||||
"HeaderNewLibrary": "Новая бібліятэка",
|
||||
"HeaderNotificationCreate": "Стварыць апавяшчэнне",
|
||||
"HeaderNotificationUpdate": "Абнавіць апавяшчэнне",
|
||||
|
|
@ -212,7 +212,7 @@
|
|||
"HeaderTableOfContents": "Змест",
|
||||
"HeaderTools": "Інструменты",
|
||||
"HeaderUpdateAccount": "Абнавіць уліковы запіс",
|
||||
"HeaderUpdateApiKey": "Абнавіць API-ключ",
|
||||
"HeaderUpdateApiKey": "Абнавіць ключ API",
|
||||
"HeaderUpdateAuthor": "Абнавіць аўтара",
|
||||
"HeaderUpdateDetails": "Абнавіць падрабязнасці",
|
||||
"HeaderUpdateLibrary": "Абнавіць бібліятэку",
|
||||
|
|
@ -242,10 +242,10 @@
|
|||
"LabelAllUsersExcludingGuests": "Усіх карыстальнікаў, акрамя гасцей",
|
||||
"LabelAllUsersIncludingGuests": "Усіх карыстальнікаў, уключаючы гасцей",
|
||||
"LabelAlreadyInYourLibrary": "Ужо ў вашай бібліятэцы",
|
||||
"LabelApiKeyCreated": "API-ключ \"{0}\" паспяхова створаны.",
|
||||
"LabelApiKeyCreatedDescription": "Пераканайцеся, што вы скапіявалі API-ключ зараз, бо паўторна яго ўбачыць не атрымаецца.",
|
||||
"LabelApiKeyCreated": "Ключ API \"{0}\" паспяхова створаны.",
|
||||
"LabelApiKeyCreatedDescription": "Абавязкова скапіюйце ключ API зараз, бо паўторна яго ўбачыць не атрымаецца.",
|
||||
"LabelApiKeyUser": "Дзейнічаць ад імя карыстальніка",
|
||||
"LabelApiKeyUserDescription": "Гэты API-ключ будзе мець тыя ж правы, што і карыстальнік, ад імя якога ён дзейнічае. У журналах гэта будзе выглядаць так, быццам запыт робіць сам карыстальнік.",
|
||||
"LabelApiKeyUserDescription": "Гэты ключ API будзе мець тыя ж правы, што і карыстальнік, ад імя якога ён дзейнічае. У журналах гэта будзе выглядаць так, быццам запыт робіць сам карыстальнік.",
|
||||
"LabelApiToken": "Токен API",
|
||||
"LabelAppend": "Дадаць",
|
||||
"LabelAudioBitrate": "Бітрэйт аўдыя (напрыклад, 128к)",
|
||||
|
|
@ -884,7 +884,7 @@
|
|||
"MessageRemoveEpisodes": "Выдаліць выпускі ({0})",
|
||||
"MessageRemoveFromPlayerQueue": "Выдаліць з чаргі прагравання",
|
||||
"MessageRemoveUserWarning": "Вы ўпэўнены, што хочаце назаўжды выдаліць карыстальніка \"{0}\"?",
|
||||
"MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце новыя функцыі і ўдзельнічайце на",
|
||||
"MessageReportBugsAndContribute": "Паведамляйце пра памылкі, прапануйце функцыі і ўносьце свой уклад на",
|
||||
"MessageResetChaptersConfirm": "Вы ўпэўнены, што хочаце скінуць раздзелы і адрабіць зробленыя вамі змены?",
|
||||
"MessageRestoreBackupConfirm": "Вы ўпэўнены, што хочаце аднавіць рэзервовую копію, створаную",
|
||||
"MessageRestoreBackupWarning": "Аднаўленне рэзервовай копіі перазапіша ўсю базу даных, размешчаную ў /config, а таксама відарысы вокладкі ў /metadata/items і /metadata/authors. <br /><br /> Рэзервовыя копіі не змяняюць файлы ў папках бібліятэкі. Калі вы ўключылі налады сервера для захоўвання воклак і метаданых у папках бібліятэкі, гэтыя файлы не будуць захаваныя ў рэзервовых копіях і не зменяцца. <br /><br /> Усе кліенты, якія карыстаюцца вашым серверам, будуць аўтаматычна абноўлены.",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
"ButtonBrowseForFolder": "Mappa keresése",
|
||||
"ButtonCancel": "Mégse",
|
||||
"ButtonCancelEncode": "Kódolás megszakítása",
|
||||
"ButtonChangeRootPassword": "Gyökérjelszó megváltoztatása",
|
||||
"ButtonChangeRootPassword": "Root jelszó megváltoztatása",
|
||||
"ButtonCheckAndDownloadNewEpisodes": "Új epizódok ellenőrzése és letöltése",
|
||||
"ButtonChooseAFolder": "Válassz egy mappát",
|
||||
"ButtonChooseFiles": "Fájlok kiválasztása",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,7 @@
|
|||
"ButtonAdd": "Toevoegen",
|
||||
"ButtonAddApiKey": "API Key toevoegen",
|
||||
"ButtonAddChapters": "Hoofdstukken toevoegen",
|
||||
"ButtonAddDevice": "Toestel toevoegen",
|
||||
"ButtonAddDevice": "Apparaat toevoegen",
|
||||
"ButtonAddLibrary": "Bibliotheek toevoegen",
|
||||
"ButtonAddPodcasts": "Podcasts toevoegen",
|
||||
"ButtonAddUser": "Gebruiker toevoegen",
|
||||
|
|
@ -139,7 +139,7 @@
|
|||
"HeaderCustomMetadataProviders": "Aangepaste Metadata Providers",
|
||||
"HeaderDetails": "Details",
|
||||
"HeaderDownloadQueue": "Download-wachtrij",
|
||||
"HeaderEbookFiles": "Ebook bestanden",
|
||||
"HeaderEbookFiles": "E-book bestanden",
|
||||
"HeaderEmail": "E-mail",
|
||||
"HeaderEmailSettings": "E-mail instellingen",
|
||||
"HeaderEpisodes": "Afleveringen",
|
||||
|
|
@ -275,7 +275,7 @@
|
|||
"LabelBonus": "Bonus",
|
||||
"LabelBooks": "Boeken",
|
||||
"LabelButtonText": "Knop Tekst",
|
||||
"LabelByAuthor": "Door {0}",
|
||||
"LabelByAuthor": "door {0}",
|
||||
"LabelChangePassword": "Wachtwoord wijzigen",
|
||||
"LabelChannels": "Kanalen",
|
||||
"LabelChapterCount": "{0} Hoofdstukken",
|
||||
|
|
@ -383,7 +383,7 @@
|
|||
"LabelFolders": "Mappen",
|
||||
"LabelFontBold": "Vetgedrukt",
|
||||
"LabelFontBoldness": "Lettertype Dikte",
|
||||
"LabelFontFamily": "Lettertypefamilie",
|
||||
"LabelFontFamily": "Letterfamilie",
|
||||
"LabelFontItalic": "Cursief",
|
||||
"LabelFontScale": "Lettertype schaal",
|
||||
"LabelFontStrikethrough": "Doorgestreept",
|
||||
|
|
@ -436,9 +436,9 @@
|
|||
"LabelLibraryFilterSublistEmpty": "Nee {0}",
|
||||
"LabelLibraryItem": "Bibliotheekonderdeel",
|
||||
"LabelLibraryName": "Bibliotheeknaam",
|
||||
"LabelLibrarySortByProgress": "Voortuigang geüpdatet",
|
||||
"LabelLibrarySortByProgressFinished": "Datum voltooid",
|
||||
"LabelLibrarySortByProgressStarted": "Datum gestart",
|
||||
"LabelLibrarySortByProgress": "Voortgang: Laatst geüpdatet",
|
||||
"LabelLibrarySortByProgressFinished": "Voortgang: Voltooid",
|
||||
"LabelLibrarySortByProgressStarted": "Voortgang: Gestart",
|
||||
"LabelLimit": "Limiet",
|
||||
"LabelLineSpacing": "Regelruimte",
|
||||
"LabelListenAgain": "Opnieuw Beluisteren",
|
||||
|
|
@ -588,8 +588,8 @@
|
|||
"LabelSettingsBookshelfViewHelp": "Skeumorphisch design met houten planken",
|
||||
"LabelSettingsChromecastSupport": "Chromecast ondersteuning",
|
||||
"LabelSettingsDateFormat": "Datumnotatie",
|
||||
"LabelSettingsEnableWatcher": "Bibliotheken automatisch scannen op wijzigingen",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Bibliotheek automatisch scannen op wijzigingen",
|
||||
"LabelSettingsEnableWatcher": "Bibliotheken automatisch monitoren op wijzigingen",
|
||||
"LabelSettingsEnableWatcherForLibrary": "Bibliotheek automatisch monitoren op wijzigingen",
|
||||
"LabelSettingsEnableWatcherHelp": "Zorgt voor het automatisch toevoegen/bijwerken van onderdelen als bestandswijzigingen worden gedetecteerd. *Vereist herstarten van server",
|
||||
"LabelSettingsEpubsAllowScriptedContent": "Sta scripted content toe in epubs",
|
||||
"LabelSettingsEpubsAllowScriptedContentHelp": "Sta toe dat epub-bestanden scripts uitvoeren. Het wordt aanbevolen om deze instelling uitgeschakeld te houden, tenzij u de bron van de epub-bestanden vertrouwt.",
|
||||
|
|
@ -888,7 +888,7 @@
|
|||
"MessageResetChaptersConfirm": "Weet je zeker dat je de hoofdstukken wil resetten en de wijzigingen die je gemaakt hebt ongedaan wil maken?",
|
||||
"MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op",
|
||||
"MessageRestoreBackupWarning": "Een back-up herstellen zal de volledige database in /config en de omslagen in /metadata/items & /metadata/authors overschrijven.<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}",
|
||||
"MessageSearchResultsFor": "Zoekresultaten voor",
|
||||
"MessageSelected": "{0} geselecteerd",
|
||||
|
|
@ -1026,6 +1026,8 @@
|
|||
"ToastCollectionItemsAddFailed": "Item(s) toegevoegd aan collectie mislukt",
|
||||
"ToastCollectionRemoveSuccess": "Collectie verwijderd",
|
||||
"ToastCollectionUpdateSuccess": "Collectie bijgewerkt",
|
||||
"ToastConnectionNotAvailable": "Verbinding niet beschikbaar. Gelieve later opnieuw te proberen",
|
||||
"ToastCoverSearchFailed": "Omslag zoeken mislukt",
|
||||
"ToastCoverUpdateFailed": "Omslag bijwerken mislukt",
|
||||
"ToastDateTimeInvalidOrIncomplete": "Datum en tijd ongeldig of onvolledig",
|
||||
"ToastDeleteFileFailed": "Bestand verwijderen mislukt",
|
||||
|
|
|
|||
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.33.2",
|
||||
"version": "2.34.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.33.2",
|
||||
"version": "2.34.0",
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "2.33.2",
|
||||
"version": "2.34.0",
|
||||
"buildNumber": 1,
|
||||
"description": "Self-hosted audiobook and podcast server",
|
||||
"main": "index.js",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ class CollectionController {
|
|||
if (reqBody.description && typeof reqBody.description !== 'string') {
|
||||
return res.status(400).send('Invalid collection description')
|
||||
}
|
||||
if (!req.user.checkCanAccessLibrary(reqBody.libraryId)) {
|
||||
Logger.warn(`[CollectionController] User "${req.user.username}" attempted to create collection in inaccessible library ${reqBody.libraryId}`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const libraryItemIds = (reqBody.books || []).filter((b) => !!b && typeof b == 'string')
|
||||
if (!libraryItemIds.length) {
|
||||
return res.status(400).send('Invalid collection data. No books')
|
||||
|
|
@ -109,8 +113,9 @@ class CollectionController {
|
|||
*/
|
||||
async findAll(req, res) {
|
||||
const collectionsExpanded = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user)
|
||||
const accessibleCollections = collectionsExpanded.filter((c) => req.user.checkCanAccessLibrary(c.libraryId))
|
||||
res.json({
|
||||
collections: collectionsExpanded
|
||||
collections: accessibleCollections
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -431,6 +436,10 @@ class CollectionController {
|
|||
if (!collection) {
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
if (!req.user.checkCanAccessLibrary(collection.libraryId)) {
|
||||
Logger.warn(`[CollectionController] User "${req.user.username}" attempted to access collection ${collection.id} in inaccessible library ${collection.libraryId}`)
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
req.collection = collection
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const { Request, Response, NextFunction } = require('express')
|
||||
const Path = require('path')
|
||||
const fs = require('../libs/fsExtra')
|
||||
const cron = require('../libs/nodeCron')
|
||||
const uaParserJs = require('../libs/uaParser')
|
||||
const Logger = require('../Logger')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
|
|
@ -36,6 +37,24 @@ const ShareManager = require('../managers/ShareManager')
|
|||
* @typedef {RequestWithUser & RequestEntityObject & RequestLibraryFileObject} LibraryItemControllerRequestWithFile
|
||||
*/
|
||||
|
||||
/**
|
||||
* Enforce per-item access for batch item routes
|
||||
*
|
||||
* @param {RequestWithUser} req
|
||||
* @param {Response} res
|
||||
* @param {import('../models/LibraryItem')[]} libraryItems
|
||||
* @returns {boolean} true if the user may access every item; false if 403 was sent
|
||||
*/
|
||||
function ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems) {
|
||||
for (const libraryItem of libraryItems) {
|
||||
if (!req.user.checkCanAccessLibraryItem(libraryItem)) {
|
||||
res.sendStatus(403)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
class LibraryItemController {
|
||||
constructor() {}
|
||||
|
||||
|
|
@ -202,6 +221,11 @@ class LibraryItemController {
|
|||
} else if (mediaPayload.autoDownloadSchedule !== undefined && req.libraryItem.media.autoDownloadSchedule !== mediaPayload.autoDownloadSchedule) {
|
||||
isPodcastAutoDownloadUpdated = true
|
||||
}
|
||||
|
||||
if (mediaPayload.autoDownloadSchedule && !cron.validate(mediaPayload.autoDownloadSchedule)) {
|
||||
Logger.error(`[LibraryItemController] Invalid auto download schedule cron expression "${mediaPayload.autoDownloadSchedule}" for library item "${req.libraryItem.media.title}"`)
|
||||
return res.status(400).send('Invalid auto download schedule cron expression')
|
||||
}
|
||||
}
|
||||
|
||||
let hasUpdates = (await req.libraryItem.media.updateFromRequest(mediaPayload)) || mediaPayload.url
|
||||
|
|
@ -547,7 +571,13 @@ class LibraryItemController {
|
|||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
// Ensure user has permission to delete these library items
|
||||
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, itemsToDelete)) {
|
||||
return
|
||||
}
|
||||
|
||||
const libraryId = itemsToDelete[0].libraryId
|
||||
|
||||
for (const libraryItem of itemsToDelete) {
|
||||
const libraryItemPath = libraryItem.path
|
||||
Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.title}" with id "${libraryItem.id}"`)
|
||||
|
|
@ -581,6 +611,7 @@ class LibraryItemController {
|
|||
}
|
||||
|
||||
await Database.resetLibraryIssuesFilterData(libraryId)
|
||||
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
|
|
@ -593,6 +624,11 @@ class LibraryItemController {
|
|||
* @param {Response} res
|
||||
*/
|
||||
async batchUpdate(req, res) {
|
||||
if (!req.user.canUpdate) {
|
||||
Logger.warn(`[LibraryItemController] User "${req.user.username}" attempted to batch update without permission`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
|
||||
const updatePayloads = req.body
|
||||
if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
|
||||
Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)
|
||||
|
|
@ -615,6 +651,11 @@ class LibraryItemController {
|
|||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
// Ensure user has permission to update these library items
|
||||
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems)) {
|
||||
return
|
||||
}
|
||||
|
||||
let itemsUpdated = 0
|
||||
|
||||
const seriesIdsRemoved = []
|
||||
|
|
@ -624,6 +665,11 @@ class LibraryItemController {
|
|||
const mediaPayload = updatePayload.mediaPayload
|
||||
const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
|
||||
|
||||
if (libraryItem.isPodcast && mediaPayload.autoDownloadSchedule && !cron.validate(mediaPayload.autoDownloadSchedule)) {
|
||||
Logger.warn(`[LibraryItemController] Invalid auto download schedule cron expression "${mediaPayload.autoDownloadSchedule}" for library item "${libraryItem.media.title}" - skipping update`)
|
||||
continue
|
||||
}
|
||||
|
||||
let hasUpdates = await libraryItem.media.updateFromRequest(mediaPayload)
|
||||
|
||||
if (libraryItem.isBook && Array.isArray(mediaPayload.metadata?.series)) {
|
||||
|
|
@ -695,6 +741,10 @@ class LibraryItemController {
|
|||
const libraryItems = await Database.libraryItemModel.findAllExpandedWhere({
|
||||
id: libraryItemIds
|
||||
})
|
||||
// Ensure user has permission to access these library items
|
||||
if (!ensureUserCanAccessLibraryItemsForBatch(req, res, libraryItems)) {
|
||||
return
|
||||
}
|
||||
res.json({
|
||||
libraryItems: libraryItems.map((li) => li.toOldJSONExpanded())
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const Database = require('../Database')
|
|||
const Watcher = require('../Watcher')
|
||||
|
||||
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
|
||||
const patternValidation = require('../libs/nodeCron/pattern-validation')
|
||||
const cron = require('../libs/nodeCron')
|
||||
const { isObject, getTitleIgnorePrefix } = require('../utils/index')
|
||||
const { sanitizeFilename } = require('../utils/fileUtils')
|
||||
|
||||
|
|
@ -605,13 +605,11 @@ class MiscController {
|
|||
return res.sendStatus(400)
|
||||
}
|
||||
|
||||
try {
|
||||
patternValidation(expression)
|
||||
res.sendStatus(200)
|
||||
} catch (error) {
|
||||
Logger.warn(`[MiscController] Invalid cron expression ${expression}`, error.message)
|
||||
res.status(400).send(error.message)
|
||||
if (!cron.validate(expression)) {
|
||||
Logger.warn(`[MiscController] Invalid cron expression ${expression}`)
|
||||
return res.status(400).send('Invalid cron expression')
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ class PlaylistController {
|
|||
if (reqBody.description && typeof reqBody.description !== 'string') {
|
||||
return res.status(400).send('Invalid playlist description')
|
||||
}
|
||||
if (!req.user.checkCanAccessLibrary(reqBody.libraryId)) {
|
||||
Logger.warn(`[PlaylistController] User "${req.user.username}" attempted to create playlist in inaccessible library ${reqBody.libraryId}`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
const items = reqBody.items || []
|
||||
const isPodcast = items.some((i) => i.episodeId)
|
||||
const libraryItemIds = new Set()
|
||||
|
|
@ -133,8 +137,9 @@ class PlaylistController {
|
|||
*/
|
||||
async findAllForUser(req, res) {
|
||||
const playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id)
|
||||
const accessiblePlaylists = playlistsForUser.filter((p) => req.user.checkCanAccessLibrary(p.libraryId))
|
||||
res.json({
|
||||
playlists: playlistsForUser
|
||||
playlists: accessiblePlaylists
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -508,6 +513,10 @@ class PlaylistController {
|
|||
if (!collection) {
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
if (!req.user.checkCanAccessLibrary(collection.libraryId)) {
|
||||
Logger.warn(`[PlaylistController] User "${req.user.username}" attempted to create playlist from collection ${collection.id} in inaccessible library ${collection.libraryId}`)
|
||||
return res.status(404).send('Collection not found')
|
||||
}
|
||||
// Expand collection to get library items
|
||||
const collectionExpanded = await collection.getOldJsonExpanded(req.user)
|
||||
if (!collectionExpanded) {
|
||||
|
|
@ -573,6 +582,10 @@ class PlaylistController {
|
|||
Logger.warn(`[PlaylistController] Playlist ${req.params.id} requested by user ${req.user.id} that is not the owner`)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
if (!req.user.checkCanAccessLibrary(playlist.libraryId)) {
|
||||
Logger.warn(`[PlaylistController] User "${req.user.username}" attempted to access playlist ${playlist.id} in inaccessible library ${playlist.libraryId}`)
|
||||
return res.status(404).send('Playlist not found')
|
||||
}
|
||||
req.playlist = playlist
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const SocketAuthority = require('../SocketAuthority')
|
|||
const Database = require('../Database')
|
||||
|
||||
const fs = require('../libs/fsExtra')
|
||||
const cron = require('../libs/nodeCron')
|
||||
|
||||
const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils')
|
||||
const { getFileTimestampsWithIno, filePathToPOSIX, isSameOrSubPath } = require('../utils/fileUtils')
|
||||
|
|
@ -46,6 +47,11 @@ class PodcastController {
|
|||
return res.status(400).send('Invalid request body. "media" and "media.metadata" are required')
|
||||
}
|
||||
|
||||
if (payload.media.autoDownloadSchedule && !cron.validate(payload.media.autoDownloadSchedule)) {
|
||||
Logger.error(`[PodcastController] Invalid auto download schedule cron expression "${payload.media.autoDownloadSchedule}"`)
|
||||
return res.status(400).send('Invalid auto download schedule cron expression')
|
||||
}
|
||||
|
||||
const library = await Database.libraryModel.findByIdWithFolders(payload.libraryId)
|
||||
if (!library) {
|
||||
Logger.error(`[PodcastController] Create: Library not found "${payload.libraryId}"`)
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ class ShareController {
|
|||
if (playbackSession) {
|
||||
if (mediaItemShare.id === playbackSession.mediaItemShareId) {
|
||||
Logger.debug(`[ShareController] Found share playback session ${req.cookies.share_session_id}`)
|
||||
// If ?t was provided, override the cached currentTime
|
||||
if (startTime > 0 && startTime < playbackSession.duration) {
|
||||
playbackSession.currentTime = startTime
|
||||
}
|
||||
mediaItemShare.playbackSession = playbackSession.toJSONForClient()
|
||||
return res.json(mediaItemShare)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -42,11 +42,14 @@ class ApiCacheManager {
|
|||
}
|
||||
|
||||
clearUserProgressSlices(modelName, hook) {
|
||||
const removedPersonalized = this.modelsInvalidatingPersonalized.has(modelName) ? this.clearByUrlPattern(/^\/libraries\/[^/]+\/personalized/) : 0
|
||||
let removedPersonalized = 0
|
||||
let removedRecentEpisodes = 0
|
||||
if (this.modelsInvalidatingPersonalized.has(modelName)) {
|
||||
removedPersonalized = this.clearByUrlPattern(/^\/libraries\/[^/]+\/personalized/)
|
||||
removedRecentEpisodes = this.clearByUrlPattern(/^\/libraries\/[^/]+\/recent-episodes/)
|
||||
}
|
||||
const removedMe = this.modelsInvalidatingMe.has(modelName) ? this.clearByUrlPattern(/^\/me(\/|\?|$)/) : 0
|
||||
Logger.debug(
|
||||
`[ApiCacheManager] ${modelName}.${hook}: cleared user-progress cache slices (personalized=${removedPersonalized}, me=${removedMe})`
|
||||
)
|
||||
Logger.debug(`[ApiCacheManager] ${modelName}.${hook}: cleared user-progress cache slices (personalized=${removedPersonalized}, recentEpisodes=${removedRecentEpisodes}, me=${removedMe})`)
|
||||
}
|
||||
|
||||
clear(model, hook) {
|
||||
|
|
|
|||
|
|
@ -153,6 +153,11 @@ class CronManager {
|
|||
|
||||
startPodcastCron(expression, libraryItemIds) {
|
||||
try {
|
||||
if (!cron.validate(expression)) {
|
||||
Logger.error(`[CronManager] Invalid auto download schedule cron expression "${expression}" - not starting podcast episode check cron`)
|
||||
return
|
||||
}
|
||||
|
||||
Logger.debug(`[CronManager] Scheduling podcast episode check cron "${expression}" for ${libraryItemIds.length} item(s)`)
|
||||
const task = cron.schedule(expression, () => {
|
||||
if (this.podcastCronExpressionsExecuting.includes(expression)) {
|
||||
|
|
@ -167,7 +172,7 @@ class CronManager {
|
|||
task
|
||||
})
|
||||
} catch (error) {
|
||||
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${this.serverSettings.podcastEpisodeSchedule}`, error)
|
||||
Logger.error(`[PodcastManager] Failed to schedule podcast cron ${expression}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ class Podcast extends Model {
|
|||
*/
|
||||
static async createFromRequest(payload, transaction) {
|
||||
const title = typeof payload.metadata.title === 'string' ? payload.metadata.title : null
|
||||
// cron expression validated in controller
|
||||
const autoDownloadSchedule = typeof payload.autoDownloadSchedule === 'string' ? payload.autoDownloadSchedule : null
|
||||
const genres = Array.isArray(payload.metadata.genres) && payload.metadata.genres.every((g) => typeof g === 'string' && g.length) ? payload.metadata.genres : []
|
||||
const tags = Array.isArray(payload.tags) && payload.tags.every((t) => typeof t === 'string' && t.length) ? payload.tags : []
|
||||
|
|
@ -89,6 +90,9 @@ class Podcast extends Model {
|
|||
}
|
||||
})
|
||||
|
||||
const rawDescription = typeof payload.metadata.description === 'string' ? payload.metadata.description : null
|
||||
const description = rawDescription ? htmlSanitizer.sanitize(rawDescription) : null
|
||||
|
||||
return this.create(
|
||||
{
|
||||
title,
|
||||
|
|
@ -97,7 +101,7 @@ class Podcast extends Model {
|
|||
releaseDate: typeof payload.metadata.releaseDate === 'string' ? payload.metadata.releaseDate : null,
|
||||
feedURL: typeof payload.metadata.feedUrl === 'string' ? payload.metadata.feedUrl : null,
|
||||
imageURL: typeof payload.metadata.imageUrl === 'string' ? payload.metadata.imageUrl : null,
|
||||
description: typeof payload.metadata.description === 'string' ? payload.metadata.description : null,
|
||||
description,
|
||||
itunesPageURL: typeof payload.metadata.itunesPageUrl === 'string' ? payload.metadata.itunesPageUrl : null,
|
||||
itunesId: typeof payload.metadata.itunesId === 'string' ? payload.metadata.itunesId : null,
|
||||
itunesArtistId: typeof payload.metadata.itunesArtistId === 'string' ? payload.metadata.itunesArtistId : null,
|
||||
|
|
@ -270,6 +274,7 @@ class Podcast extends Model {
|
|||
hasUpdates = true
|
||||
}
|
||||
if (typeof payload.autoDownloadSchedule === 'string' && payload.autoDownloadSchedule !== this.autoDownloadSchedule) {
|
||||
// cron expression validated in controller
|
||||
this.autoDownloadSchedule = payload.autoDownloadSchedule
|
||||
hasUpdates = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const axios = require('axios')
|
||||
const ssrfFilter = require('ssrf-req-filter')
|
||||
const Ffmpeg = require('../libs/fluentFfmpeg')
|
||||
const ffmpgegUtils = require('../libs/fluentFfmpeg/utils')
|
||||
const fs = require('../libs/fsExtra')
|
||||
|
|
@ -97,6 +98,8 @@ async function resizeImage(filePath, outputPath, width, height) {
|
|||
module.exports.resizeImage = resizeImage
|
||||
|
||||
/**
|
||||
* Download podcast episode
|
||||
* Uses SSRF filter to prevent internal URLs
|
||||
*
|
||||
* @param {import('../objects/PodcastEpisodeDownload')} podcastEpisodeDownload
|
||||
* @returns {Promise<{success: boolean, isRequestError?: boolean}>}
|
||||
|
|
@ -121,7 +124,9 @@ module.exports.downloadPodcastEpisode = (podcastEpisodeDownload) => {
|
|||
Accept: '*/*',
|
||||
'User-Agent': userAgent
|
||||
},
|
||||
timeout: global.PodcastDownloadTimeout
|
||||
timeout: global.PodcastDownloadTimeout,
|
||||
httpAgent: global.DisableSsrfRequestFilter?.(podcastEpisodeDownload.url) ? null : ssrfFilter(podcastEpisodeDownload.url),
|
||||
httpsAgent: global.DisableSsrfRequestFilter?.(podcastEpisodeDownload.url) ? null : ssrfFilter(podcastEpisodeDownload.url)
|
||||
})
|
||||
|
||||
Logger.debug(`[ffmpegHelpers] Successfully connected with User-Agent: ${userAgent}`)
|
||||
|
|
|
|||
|
|
@ -123,7 +123,9 @@ describe('LibraryItemController', () => {
|
|||
const fakeReq = {
|
||||
query: {},
|
||||
user: {
|
||||
canDelete: true
|
||||
username: 'test',
|
||||
canDelete: true,
|
||||
checkCanAccessLibraryItem: () => true
|
||||
},
|
||||
body: {
|
||||
libraryItemIds: [libraryItem1Id]
|
||||
|
|
@ -199,4 +201,102 @@ describe('LibraryItemController', () => {
|
|||
expect(series2Exists).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('batch item access control', () => {
|
||||
let lib1Id
|
||||
let itemLib1Id
|
||||
let itemLib2Id
|
||||
|
||||
beforeEach(async () => {
|
||||
const lib1 = await Database.libraryModel.create({ name: 'Lib 1', mediaType: 'book' })
|
||||
const folder1 = await Database.libraryFolderModel.create({ path: '/l1', libraryId: lib1.id })
|
||||
const book1 = await Database.bookModel.create({ title: 'B1', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||
const li1 = await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: book1.id,
|
||||
mediaType: 'book',
|
||||
libraryId: lib1.id,
|
||||
libraryFolderId: folder1.id
|
||||
})
|
||||
lib1Id = lib1.id
|
||||
itemLib1Id = li1.id
|
||||
|
||||
const lib2 = await Database.libraryModel.create({ name: 'Lib 2', mediaType: 'book' })
|
||||
const folder2 = await Database.libraryFolderModel.create({ path: '/l2', libraryId: lib2.id })
|
||||
const book2 = await Database.bookModel.create({ title: 'B2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
|
||||
const li2 = await Database.libraryItemModel.create({
|
||||
libraryFiles: [],
|
||||
mediaId: book2.id,
|
||||
mediaType: 'book',
|
||||
libraryId: lib2.id,
|
||||
libraryFolderId: folder2.id
|
||||
})
|
||||
itemLib2Id = li2.id
|
||||
})
|
||||
|
||||
const userLimitedToLib1 = () => ({
|
||||
username: 'limited',
|
||||
canDelete: true,
|
||||
canUpdate: true,
|
||||
checkCanAccessLibraryItem(li) {
|
||||
return li.libraryId === lib1Id
|
||||
}
|
||||
})
|
||||
|
||||
it('batchGet returns 403 for a library item the user cannot access', async () => {
|
||||
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
|
||||
const fakeReq = {
|
||||
body: { libraryItemIds: [itemLib2Id] },
|
||||
user: userLimitedToLib1()
|
||||
}
|
||||
await LibraryItemController.batchGet.bind(apiRouter)(fakeReq, fakeRes)
|
||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||
})
|
||||
|
||||
it('batchGet returns items when the user can access them', async () => {
|
||||
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
|
||||
const fakeReq = {
|
||||
body: { libraryItemIds: [itemLib1Id] },
|
||||
user: userLimitedToLib1()
|
||||
}
|
||||
await LibraryItemController.batchGet.bind(apiRouter)(fakeReq, fakeRes)
|
||||
expect(fakeRes.json.calledOnce).to.be.true
|
||||
const payload = fakeRes.json.firstCall.args[0]
|
||||
expect(payload.libraryItems).to.have.length(1)
|
||||
expect(payload.libraryItems[0].id).to.equal(itemLib1Id)
|
||||
})
|
||||
|
||||
it('batchUpdate returns 403 for a library item the user cannot access', async () => {
|
||||
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
|
||||
const fakeReq = {
|
||||
user: userLimitedToLib1(),
|
||||
body: [{ id: itemLib2Id, mediaPayload: {} }]
|
||||
}
|
||||
await LibraryItemController.batchUpdate.bind(apiRouter)(fakeReq, fakeRes)
|
||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||
})
|
||||
|
||||
it('batchUpdate returns 403 when the user lacks canUpdate', async () => {
|
||||
const u = userLimitedToLib1()
|
||||
u.canUpdate = false
|
||||
const fakeRes = { sendStatus: sinon.spy(), json: sinon.spy() }
|
||||
const fakeReq = {
|
||||
user: u,
|
||||
body: [{ id: itemLib1Id, mediaPayload: {} }]
|
||||
}
|
||||
await LibraryItemController.batchUpdate.bind(apiRouter)(fakeReq, fakeRes)
|
||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||
})
|
||||
|
||||
it('batchDelete returns 403 for a library item the user cannot access', async () => {
|
||||
const fakeRes = { sendStatus: sinon.spy() }
|
||||
const fakeReq = {
|
||||
query: {},
|
||||
user: userLimitedToLib1(),
|
||||
body: { libraryItemIds: [itemLib2Id] }
|
||||
}
|
||||
await LibraryItemController.batchDelete.bind(apiRouter)(fakeReq, fakeRes)
|
||||
expect(fakeRes.sendStatus.calledWith(403)).to.be.true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Import dependencies and modules for testing
|
||||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
const { LRUCache } = require('lru-cache')
|
||||
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
|
||||
|
||||
describe('ApiCacheManager', () => {
|
||||
|
|
@ -94,4 +95,17 @@ describe('ApiCacheManager', () => {
|
|||
expect(res.originalSend.calledWith(body)).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear on mediaProgress', () => {
|
||||
it('should remove recent-episodes cache entries', () => {
|
||||
const key = JSON.stringify({ user: 'u', url: '/libraries/abc-123/recent-episodes?limit=50&page=0' })
|
||||
const cache = new LRUCache({ max: 10 })
|
||||
cache.set(key, { body: '[]', headers: {}, statusCode: 200 })
|
||||
const manager = new ApiCacheManager(cache)
|
||||
|
||||
manager.clear({ name: 'mediaProgress' }, 'afterUpdate')
|
||||
|
||||
expect(cache.get(key)).to.be.undefined
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue