mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(book-requests): link releases to their source page and hold imports for review
Torznab and Newznab read the feed's details page, plugins may send infoUrl, and the search drops any link that could carry a credential. A grab can ask to be held for review whatever its score, and the held review shows the file's own edition facts, its cover and the release page. Torznab guids are now digested like Newznab's. Refs #1438
This commit is contained in:
@@ -85,6 +85,7 @@ function release(overrides: Partial<ReleaseCandidateItem> = {}): ReleaseCandidat
|
||||
indexerName: 'MyAnonaMouse',
|
||||
guid: 'g1',
|
||||
title: 'Spark of the Everflame',
|
||||
infoUrl: null,
|
||||
sizeBytes: 3_100_000,
|
||||
seeders: 506,
|
||||
leechers: 2,
|
||||
@@ -671,6 +672,70 @@ describe('ReleasePickerPanel', () => {
|
||||
expect(grabButton(rowFor('libgen-1')).attributes('disabled')).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('the release page', () => {
|
||||
it("links the release title to its source's page, out of this instance's referrer", async () => {
|
||||
const wrapper = await render({ ...MIXED, releases: [release({ infoUrl: 'https://tracker.example.com/t/42' })], indexers: [status()] })
|
||||
|
||||
const link = releaseRows(wrapper)[0]!.get('a[href="https://tracker.example.com/t/42"]')
|
||||
expect(link.attributes('target')).toBe('_blank')
|
||||
expect(link.attributes('rel')).toBe('noopener noreferrer')
|
||||
// The visible title leads the accessible name, which also says where the link goes.
|
||||
expect(link.attributes('aria-label')).toMatch(/^.+, release page on MyAnonaMouse$/)
|
||||
})
|
||||
|
||||
it('leaves the title as plain text when the source published no page', async () => {
|
||||
const wrapper = await render({ ...MIXED, releases: [release({ infoUrl: null })], indexers: [status()] })
|
||||
|
||||
expect(releaseRows(wrapper)[0]!.find('a').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reviewing before filing', () => {
|
||||
function grabBodies(): Array<Record<string, unknown>> {
|
||||
return api.mock.calls.filter(([input]) => input.includes('/grab')).map(([, init]) => JSON.parse(String(init?.body)) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
async function renderGrabbable() {
|
||||
api.mockImplementation((input: string) => {
|
||||
if (input.includes('/releases/inspect')) return respond(READY_INSPECTION)
|
||||
if (input.includes('/releases')) return respond({ ...MIXED, releases: [release()], indexers: [status()] })
|
||||
if (input.includes('download-clients')) return respond([])
|
||||
if (input.includes('/grab')) return respond(request({ status: 'grabbed' }))
|
||||
return respond(request())
|
||||
})
|
||||
const wrapper = mount(ReleasePickerPanel, { global: { stubs: { RouterLink: RouterLinkStub } } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function sendButton(wrapper: VueWrapper) {
|
||||
return releaseRows(wrapper)[0]!
|
||||
.findAll('button')
|
||||
.find((button) => /Download release|Send/.test(button.text()))!
|
||||
}
|
||||
|
||||
it('asks the server to hold the import when the approver ticks the box', async () => {
|
||||
const wrapper = await renderGrabbable()
|
||||
|
||||
await wrapper.get('input[type="checkbox"]').setValue(true)
|
||||
await sendButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(grabBodies()).toEqual([expect.objectContaining({ releaseGuid: 'g1', holdForReview: true })])
|
||||
})
|
||||
|
||||
/** An older server rejects a field it does not know, so an unticked grab must not send it at all. */
|
||||
it('sends nothing new when the box is left alone', async () => {
|
||||
const wrapper = await renderGrabbable()
|
||||
|
||||
await sendButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(grabBodies()).toHaveLength(1)
|
||||
expect(grabBodies()[0]).not.toHaveProperty('holdForReview')
|
||||
})
|
||||
})
|
||||
|
||||
it('still reports an unknown count on a swarm source', async () => {
|
||||
const wrapper = await render({
|
||||
...MIXED,
|
||||
|
||||
@@ -23,13 +23,17 @@ function review(overrides: Partial<BookRequestReview> = {}): BookRequestReview {
|
||||
files: [{ fileName: 'fablehaven-01.m4b', fileSize: 402_000_000, format: 'm4b', role: 'content' }],
|
||||
totalSizeBytes: 402_000_000,
|
||||
canFile: true,
|
||||
heldOnRequest: false,
|
||||
releaseInfoUrl: null,
|
||||
edition: null,
|
||||
hasCover: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function render(overrides: Partial<BookRequestReview> = {}, props: { canManage?: boolean; busy?: boolean } = {}) {
|
||||
function render(overrides: Partial<BookRequestReview> = {}, props: { canManage?: boolean; busy?: boolean; coverUrl?: string | null } = {}) {
|
||||
return mount(RequestReviewPanel, {
|
||||
props: { review: review(overrides), canManage: props.canManage ?? true, busy: props.busy ?? false },
|
||||
props: { review: review(overrides), canManage: props.canManage ?? true, busy: props.busy ?? false, coverUrl: props.coverUrl ?? null },
|
||||
global: { stubs: { RouterLink: RouterLinkStub } },
|
||||
})
|
||||
}
|
||||
@@ -117,4 +121,75 @@ describe('RequestReviewPanel', () => {
|
||||
expect(wrapper.find('dl').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('fablehaven-01.m4b')
|
||||
})
|
||||
describe('a review the sender asked for', () => {
|
||||
const passed = {
|
||||
score: 96,
|
||||
threshold: 70,
|
||||
passed: true,
|
||||
reason: 'above_threshold' as const,
|
||||
rows: [{ field: 'title' as const, requested: 'Fablehaven', imported: 'Fablehaven', verdict: 'match' as const }],
|
||||
}
|
||||
|
||||
it('says why it is waiting and offers to file it without calling that an override', () => {
|
||||
const wrapper = render({ heldOnRequest: true, verification: passed })
|
||||
|
||||
expect(wrapper.text()).toContain('sent to be checked before filing')
|
||||
expect(wrapper.text()).toContain('scored 96')
|
||||
const file = wrapper.findAll('button').find((button) => button.text() === 'File it')
|
||||
expect(file).toBeDefined()
|
||||
expect(wrapper.text()).not.toContain('File it anyway')
|
||||
})
|
||||
|
||||
it('still calls filing an override when the import also failed its check', () => {
|
||||
expect(render({ heldOnRequest: true }).text()).toContain('File it anyway')
|
||||
})
|
||||
})
|
||||
|
||||
it('links the page of the release the import came from', () => {
|
||||
const link = render({ releaseInfoUrl: 'https://tracker.example.com/t/42' }).get('a[href="https://tracker.example.com/t/42"]')
|
||||
|
||||
expect(link.text()).toContain('Open the release page')
|
||||
expect(link.attributes('rel')).toBe('noopener noreferrer')
|
||||
expect(link.attributes('target')).toBe('_blank')
|
||||
})
|
||||
|
||||
it('lists only the edition facts the file states, beside the cover it carried', () => {
|
||||
const wrapper = render(
|
||||
{
|
||||
edition: {
|
||||
publisher: 'Brilliance Audio',
|
||||
publishedDate: '2007',
|
||||
language: 'en',
|
||||
pageCount: null,
|
||||
isbn10: null,
|
||||
narrators: ['E.B. Stevens'],
|
||||
durationSeconds: 9 * 3600 + 30 * 60,
|
||||
abridged: false,
|
||||
chapterCount: 12,
|
||||
},
|
||||
},
|
||||
{ coverUrl: '/api/v1/admin/book-requests/7/review/cover?entry=100' },
|
||||
)
|
||||
const facts = wrapper.get('dl.grid-cols-\\[auto_minmax\\(0\\,1fr\\)\\]')
|
||||
|
||||
expect(facts.text()).toContain('PublisherBrilliance Audio')
|
||||
expect(facts.text()).toContain('Narrated byE.B. Stevens')
|
||||
expect(facts.text()).toContain('VersionUnabridged')
|
||||
expect(facts.text()).toContain('Chapters12')
|
||||
expect(facts.text()).not.toContain('Pages')
|
||||
expect(wrapper.get('img').attributes('alt')).toBe('Cover found in the imported file')
|
||||
})
|
||||
|
||||
it('draws no edition section for a file that states nothing and carries no cover', () => {
|
||||
expect(render().text()).not.toContain('What the file says it is')
|
||||
})
|
||||
|
||||
it('drops a cover that will not load and keeps the facts', async () => {
|
||||
const wrapper = render({ edition: null }, { coverUrl: '/api/v1/admin/book-requests/7/review/cover?entry=100' })
|
||||
|
||||
await wrapper.get('img').trigger('error')
|
||||
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('What the file says it is')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ function release(overrides: Partial<ReleaseCandidateItem> = {}): ReleaseCandidat
|
||||
indexerName: 'jackett',
|
||||
guid: 'g1',
|
||||
title: 'Dune',
|
||||
infoUrl: null,
|
||||
sizeBytes: null,
|
||||
seeders: null,
|
||||
leechers: null,
|
||||
|
||||
@@ -11,6 +11,7 @@ function release(overrides: Partial<ReleaseCandidateItem> = {}): ReleaseCandidat
|
||||
indexerName: 'jackett',
|
||||
guid: 'g1',
|
||||
title: 'Dune',
|
||||
infoUrl: null,
|
||||
sizeBytes: null,
|
||||
seeders: null,
|
||||
leechers: null,
|
||||
|
||||
@@ -179,16 +179,17 @@ const {
|
||||
resetSearchEditorState,
|
||||
} = useReleaseSearchEditor({ request, criteria, attempts, requestId, fetchReleases, beforeSearch: startFreshSearch })
|
||||
|
||||
const { manualOpen, grabbing, openManual, closeManual, forgetRefusals, handleGrab, handleManualGrab, isRefused, refusalText } = useReleaseGrab({
|
||||
request,
|
||||
requestId,
|
||||
grab: actions.grab,
|
||||
setRequest,
|
||||
inspectRelease,
|
||||
setFilesExpanded,
|
||||
deliveryFor,
|
||||
afterGrab,
|
||||
})
|
||||
const { manualOpen, grabbing, holdForReview, openManual, closeManual, forgetRefusals, handleGrab, handleManualGrab, isRefused, refusalText } =
|
||||
useReleaseGrab({
|
||||
request,
|
||||
requestId,
|
||||
grab: actions.grab,
|
||||
setRequest,
|
||||
inspectRelease,
|
||||
setFilesExpanded,
|
||||
deliveryFor,
|
||||
afterGrab,
|
||||
})
|
||||
|
||||
/**
|
||||
* Every Send is off while one release is on its way, not only the row being inspected. The
|
||||
@@ -1201,19 +1202,30 @@ function showEverySource(): void {
|
||||
constant on a single-indexer search, and dropping them gives the two that actually
|
||||
differ the width the release title was being squeezed out of.
|
||||
-->
|
||||
<p class="mt-3.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<span class="font-medium text-foreground">{{ t('bookRequests.releases.summaryCount', { count: releases.length }) }}</span>
|
||||
<template v-for="fact in summaryFacts" :key="fact">
|
||||
<span aria-hidden="true" class="opacity-50">·</span>
|
||||
<span>{{ fact }}</span>
|
||||
</template>
|
||||
<template v-if="sharedIndexer">
|
||||
<span aria-hidden="true" class="opacity-50">·</span>
|
||||
<span class="rounded-full border px-1.5 py-px font-medium" :class="sharedIndexerClass">
|
||||
{{ t('bookRequests.releases.allIndexer', { name: sharedIndexer }) }}
|
||||
</span>
|
||||
</template>
|
||||
</p>
|
||||
<div class="mt-3.5 flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||
<p class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
||||
<span class="font-medium text-foreground">{{ t('bookRequests.releases.summaryCount', { count: releases.length }) }}</span>
|
||||
<template v-for="fact in summaryFacts" :key="fact">
|
||||
<span aria-hidden="true" class="opacity-50">·</span>
|
||||
<span>{{ fact }}</span>
|
||||
</template>
|
||||
<template v-if="sharedIndexer">
|
||||
<span aria-hidden="true" class="opacity-50">·</span>
|
||||
<span class="rounded-full border px-1.5 py-px font-medium" :class="sharedIndexerClass">
|
||||
{{ t('bookRequests.releases.allIndexer', { name: sharedIndexer }) }}
|
||||
</span>
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<!--
|
||||
Beside the list it governs rather than on each row: it answers a doubt about the
|
||||
request, and it applies to a magnet pasted by hand as much as to a ranked release.
|
||||
-->
|
||||
<label class="flex cursor-pointer items-center gap-2 text-xs text-foreground">
|
||||
<input v-model="holdForReview" type="checkbox" class="size-3.5 shrink-0 rounded-xs border-border accent-primary" />
|
||||
{{ t('bookRequests.releases.holdForReview') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="nothingMatchedProfile"
|
||||
@@ -1344,7 +1356,25 @@ function showEverySource(): void {
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p class="line-clamp-2 text-sm font-medium text-foreground">{{ release.title }}</p>
|
||||
<!--
|
||||
The title is the link because it is what the source page describes, and a
|
||||
separate button would be a third control competing with Send on every row.
|
||||
`noreferrer` keeps a private tracker from learning this instance's address.
|
||||
-->
|
||||
<p class="line-clamp-2 text-sm font-medium text-foreground">
|
||||
<a
|
||||
v-if="release.infoUrl"
|
||||
:href="release.infoUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
:aria-label="t('bookRequests.releases.releasePageLabel', { title: release.title, indexer: release.indexerName })"
|
||||
class="rounded-xs underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
>
|
||||
{{ release.title }}
|
||||
<ArrowUpRight :size="13" class="inline-block align-[-2px] text-muted-foreground" aria-hidden="true" />
|
||||
</a>
|
||||
<template v-else>{{ release.title }}</template>
|
||||
</p>
|
||||
|
||||
<!--
|
||||
Two runs, not one. The chips say where the release comes from and what
|
||||
|
||||
@@ -84,7 +84,7 @@ const { load: loadDefaultDestination, defaultFor: instanceDefaultFor, resolveDes
|
||||
const { progressByRequest, onRequestsChanged, pruneSettledProgress } = useBookRequestProgress()
|
||||
const actions = useBookRequestActions(canManageFn)
|
||||
const picker = useFulfilmentPicker()
|
||||
const { review, fetchReview, reset: resetReview } = useRequestReview(canManageFn)
|
||||
const { review, coverUrl: reviewCoverUrl, fetchReview, reset: resetReview } = useRequestReview(canManageFn)
|
||||
|
||||
const decisionNote = ref('')
|
||||
/**
|
||||
@@ -128,7 +128,9 @@ const isHeld = computed(() => request.value?.status === 'needs_review')
|
||||
* stays. It also stays for every ordinary failure, which has no panel at all.
|
||||
*/
|
||||
const failureText = computed(() => (request.value ? requestFailureText(request.value, (key, named) => t(key, named)) : null))
|
||||
const showFailureReason = computed(() => failureText.value != null && !(isHeld.value && review.value?.verification != null))
|
||||
const showFailureReason = computed(
|
||||
() => failureText.value != null && !(isHeld.value && (review.value?.verification != null || review.value?.heldOnRequest === true)),
|
||||
)
|
||||
/** Only a request that stopped badly earns the destructive treatment; a hand-back is information. */
|
||||
const failureIsAlarming = computed(() => request.value?.status === 'failed' || request.value?.status === 'needs_review')
|
||||
/** Whoever is going to say it, the transfer line is not the one that should say it twice. */
|
||||
@@ -777,6 +779,7 @@ function dockFileMeta(file: BookDockFile): string {
|
||||
<RequestReviewPanel
|
||||
v-if="isHeld && review"
|
||||
:review="review"
|
||||
:cover-url="reviewCoverUrl"
|
||||
:can-manage="canFulfilThis"
|
||||
:busy="busy"
|
||||
@file="handleFileAnyway"
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Check, FileText, Image, Loader2, Minus, Paperclip, Tags, Trash2, X } from '@lucide/vue'
|
||||
import { Check, ExternalLink, FileText, Image, Loader2, Minus, Paperclip, Tags, Trash2, X } from '@lucide/vue'
|
||||
import type { BookRequestReview, BookRequestReviewFile, BookRequestVerificationRow } from '@bookorbit/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatLanguageName, formatList, formatNumber } from '@/i18n/formatters'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
|
||||
const props = defineProps<{
|
||||
review: BookRequestReview
|
||||
/** The cover found in the imported file, or null where it carried none. */
|
||||
coverUrl?: string | null
|
||||
canManage: boolean
|
||||
busy: boolean
|
||||
}>()
|
||||
@@ -35,6 +38,58 @@ const headline = computed(() => {
|
||||
: t('bookRequests.review.scoreShort', { score: detail.score, threshold: detail.threshold })
|
||||
})
|
||||
|
||||
/**
|
||||
* "File it anyway" is an override, which is the wrong word for an import that passed and is only
|
||||
* waiting because whoever sent it asked to look first.
|
||||
*/
|
||||
const fileIsOverride = computed(() => !props.review.heldOnRequest || verification.value?.passed === false)
|
||||
|
||||
interface EditionRow {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** Only what the file states. A row of "Not set" for every fact an ebook never carries is noise. */
|
||||
const editionRows = computed<EditionRow[]>(() => {
|
||||
const edition = props.review.edition
|
||||
if (!edition) return []
|
||||
const rows: EditionRow[] = []
|
||||
const add = (key: string, value: string | null) => {
|
||||
if (value) rows.push({ key, label: t(`bookRequests.review.edition.${key}`), value })
|
||||
}
|
||||
add('publisher', edition.publisher)
|
||||
add('published', edition.publishedDate)
|
||||
add('language', edition.language ? formatLanguageName(edition.language) : null)
|
||||
add('pages', edition.pageCount !== null ? formatNumber(edition.pageCount) : null)
|
||||
add('isbn10', edition.isbn10)
|
||||
add('narrators', edition.narrators.length ? formatList(edition.narrators) : null)
|
||||
add('length', edition.durationSeconds !== null ? formatDuration(edition.durationSeconds) : null)
|
||||
add('version', edition.abridged === null ? null : t(edition.abridged ? 'bookRequests.review.abridged' : 'bookRequests.review.unabridged'))
|
||||
add('chapters', edition.chapterCount !== null ? formatNumber(edition.chapterCount) : null)
|
||||
return rows
|
||||
})
|
||||
|
||||
/** A cover the server listed can still fail to load; the facts beside it stand on their own. */
|
||||
const coverFailed = ref(false)
|
||||
watch(
|
||||
() => props.coverUrl,
|
||||
() => {
|
||||
coverFailed.value = false
|
||||
},
|
||||
)
|
||||
const showCover = computed(() => Boolean(props.coverUrl) && !coverFailed.value)
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.round((seconds % 3600) / 60)
|
||||
return hours > 0 ? t('bookRequests.releases.durationHm', { hours, minutes }) : t('bookRequests.releases.durationM', { minutes })
|
||||
}
|
||||
|
||||
function handleCoverError() {
|
||||
coverFailed.value = true
|
||||
}
|
||||
|
||||
const filesSummary = computed(() => {
|
||||
const count = t('bookRequests.review.fileCount', { count: props.review.files.length })
|
||||
return props.review.totalSizeBytes === null ? count : `${count} · ${formatBytes(props.review.totalSizeBytes)}`
|
||||
@@ -64,7 +119,20 @@ function handleDiscard() {
|
||||
|
||||
<template>
|
||||
<section class="rounded-lg border border-border bg-card p-4" :aria-label="t('bookRequests.review.title')">
|
||||
<h3 class="text-xs font-semibold tracking-wide text-muted-foreground uppercase">{{ t('bookRequests.review.title') }}</h3>
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
||||
<h3 class="text-xs font-semibold tracking-wide text-muted-foreground uppercase">{{ t('bookRequests.review.title') }}</h3>
|
||||
<!-- The source's own description of what was sent, to hold the file below up against. -->
|
||||
<a
|
||||
v-if="props.review.releaseInfoUrl"
|
||||
:href="props.review.releaseInfoUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 rounded-sm text-xs text-foreground hover:text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
>
|
||||
<span class="underline underline-offset-2">{{ t('bookRequests.review.releasePage') }}</span>
|
||||
<ExternalLink :size="12" class="shrink-0" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<template v-if="entryGone">
|
||||
<p class="mt-2 text-sm text-foreground">{{ t('bookRequests.review.missingEntry') }}</p>
|
||||
@@ -72,7 +140,8 @@ function handleDiscard() {
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="mt-2 text-sm text-foreground">{{ headline }}</p>
|
||||
<p v-if="props.review.heldOnRequest" class="mt-2 text-sm text-foreground">{{ t('bookRequests.review.heldOnRequest') }}</p>
|
||||
<p class="text-sm" :class="props.review.heldOnRequest ? 'mt-1 text-muted-foreground' : 'mt-2 text-foreground'">{{ headline }}</p>
|
||||
<p v-if="verification" class="mt-1 text-sm text-muted-foreground">{{ t(`bookRequests.review.reason.${verification.reason}`) }}</p>
|
||||
|
||||
<dl v-if="verification" class="mt-4 grid gap-3">
|
||||
@@ -99,6 +168,27 @@ function handleDiscard() {
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<template v-if="editionRows.length || showCover">
|
||||
<h4 class="mt-4 border-t border-border pt-3 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
{{ t('bookRequests.review.editionTitle') }}
|
||||
</h4>
|
||||
<div class="mt-2 flex items-start gap-3">
|
||||
<img
|
||||
v-if="showCover"
|
||||
:src="props.coverUrl ?? undefined"
|
||||
:alt="t('bookRequests.review.importedCover')"
|
||||
class="h-[90px] w-[60px] shrink-0 rounded-sm border border-border object-cover"
|
||||
@error="handleCoverError"
|
||||
/>
|
||||
<dl v-if="editionRows.length" class="grid min-w-0 flex-1 grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1">
|
||||
<template v-for="row in editionRows" :key="row.key">
|
||||
<dt class="text-xs text-muted-foreground">{{ row.label }}</dt>
|
||||
<dd class="break-words text-sm text-foreground">{{ row.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<h4 class="mt-4 border-t border-border pt-3 text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
{{ t('bookRequests.review.filesTitle') }}
|
||||
</h4>
|
||||
@@ -119,7 +209,7 @@ function handleDiscard() {
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||
<Button :disabled="!props.review.canFile || props.busy" @click="handleFile">
|
||||
<Loader2 v-if="props.busy" class="size-4 animate-spin" aria-hidden="true" />
|
||||
{{ t('bookRequests.review.fileAnyway') }}
|
||||
{{ fileIsOverride ? t('bookRequests.review.fileAnyway') : t('bookRequests.review.fileIt') }}
|
||||
</Button>
|
||||
<!--
|
||||
The other answer, beside the first rather than behind a menu: a held import is a
|
||||
@@ -137,7 +227,9 @@ function handleDiscard() {
|
||||
{{ t('bookRequests.review.openDock') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-muted-foreground">{{ t('bookRequests.review.fileAnywayHint') }}</p>
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
{{ fileIsOverride ? t('bookRequests.review.fileAnywayHint') : t('bookRequests.review.fileItHint') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ t('bookRequests.review.discardHint') }}</p>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -67,6 +67,12 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
|
||||
* called yet while the inspections run.
|
||||
*/
|
||||
const grabbing = ref(false)
|
||||
/**
|
||||
* Whether whatever this picker sends waits in review once it lands, whatever its score. One
|
||||
* choice for the visit rather than one per row: the doubt it answers is about the request, and
|
||||
* a magnet pasted by hand is just as likely to be the wrong edition as a ranked release.
|
||||
*/
|
||||
const holdForReview = ref(false)
|
||||
|
||||
function openManual() {
|
||||
manualOpen.value = true
|
||||
@@ -84,7 +90,7 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
|
||||
/** A sent release leaves nothing to pick, so the drawer pops back to the request it belongs to, unless told otherwise. */
|
||||
async function sendGrab(body: GrabBookRequestPayload, delivery: DownloadDelivery) {
|
||||
if (!request.value) return
|
||||
const outcome = await grab(request.value.id, body)
|
||||
const outcome = await grab(request.value.id, holdForReview.value ? { ...body, holdForReview: true } : body)
|
||||
if (!outcome.item) {
|
||||
rememberRefusal(body, delivery, outcome.errorCode)
|
||||
toast.error(t('bookRequests.errors.grabFailed'), outcome.reason ? { description: outcome.reason } : undefined)
|
||||
@@ -150,5 +156,18 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
|
||||
return key ? t(`bookRequests.releases.refused.${key}`) : null
|
||||
}
|
||||
|
||||
return { manualOpen, grabbing, refusals, openManual, closeManual, forgetRefusals, submitGrab, handleGrab, handleManualGrab, isRefused, refusalText }
|
||||
return {
|
||||
manualOpen,
|
||||
grabbing,
|
||||
holdForReview,
|
||||
refusals,
|
||||
openManual,
|
||||
closeManual,
|
||||
forgetRefusals,
|
||||
submitGrab,
|
||||
handleGrab,
|
||||
handleManualGrab,
|
||||
isRefused,
|
||||
refusalText,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { fulfilmentBase } from '../fulfilmentBase'
|
||||
import type { BookRequestReview } from '@bookorbit/types'
|
||||
@@ -44,6 +44,16 @@ export function useRequestReview(canManage: () => boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyed on the dock entry as well as the request: discarding an import and grabbing another
|
||||
* leaves the request id unchanged, and the browser would otherwise keep showing the first cover.
|
||||
*/
|
||||
const coverUrl = computed(() => {
|
||||
const current = review.value
|
||||
if (!current?.hasCover || current.bookDockFileId === null) return null
|
||||
return `${basePath()}/${current.requestId}/review/cover?entry=${current.bookDockFileId}`
|
||||
})
|
||||
|
||||
function reset(): void {
|
||||
generation++
|
||||
review.value = null
|
||||
@@ -51,5 +61,5 @@ export function useRequestReview(canManage: () => boolean) {
|
||||
failed.value = false
|
||||
}
|
||||
|
||||
return { review, loading, failed, fetchReview, reset }
|
||||
return { review, coverUrl, loading, failed, fetchReview, reset }
|
||||
}
|
||||
|
||||
@@ -8499,6 +8499,8 @@
|
||||
"clientUnavailable": "Your download client did not answer an earlier release"
|
||||
},
|
||||
"singleFileNote": "A release can be one book in several formats, an audiobook in parts, or an archive - all of those import as a single book. A release holding several separate books asks you which one to keep.",
|
||||
"holdForReview": "Review the download before it is filed",
|
||||
"releasePageLabel": "{title}, release page on {indexer}",
|
||||
"searching": "Searching indexers...",
|
||||
"refresh": "Search again",
|
||||
"cached": "Showing the last search. Search again for fresh results.",
|
||||
@@ -8816,6 +8818,7 @@
|
||||
"scoreShort": "The imported file scored {score} against this request, below the {threshold} needed to file it automatically.",
|
||||
"scoreClears": "The imported file scored {score}, which clears the {threshold} needed to file it automatically.",
|
||||
"verificationOff": "Import checking is off, so this file was never scored against the request.",
|
||||
"heldOnRequest": "This release was sent to be checked before filing, so it is waiting for you.",
|
||||
"reason": {
|
||||
"isbn_match": "Its ISBN is the one that was requested.",
|
||||
"above_threshold": "It looks like the book that was asked for.",
|
||||
@@ -8835,8 +8838,24 @@
|
||||
},
|
||||
"requested": "Requested",
|
||||
"imported": "Imported",
|
||||
"releasePage": "Open the release page",
|
||||
"absent": "Neither the request nor the file has one.",
|
||||
"notSet": "Not set",
|
||||
"editionTitle": "What the file says it is",
|
||||
"edition": {
|
||||
"publisher": "Publisher",
|
||||
"published": "Published",
|
||||
"language": "Language",
|
||||
"pages": "Pages",
|
||||
"isbn10": "ISBN-10",
|
||||
"narrators": "Narrated by",
|
||||
"length": "Length",
|
||||
"version": "Version",
|
||||
"chapters": "Chapters"
|
||||
},
|
||||
"abridged": "Abridged",
|
||||
"unabridged": "Unabridged",
|
||||
"importedCover": "Cover found in the imported file",
|
||||
"filesTitle": "What landed",
|
||||
"fileCount": "{count, plural, one {# file} other {# files}}",
|
||||
"role": {
|
||||
@@ -8846,6 +8865,8 @@
|
||||
},
|
||||
"fileAnyway": "File it anyway",
|
||||
"fileAnywayHint": "Files this into the destination library and marks the request available, exactly as a passing score would have.",
|
||||
"fileIt": "File it",
|
||||
"fileItHint": "Files this into the destination library and marks the request available.",
|
||||
"noDestination": "This request has no destination library with a folder in it, so there is nowhere to file the book yet.",
|
||||
"openDock": "Open the Book Dock",
|
||||
"missingEntry": "The file this request was held over is no longer in the Book Dock. It was filed or discarded by hand, so there is nothing left here to review.",
|
||||
|
||||
@@ -99,6 +99,12 @@ export interface PluginReleaseCandidate {
|
||||
downloadUrl?: string;
|
||||
magnet?: string;
|
||||
infoHash?: string;
|
||||
/**
|
||||
* The release's page on the source, for an approver to read before sending it. An http(s) URL
|
||||
* that needs no credential: the host drops one carrying the indexer's credential or a key-like
|
||||
* query parameter rather than showing it. Ignored by hosts older than the field.
|
||||
*/
|
||||
infoUrl?: string;
|
||||
sizeBytes: number | null;
|
||||
/**
|
||||
* Null means the source reported no swarm counts, which must not be read as zero: the
|
||||
|
||||
@@ -20,6 +20,11 @@ export const SERVER_FEATURES = [
|
||||
"server-connection-identity",
|
||||
/** `GET /books/:id/annotations` and `/bookmarks` accept `readerFileId` and return positions for that file. */
|
||||
"reader-file-positions",
|
||||
/**
|
||||
* `POST .../grab` accepts `holdForReview`, release candidates carry `infoUrl`, and a held
|
||||
* review reports `heldOnRequest`, `releaseInfoUrl`, `edition` and `hasCover` with a cover route.
|
||||
*/
|
||||
"book-request-review-hold",
|
||||
] as const;
|
||||
|
||||
export type ServerFeature = (typeof SERVER_FEATURES)[number];
|
||||
|
||||
@@ -33,6 +33,26 @@ export interface BookRequestVerification {
|
||||
rows: BookRequestVerificationRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the imported file says about itself, for telling one edition from another. The comparison
|
||||
* is about the work, and two editions of one work agree on its title and author.
|
||||
*
|
||||
* Read from the file's own metadata only, never from what a provider matched it to: a provider
|
||||
* match is itself a guess at the edition, and this is what an approver checks that guess against.
|
||||
*/
|
||||
export interface BookRequestReviewEdition {
|
||||
publisher: string | null;
|
||||
/** As the file states it, which may be a year, a month or a full date. Shown, never parsed. */
|
||||
publishedDate: string | null;
|
||||
language: string | null;
|
||||
pageCount: number | null;
|
||||
isbn10: string | null;
|
||||
narrators: string[];
|
||||
durationSeconds: number | null;
|
||||
abridged: boolean | null;
|
||||
chapterCount: number | null;
|
||||
}
|
||||
|
||||
/** One file of the imported entry. Names only: a dock path is a server path and never leaves. */
|
||||
export type BookRequestReviewFile = Pick<BookDockUnitFile, "fileName" | "fileSize" | "format" | "role">;
|
||||
|
||||
@@ -58,4 +78,15 @@ export interface BookRequestReview {
|
||||
totalSizeBytes: number | null;
|
||||
/** False when the request has no library to be filed into, which force-filing cannot invent. */
|
||||
canFile: boolean;
|
||||
/**
|
||||
* Whoever sent the release asked to look at the import before it was filed, so it is held
|
||||
* whatever the score says. Absent on servers older than the `book-request-review-hold` feature.
|
||||
*/
|
||||
heldOnRequest: boolean;
|
||||
/** The page of the release this import came from, where its source published one. */
|
||||
releaseInfoUrl: string | null;
|
||||
/** Null once the dock entry is gone, and on a file that states none of it. */
|
||||
edition: BookRequestReviewEdition | null;
|
||||
/** Whether `.../review/cover` will answer with the cover found in the imported file. */
|
||||
hasCover: boolean;
|
||||
}
|
||||
|
||||
@@ -312,6 +312,11 @@ export interface GrabBookRequestPayload {
|
||||
torrentFileBase64?: string;
|
||||
torrentFileName?: string;
|
||||
downloadClientId?: number;
|
||||
/**
|
||||
* Wait in review once the download lands rather than being filed on a passing score. Send only
|
||||
* to a server advertising `book-request-review-hold`: an older one rejects the unknown field.
|
||||
*/
|
||||
holdForReview?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -411,6 +411,12 @@ export interface ReleaseCandidateItem {
|
||||
indexerName: string;
|
||||
guid: string;
|
||||
title: string;
|
||||
/**
|
||||
* The release's own page on its source, where the source publishes one: the description, file
|
||||
* list and comments an approver checks an edition against. Never a download link, and never a
|
||||
* link carrying a credential: one that might is dropped on the server rather than shown.
|
||||
*/
|
||||
infoUrl: string | null;
|
||||
sizeBytes: number | null;
|
||||
/** Null where the indexer did not report a swarm count at all, which is not the same as zero. */
|
||||
seeders: number | null;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "book_request_downloads" ADD COLUMN "release_info_url" text;--> statement-breakpoint
|
||||
ALTER TABLE "book_request_downloads" ADD COLUMN "hold_for_review" boolean DEFAULT false NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -855,6 +855,13 @@
|
||||
"when": 1792444043652,
|
||||
"tag": "0121_add_device_annotation_sync",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 122,
|
||||
"version": "7",
|
||||
"when": 1792511452628,
|
||||
"tag": "0122_add_release_info_url_and_review_hold",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -103,6 +103,16 @@ export const bookRequestDownloads = pgTable(
|
||||
releaseSeeders: integer('release_seeders'),
|
||||
releaseFormat: varchar('release_format', { length: 20 }),
|
||||
freeleech: boolean('freeleech').notNull().default(false),
|
||||
/**
|
||||
* The release's page on its source, already vetted for credentials, kept so the person
|
||||
* reviewing what landed can compare it against what the source said it was.
|
||||
*/
|
||||
releaseInfoUrl: text('release_info_url'),
|
||||
/**
|
||||
* Whoever sent this release asked to look at the import before it is filed, so it waits in
|
||||
* review whatever its verification score.
|
||||
*/
|
||||
holdForReview: boolean('hold_for_review').notNull().default(false),
|
||||
|
||||
/**
|
||||
* Null for an attempt that was refused before anything was handed over: a tracker that would
|
||||
|
||||
@@ -11,13 +11,17 @@ import {
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { RouteConfig } from '@nestjs/platform-fastify';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { createReadStream } from 'fs';
|
||||
import { AuditAction, AuditResource, Permission } from '@bookorbit/types';
|
||||
|
||||
import { Auditable } from '../../common/decorators/auditable.decorator';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { imageContentTypeFromPath } from '../../common/image-content-type';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { BookRequestService } from './book-request.service';
|
||||
import { DownloadClientConfigService } from './download-clients/download-client-config.service';
|
||||
@@ -196,6 +200,14 @@ export class BookRequestAdminController {
|
||||
return this.verification.getReview(id);
|
||||
}
|
||||
|
||||
@Get(':id/review/cover')
|
||||
async getReviewCover(@Param('id', ParseIntPipe) id: number, @Res() reply: FastifyReply) {
|
||||
const coverPath = await this.verification.getReviewCoverPath(id);
|
||||
reply.header('Content-Type', imageContentTypeFromPath(coverPath));
|
||||
reply.header('Cache-Control', 'private, no-cache');
|
||||
return reply.send(createReadStream(coverPath));
|
||||
}
|
||||
|
||||
/** The approver disagrees with the score. Files the held import exactly as a pass would have. */
|
||||
@Post(':id/force-file')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Body, Controller, DefaultValuePipe, Get, HttpCode, HttpStatus, Param, ParseBoolPipe, ParseIntPipe, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, DefaultValuePipe, Get, HttpCode, HttpStatus, Param, ParseBoolPipe, ParseIntPipe, Post, Query, Res } from '@nestjs/common';
|
||||
import { RouteConfig } from '@nestjs/platform-fastify';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { createReadStream } from 'fs';
|
||||
import { AuditAction, AuditResource, Permission } from '@bookorbit/types';
|
||||
|
||||
import { Auditable } from '../../common/decorators/auditable.decorator';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { imageContentTypeFromPath } from '../../common/image-content-type';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { BookRequestService } from './book-request.service';
|
||||
import { DownloadClientConfigService } from './download-clients/download-client-config.service';
|
||||
@@ -109,6 +112,15 @@ export class BookRequestSelfFulfilController {
|
||||
return this.verification.getReview(id);
|
||||
}
|
||||
|
||||
@Get(':id/review/cover')
|
||||
async getReviewCover(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: RequestUser, @Res() reply: FastifyReply) {
|
||||
await this.service.assertCanFulfil(id, user);
|
||||
const coverPath = await this.verification.getReviewCoverPath(id);
|
||||
reply.header('Content-Type', imageContentTypeFromPath(coverPath));
|
||||
reply.header('Cache-Control', 'private, no-cache');
|
||||
return reply.send(createReadStream(coverPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Overriding the verifier on a release you picked deliberately. Without this the feature would
|
||||
* dead-end exactly where auto-approval does: a below-threshold score on your own choice is a
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsBase64, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
import { IsBase64, IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
import { MAX_INDEXER_RELEASE_GUID_LENGTH } from '@bookorbit/types';
|
||||
|
||||
/** The longest base64 `.torrent` the grab takes. The 2 MiB the service accepts is about 2.7 Mi characters. */
|
||||
@@ -55,4 +55,12 @@ export class GrabBookRequestDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
downloadClientId?: number;
|
||||
|
||||
/**
|
||||
* Wait in review once the download lands, whatever the verification score. Advertised as the
|
||||
* `book-request-review-hold` server feature, because a server older than the field rejects it.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
holdForReview?: boolean;
|
||||
}
|
||||
|
||||
@@ -15,3 +15,15 @@ describe('book request release guid DTOs', () => {
|
||||
await expect(validate(dto)).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GrabBookRequestDto.holdForReview', () => {
|
||||
it('is optional, so a client that has never heard of it grabs exactly as before', async () => {
|
||||
await expect(validate(Object.assign(new GrabBookRequestDto(), { indexerId: 1, releaseGuid: 'g' }))).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('takes a boolean and nothing else', async () => {
|
||||
await expect(validate(Object.assign(new GrabBookRequestDto(), { indexerId: 1, releaseGuid: 'g', holdForReview: true }))).resolves.toEqual([]);
|
||||
const errors = await validate(Object.assign(new GrabBookRequestDto(), { indexerId: 1, releaseGuid: 'g', holdForReview: 'yes' }));
|
||||
expect(errors.map((error) => error.property)).toEqual(['holdForReview']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,3 +45,31 @@ describe('BookRequestDownloadRepository.findLatestForRequests', () => {
|
||||
expect(queries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookRequestDownloadRepository.findTriedReleaseKeys', () => {
|
||||
function repositoryReturning(rows: Array<{ indexerId: number | null; releaseGuid: string | null }>) {
|
||||
const db = { select: () => ({ from: () => ({ where: () => Promise.resolve(rows) }) }) };
|
||||
return new BookRequestDownloadRepository(db as never);
|
||||
}
|
||||
|
||||
/**
|
||||
* Torznab guids are digested now, and an attempt made before that stored the raw link. Without
|
||||
* the digested key alongside it, the next automated retry would pick the release that failed.
|
||||
*/
|
||||
it('also matches a raw URL guid stored before guids were digested', async () => {
|
||||
const keys = await repositoryReturning([{ indexerId: 3, releaseGuid: 'https://tracker.example.com/details/1' }]).findTriedReleaseKeys(9);
|
||||
|
||||
expect(keys.has('3:https://tracker.example.com/details/1')).toBe(true);
|
||||
expect(keys.size).toBe(2);
|
||||
expect([...keys].some((key) => /^3:[a-f0-9]{64}$/.test(key))).toBe(true);
|
||||
});
|
||||
|
||||
it('adds nothing for an opaque guid or a hand-pasted magnet', async () => {
|
||||
const keys = await repositoryReturning([
|
||||
{ indexerId: 3, releaseGuid: '12345' },
|
||||
{ indexerId: null, releaseGuid: null },
|
||||
]).findTriedReleaseKeys(9);
|
||||
|
||||
expect([...keys]).toEqual(['3:12345']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type BookRequestDownloadRow,
|
||||
type NewBookRequestDownloadRow,
|
||||
} from '../../../db/schema';
|
||||
import { publicReleaseGuid } from '../indexers/release-guid';
|
||||
|
||||
/** Newest first, and bounded: an attempt list is something a person reads, not a full history. */
|
||||
const MAX_ATTEMPTS_PER_REQUEST = 20;
|
||||
@@ -345,6 +346,10 @@ export class BookRequestDownloadRepository {
|
||||
// A hand-pasted magnet has neither, and there is nothing about it to match a release on.
|
||||
if (row.indexerId === null || row.releaseGuid === null) continue;
|
||||
keys.add(`${row.indexerId}:${row.releaseGuid}`);
|
||||
// Torznab attempts made before its guids were digested stored the raw value, which a search
|
||||
// now reports digested. Without the second key a retry would pick the release that just failed.
|
||||
const digested = publicReleaseGuid(row.indexerId, row.releaseGuid);
|
||||
if (digested !== row.releaseGuid) keys.add(`${row.indexerId}:${digested}`);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
@@ -374,6 +374,30 @@ describe('RequestFulfillmentService.grab from a picked release', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the release page and the request to review it on the attempt', async () => {
|
||||
const { service, downloads } = makeService({
|
||||
releases: { find: vi.fn().mockReturnValue({ ...RELEASE, infoUrl: 'https://tracker.example.com/details/1' }) },
|
||||
indexerAdapter: { fetchTorrentFile: vi.fn().mockResolvedValue(torrentBytes()) },
|
||||
});
|
||||
|
||||
await service.grab(7, { indexerId: 9, releaseGuid: 'r-1', holdForReview: true }, user());
|
||||
|
||||
expect(downloads.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ releaseInfoUrl: 'https://tracker.example.com/details/1', holdForReview: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('files a grab on its score unless the sender asked to review it', async () => {
|
||||
const { service, downloads } = makeService({
|
||||
releases: { find: vi.fn().mockReturnValue(RELEASE) },
|
||||
indexerAdapter: { fetchTorrentFile: vi.fn().mockResolvedValue(torrentBytes()) },
|
||||
});
|
||||
|
||||
await service.grab(7, { indexerId: 9, releaseGuid: 'r-1' }, user());
|
||||
|
||||
expect(downloads.create).toHaveBeenCalledWith(expect.objectContaining({ releaseInfoUrl: null, holdForReview: false }));
|
||||
});
|
||||
|
||||
it('hands a magnet redirect from a Torznab download endpoint to the torrent client', async () => {
|
||||
const { service, downloads, adapter } = makeService({
|
||||
releases: { find: vi.fn().mockReturnValue(RELEASE) },
|
||||
|
||||
@@ -265,6 +265,8 @@ export class RequestFulfillmentService {
|
||||
automated: user === null,
|
||||
releaseTitle: grab.releaseTitle,
|
||||
releaseGuid: grab.releaseGuid ?? null,
|
||||
releaseInfoUrl: grab.releaseInfoUrl ?? null,
|
||||
holdForReview: dto.holdForReview === true,
|
||||
releaseSizeBytes: grab.releaseSizeBytes,
|
||||
releaseSeeders: grab.releaseSeeders ?? null,
|
||||
releaseFormat: grab.releaseFormat ?? null,
|
||||
@@ -385,6 +387,7 @@ export class RequestFulfillmentService {
|
||||
automated: user === null,
|
||||
releaseTitle: release.title.slice(0, 500),
|
||||
releaseGuid,
|
||||
releaseInfoUrl: release.infoUrl ?? null,
|
||||
releaseSizeBytes: release.sizeBytes,
|
||||
releaseSeeders: release.seeders,
|
||||
releaseFormat: resolveFormat(release)?.slice(0, 20) ?? null,
|
||||
@@ -643,6 +646,7 @@ export class RequestFulfillmentService {
|
||||
indexerAdapterType: indexer.adapterType,
|
||||
releaseGuid: release.guid,
|
||||
releaseTitle: release.title.slice(0, 500),
|
||||
releaseInfoUrl: release.infoUrl ?? null,
|
||||
releaseSizeBytes: release.sizeBytes,
|
||||
releaseSeeders: release.seeders,
|
||||
// The resolved format, not the raw field: most indexers state none and it is read off the
|
||||
@@ -959,6 +963,8 @@ interface ResolvedGrab extends ParsedGrab {
|
||||
indexerName?: string;
|
||||
indexerAdapterType?: string;
|
||||
releaseGuid?: string;
|
||||
/** Vetted by the search that produced the release, so it is safe to keep and show. */
|
||||
releaseInfoUrl?: string | null;
|
||||
releaseSeeders?: number | null;
|
||||
releaseFormat?: string | null;
|
||||
freeleech?: boolean;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { NotificationType, type BookDockMetadata } from '@bookorbit/types';
|
||||
|
||||
import type { BookRequestDownloadRow } from '../../../db/schema';
|
||||
@@ -122,7 +123,7 @@ describe('verifyAgainstRequest with an operator-set threshold', () => {
|
||||
* handler: one file, one score, two operator settings, two outcomes.
|
||||
*/
|
||||
describe('RequestVerificationService against the configured threshold', () => {
|
||||
function makeService(threshold: number, verificationEnabled = true, overrides: { requestSettled?: boolean } = {}) {
|
||||
function makeService(threshold: number, verificationEnabled = true, overrides: { requestSettled?: boolean; holdForReview?: boolean } = {}) {
|
||||
const events = { on: vi.fn() };
|
||||
const dockRepo = {
|
||||
findById: vi.fn().mockResolvedValue({ id: 100, fetchedMetadata: metadata({ authors: ['F. Herbert'] }), embeddedMetadata: null }),
|
||||
@@ -130,7 +131,13 @@ describe('RequestVerificationService against the configured threshold', () => {
|
||||
const dock = { discardFile: vi.fn().mockResolvedValue(undefined) };
|
||||
const finalizeService = { finalizeManagedFile: vi.fn().mockResolvedValue({ success: true, bookId: 55 }) };
|
||||
const downloads = {
|
||||
findByBookDockFileId: vi.fn().mockResolvedValue({ id: 11, requestId: 7, status: 'importing', bookDockFileId: 100 } as BookRequestDownloadRow),
|
||||
findByBookDockFileId: vi.fn().mockResolvedValue({
|
||||
id: 11,
|
||||
requestId: 7,
|
||||
status: 'importing',
|
||||
bookDockFileId: 100,
|
||||
holdForReview: overrides.holdForReview ?? false,
|
||||
} as BookRequestDownloadRow),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
updateIf: vi.fn().mockImplementation((id: number, _expected: unknown, patch: Record<string, unknown>) => Promise.resolve({ id, ...patch })),
|
||||
};
|
||||
@@ -237,6 +244,41 @@ describe('RequestVerificationService against the configured threshold', () => {
|
||||
);
|
||||
expect(fulfillment.holdForReview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('holds a passing import when the person who sent it asked to look first', async () => {
|
||||
const { ingest, finalizeService, fulfillment } = makeService(70, true, { holdForReview: true });
|
||||
|
||||
await ingest();
|
||||
|
||||
expect(finalizeService.finalizeManagedFile).not.toHaveBeenCalled();
|
||||
expect(fulfillment.holdForReview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 11 }),
|
||||
expect.objectContaining({ id: 7 }),
|
||||
expect.stringContaining('reviewed'),
|
||||
);
|
||||
});
|
||||
|
||||
it('holds an import the sender asked to review even with checking switched off', async () => {
|
||||
const { ingest, finalizeService, fulfillment } = makeService(70, false, { holdForReview: true });
|
||||
|
||||
await ingest();
|
||||
|
||||
expect(finalizeService.finalizeManagedFile).not.toHaveBeenCalled();
|
||||
expect(fulfillment.holdForReview).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ id: 7 }),
|
||||
expect.stringContaining('reviewed'),
|
||||
);
|
||||
});
|
||||
|
||||
it('holds a failing import for its score rather than for the request to look', async () => {
|
||||
const { ingest, fulfillment } = makeService(95, true, { holdForReview: true });
|
||||
|
||||
await ingest();
|
||||
|
||||
expect(fulfillment.holdForReview).toHaveBeenCalledTimes(1);
|
||||
expect(fulfillment.holdForReview).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ id: 7 }), expect.stringContaining('scored'));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -477,6 +519,77 @@ describe('RequestVerificationService review surface', () => {
|
||||
expect(review).toMatchObject({ bookDockFileId: null, verification: null, files: [], canFile: false });
|
||||
});
|
||||
|
||||
it('says whether the sender asked for the review, and links the release it came from', async () => {
|
||||
const { service } = makeService({ downloadRow: { holdForReview: true, releaseInfoUrl: 'https://tracker.example.com/details/1' } });
|
||||
|
||||
expect(await service.getReview(7)).toMatchObject({ heldOnRequest: true, releaseInfoUrl: 'https://tracker.example.com/details/1' });
|
||||
});
|
||||
|
||||
it('keeps the release link once the dock entry is gone, since the source still describes it', async () => {
|
||||
const { service } = makeService({
|
||||
requestRow: { bookDockFileId: null },
|
||||
downloadRow: { bookDockFileId: null, releaseInfoUrl: 'https://tracker.example.com/details/1' },
|
||||
});
|
||||
|
||||
expect(await service.getReview(7)).toMatchObject({
|
||||
bookDockFileId: null,
|
||||
releaseInfoUrl: 'https://tracker.example.com/details/1',
|
||||
edition: null,
|
||||
hasCover: false,
|
||||
});
|
||||
});
|
||||
|
||||
/** A provider match is itself a guess at the edition, which is what these facts exist to check. */
|
||||
it("states the edition from the file's own metadata, never from the provider match", async () => {
|
||||
const { service } = makeService({
|
||||
dockRow: {
|
||||
id: 100,
|
||||
fileName: 'dune.m4b',
|
||||
fileSize: 1024,
|
||||
format: 'm4b',
|
||||
unitDirectory: null,
|
||||
coverPath: '/data/dock/covers/100.jpg',
|
||||
fetchedMetadata: metadata({ publisher: 'Provider Press', pageCount: 900 }),
|
||||
embeddedMetadata: {
|
||||
publisher: ' Macmillan Audio ',
|
||||
publishedYear: 2007,
|
||||
language: 'en',
|
||||
narrators: ['Scott Brick', ' '],
|
||||
durationSeconds: 75_600,
|
||||
abridged: false,
|
||||
chapters: [{ title: 'One' }, { title: 'Two' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const review = await service.getReview(7);
|
||||
|
||||
expect(review.edition).toEqual({
|
||||
publisher: 'Macmillan Audio',
|
||||
publishedDate: '2007',
|
||||
language: 'en',
|
||||
pageCount: null,
|
||||
isbn10: null,
|
||||
narrators: ['Scott Brick'],
|
||||
durationSeconds: 75_600,
|
||||
abridged: false,
|
||||
chapterCount: 2,
|
||||
});
|
||||
expect(review.hasCover).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no edition for a file that states none of it', async () => {
|
||||
const { service } = makeService();
|
||||
|
||||
expect(await service.getReview(7)).toMatchObject({ edition: null, hasCover: false, heldOnRequest: false, releaseInfoUrl: null });
|
||||
});
|
||||
|
||||
it('answers no cover for a held import without one', async () => {
|
||||
const { service } = makeService();
|
||||
|
||||
await expect(service.getReviewCoverPath(7)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('finds the entry through the attempt when only the request lost its pointer', async () => {
|
||||
const { service } = makeService({ requestRow: { bookDockFileId: null } });
|
||||
expect((await service.getReview(7)).bookDockFileId).toBe(100);
|
||||
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
NotFoundException,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { access } from 'fs/promises';
|
||||
import { basename } from 'path';
|
||||
import { DEFAULT_BOOK_REQUEST_AUTOMATION_SETTINGS, NotificationType, UNSETTLED_BOOK_REQUEST_DOWNLOAD_STATUSES } from '@bookorbit/types';
|
||||
import type {
|
||||
BookDockMetadata,
|
||||
BookRequestStatus,
|
||||
BookRequestReview,
|
||||
BookRequestReviewEdition,
|
||||
BookRequestReviewFile,
|
||||
BookRequestVerificationReason,
|
||||
BookRequestVerificationRow,
|
||||
@@ -47,6 +49,9 @@ import { RequestFulfillmentService } from './request-fulfillment.service';
|
||||
*/
|
||||
const DEFAULT_VERIFICATION_THRESHOLD = DEFAULT_BOOK_REQUEST_AUTOMATION_SETTINGS.verificationThreshold;
|
||||
|
||||
/** Prose for the stored reason and the notification. The drawer reads `heldOnRequest` instead. */
|
||||
const HELD_ON_REQUEST_REASON = 'the release was sent to be reviewed before it is filed';
|
||||
|
||||
const TITLE_WEIGHT = 0.7;
|
||||
const AUTHOR_WEIGHT = 0.3;
|
||||
|
||||
@@ -123,8 +128,13 @@ export class RequestVerificationService implements OnModuleInit {
|
||||
|
||||
const { verificationEnabled, verificationThreshold } = await this.settings.get();
|
||||
// Off means the operator has chosen to trust the grab: the import goes straight into the
|
||||
// target library without waiting on a score, and nothing is ever held for a human to look at.
|
||||
// target library without waiting on a score, and nothing is held for a human to look at
|
||||
// unless the person who sent the release asked to look at it.
|
||||
if (!verificationEnabled) {
|
||||
if (download.holdForReview) {
|
||||
await this.holdOnRequest(download, joined.request, fileId);
|
||||
return;
|
||||
}
|
||||
this.logger.log(
|
||||
`[book_request.verify] [end] requestId=${joined.request.id} dockFileId=${fileId} enabled=false - verification is off, filing the import as grabbed`,
|
||||
);
|
||||
@@ -141,10 +151,24 @@ export class RequestVerificationService implements OnModuleInit {
|
||||
await this.holdForReview(download, joined.request, outcome);
|
||||
return;
|
||||
}
|
||||
// After the score rather than instead of it: a release that also failed verification is held
|
||||
// for that reason, which says more than "you asked".
|
||||
if (download.holdForReview) {
|
||||
await this.holdOnRequest(download, joined.request, fileId);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.finalize(download, joined.request, fileId);
|
||||
}
|
||||
|
||||
/** The sender's own request to look first outranks a passing score and verification being off. */
|
||||
private async holdOnRequest(download: BookRequestDownloadRow, request: BookRequestRow, fileId: number): Promise<void> {
|
||||
this.logger.log(
|
||||
`[book_request.verify] [end] requestId=${request.id} dockFileId=${fileId} holdForReview=true - held for the review the sender asked for`,
|
||||
);
|
||||
await this.fulfillment.holdForReview(download, request, HELD_ON_REQUEST_REASON);
|
||||
}
|
||||
|
||||
private async finalize(download: BookRequestDownloadRow, request: BookRequestRow, fileId: number): Promise<void> {
|
||||
const filed = await this.fileIntoLibrary(download, request, fileId);
|
||||
if (!filed.ok) await this.fulfillment.holdForReview(download, request, filed.reason);
|
||||
@@ -264,15 +288,29 @@ export class RequestVerificationService implements OnModuleInit {
|
||||
* that sent them there.
|
||||
*/
|
||||
async getReview(requestId: number): Promise<BookRequestReview> {
|
||||
const { request, fileId } = await this.loadHeld(requestId);
|
||||
const { request, download, fileId } = await this.loadHeld(requestId);
|
||||
|
||||
const row = fileId === null ? undefined : await this.dockRepo.findById(fileId);
|
||||
const canFile = (request.targetFolderId ?? (await this.resolveFolder(request.targetLibraryId))) !== null;
|
||||
|
||||
const heldOnRequest = download?.holdForReview ?? false;
|
||||
const releaseInfoUrl = download?.releaseInfoUrl ?? null;
|
||||
|
||||
// Filing or discarding the entry by hand empties both pointers to it, which leaves the request
|
||||
// held over a file that is not there any more. Saying so is the only useful thing left.
|
||||
if (!row) {
|
||||
return { requestId, bookDockFileId: null, verification: null, files: [], totalSizeBytes: null, canFile: false };
|
||||
return {
|
||||
requestId,
|
||||
bookDockFileId: null,
|
||||
verification: null,
|
||||
files: [],
|
||||
totalSizeBytes: null,
|
||||
canFile: false,
|
||||
heldOnRequest,
|
||||
releaseInfoUrl,
|
||||
edition: null,
|
||||
hasCover: false,
|
||||
};
|
||||
}
|
||||
|
||||
const [unitFiles, settings] = await Promise.all([
|
||||
@@ -310,9 +348,30 @@ export class RequestVerificationService implements OnModuleInit {
|
||||
files,
|
||||
totalSizeBytes: sized.length ? sized.reduce((total, file) => total + (file.fileSize ?? 0), 0) : null,
|
||||
canFile,
|
||||
heldOnRequest,
|
||||
releaseInfoUrl,
|
||||
edition: editionFacts(row.embeddedMetadata),
|
||||
hasCover: Boolean(row.coverPath),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the cover found in a held import is on disk. Read through the request rather than the
|
||||
* Book Dock's own route, because the person reviewing an import need not hold Book Dock
|
||||
* permissions; the request route has already decided they may look at this one.
|
||||
*/
|
||||
async getReviewCoverPath(requestId: number): Promise<string> {
|
||||
const { fileId } = await this.loadHeld(requestId);
|
||||
const row = fileId === null ? undefined : await this.dockRepo.findById(fileId);
|
||||
if (!row?.coverPath) throw new NotFoundException('This import has no cover');
|
||||
try {
|
||||
await access(row.coverPath);
|
||||
} catch {
|
||||
throw new NotFoundException('This import has no cover');
|
||||
}
|
||||
return row.coverPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* An approver has looked at a held import and decided it is the right book anyway. Runs exactly
|
||||
* the filing a passing score would have run. The score is not rewritten on the way through: it
|
||||
@@ -553,6 +612,33 @@ function joinTitle(title: string, subtitle: string | null): string {
|
||||
* The unit's own directory name, which is what a release names after the book. Only offered for a
|
||||
* multi-file unit: a loose single file has no directory that means anything.
|
||||
*/
|
||||
/** Null when the file states none of it, so the drawer draws no empty section. */
|
||||
function editionFacts(metadata: BookDockMetadata | null): BookRequestReviewEdition | null {
|
||||
if (!metadata) return null;
|
||||
const facts: BookRequestReviewEdition = {
|
||||
publisher: nonEmpty(metadata.publisher),
|
||||
publishedDate: nonEmpty(metadata.publishedDate) ?? (isCount(metadata.publishedYear) ? String(metadata.publishedYear) : null),
|
||||
language: nonEmpty(metadata.language),
|
||||
pageCount: isCount(metadata.pageCount) ? metadata.pageCount : null,
|
||||
isbn10: nonEmpty(metadata.isbn10),
|
||||
narrators: Array.isArray(metadata.narrators) ? metadata.narrators.map((narrator) => nonEmpty(narrator)).filter((name) => name !== null) : [],
|
||||
durationSeconds: isCount(metadata.durationSeconds) ? metadata.durationSeconds : null,
|
||||
abridged: typeof metadata.abridged === 'boolean' ? metadata.abridged : null,
|
||||
chapterCount: Array.isArray(metadata.chapters) && metadata.chapters.length > 0 ? metadata.chapters.length : null,
|
||||
};
|
||||
const stated = Object.values(facts).some((value) => (Array.isArray(value) ? value.length > 0 : value !== null));
|
||||
return stated ? facts : null;
|
||||
}
|
||||
|
||||
function nonEmpty(value: string | null | undefined): string | null {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||
return trimmed === '' ? null : trimmed;
|
||||
}
|
||||
|
||||
function isCount(value: number | null | undefined): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function unitTitleFallback(row: { unitDirectory: string | null }): string | null {
|
||||
if (!row.unitDirectory) return null;
|
||||
const name = basename(row.unitDirectory)
|
||||
|
||||
@@ -104,6 +104,24 @@ describe('NewznabAdapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the details page the feed publishes in comments', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
feed(`<item>
|
||||
<title>Dune EPUB</title>
|
||||
<guid isPermaLink="false">release-guid</guid>
|
||||
<link>https://indexer.example.com/api?t=get&id=release-guid&apikey=secret-key</link>
|
||||
<comments>https://indexer.example.com/details/release-guid#comments</comments>
|
||||
</item>`),
|
||||
),
|
||||
);
|
||||
|
||||
const [release] = await new NewznabAdapter().search(query(), config(), AbortSignal.timeout(1000));
|
||||
|
||||
// Raw here: the search service vets it, which is where the anchor and any key are dealt with.
|
||||
expect(release?.infoUrl).toBe('https://indexer.example.com/details/release-guid#comments');
|
||||
});
|
||||
|
||||
it('falls back to title and author for a search without an ISBN', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(feed('')));
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { readBoundedBytes, ResponseTooLargeError } from '../../../../common/util
|
||||
import { sanitizeLogValue } from '../../../../common/utils/log-sanitize.utils';
|
||||
import { safeFetch } from '../../../../common/utils/safe-fetch';
|
||||
import { ensureSafeUrl, PrivateAddressException } from '../../../../common/utils/ssrf.utils';
|
||||
import { publicReleaseGuid } from '../release-guid';
|
||||
import { buildSearchText } from '../search-text';
|
||||
import {
|
||||
IndexerSearchException,
|
||||
@@ -41,6 +42,8 @@ interface NewznabItem {
|
||||
title?: string;
|
||||
guid?: unknown;
|
||||
link?: string;
|
||||
/** The release's details page, which the Newznab spec points at its comment thread. */
|
||||
comments?: unknown;
|
||||
size?: string | number;
|
||||
pubDate?: string;
|
||||
category?: string | string[];
|
||||
@@ -295,6 +298,7 @@ function parseItems(xml: string, indexerId: number): ReleaseCandidate[] {
|
||||
const sourceGuid = readGuid(item);
|
||||
if (!withinBound(sourceGuid) || !withinBound(link) || !link || !/^https?:/i.test(link)) return [];
|
||||
const guid = publicReleaseGuid(indexerId, sourceGuid ?? link);
|
||||
const infoUrl = readText(item.comments);
|
||||
const publishedAt = parseDate(item.pubDate);
|
||||
const fileCount = toNumber(attrs.get('files'));
|
||||
const format = attrs.get('format');
|
||||
@@ -303,6 +307,7 @@ function parseItems(xml: string, indexerId: number): ReleaseCandidate[] {
|
||||
indexerId,
|
||||
guid,
|
||||
title,
|
||||
...(infoUrl && withinBound(infoUrl) ? { infoUrl } : {}),
|
||||
downloadUrl: link,
|
||||
sizeBytes: toNumber(attrs.get('size') ?? item.size ?? enclosure?.['@_length']),
|
||||
seeders: null,
|
||||
@@ -332,22 +337,21 @@ function attrMap(item: NewznabItem): Map<string, string> {
|
||||
}
|
||||
|
||||
function readGuid(item: NewznabItem): string | undefined {
|
||||
if (typeof item.guid === 'string' && item.guid.trim()) return item.guid.trim();
|
||||
if (typeof item.guid === 'number') return String(item.guid);
|
||||
if (item.guid && typeof item.guid === 'object') {
|
||||
const text = (item.guid as { '#text'?: unknown })['#text'];
|
||||
return readText(item.guid);
|
||||
}
|
||||
|
||||
/** An element's text, whether the parser handed it over bare or as `{ '#text', ...attributes }`. */
|
||||
function readText(value: unknown): string | undefined {
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
if (typeof value === 'number') return String(value);
|
||||
if (value && typeof value === 'object') {
|
||||
const text = (value as { '#text'?: unknown })['#text'];
|
||||
if (typeof text === 'string' && text.trim()) return text.trim();
|
||||
if (typeof text === 'number') return String(text);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A URL-shaped GUID can carry the API key, so expose only a stable opaque identifier for it. */
|
||||
function publicReleaseGuid(indexerId: number, value: string): string {
|
||||
if (!/^https?:/i.test(value)) return value;
|
||||
return createHash('sha256').update(String(indexerId)).update('\0release\0').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function withinBound(value: string | undefined): boolean {
|
||||
return value === undefined || value.length <= MAX_FEED_FIELD_CHARS;
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ const ITEM = `
|
||||
<title>Dune - Frank Herbert (retail) [EPUB]</title>
|
||||
<guid>https://tracker.example.com/details/1</guid>
|
||||
<link>https://tracker.example.com/download/1.torrent?key=abc</link>
|
||||
<comments>https://tracker.example.com/details/1</comments>
|
||||
<pubDate>Tue, 01 Apr 2025 10:00:00 +0000</pubDate>
|
||||
<enclosure url="https://tracker.example.com/download/1.torrent?key=abc" length="1048576" type="application/x-bittorrent" />
|
||||
<torznab:attr name="seeders" value="42" />
|
||||
@@ -189,8 +190,8 @@ describe('TorznabAdapter', () => {
|
||||
|
||||
expect(release).toMatchObject({
|
||||
indexerId: 7,
|
||||
guid: 'https://tracker.example.com/details/1',
|
||||
title: 'Dune - Frank Herbert (retail) [EPUB]',
|
||||
infoUrl: 'https://tracker.example.com/details/1',
|
||||
downloadUrl: 'https://tracker.example.com/download/1.torrent?key=abc',
|
||||
infoHash: 'c9e15763f722f23e98a29decdfae341b98d53056',
|
||||
sizeBytes: 1048576,
|
||||
@@ -201,6 +202,41 @@ describe('TorznabAdapter', () => {
|
||||
expect(release.leechers).toBe(8);
|
||||
});
|
||||
|
||||
/**
|
||||
* Jackett reuses the tracker's download link as the guid for some trackers, and a private
|
||||
* tracker's download link carries the passkey. The guid is what the picker holds on to, so it
|
||||
* must not be the link itself.
|
||||
*/
|
||||
it('digests a URL-shaped guid so a passkeyed link never reaches a client', async () => {
|
||||
const item = `<item><title>Dune</title><guid>https://tracker.example.com/download.php?id=1&passkey=p4ssk3y</guid><link>https://tracker.example.com/download.php?id=1&passkey=p4ssk3y</link></item>`;
|
||||
fetchMock.mockResolvedValue(new Response(feed(item)));
|
||||
|
||||
const [first] = await new TorznabAdapter().search(query(), config(), AbortSignal.timeout(1000));
|
||||
fetchMock.mockResolvedValue(new Response(feed(item)));
|
||||
const [second] = await new TorznabAdapter().search(query(), config(), AbortSignal.timeout(1000));
|
||||
|
||||
expect(first.guid).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(first.guid).not.toContain('p4ssk3y');
|
||||
// Stable across searches, which is what lets a grab name the release it was shown.
|
||||
expect(second.guid).toBe(first.guid);
|
||||
});
|
||||
|
||||
it('keeps an opaque guid exactly as the proxy sent it', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(feed(`<item><title>Dune</title><guid>12345</guid><link>https://t.example/1.torrent</link></item>`)));
|
||||
|
||||
const [release] = await new TorznabAdapter().search(query(), config(), AbortSignal.timeout(1000));
|
||||
|
||||
expect(release.guid).toBe('12345');
|
||||
});
|
||||
|
||||
it('leaves the page link out when the proxy publishes none', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(feed(`<item><title>Dune</title><guid>1</guid><link>https://t.example/1.torrent</link></item>`)));
|
||||
|
||||
const [release] = await new TorznabAdapter().search(query(), config(), AbortSignal.timeout(1000));
|
||||
|
||||
expect(release.infoUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('treats a lone item as a list rather than dropping it', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(feed(ITEM)));
|
||||
expect(await new TorznabAdapter().search(query(), config(), AbortSignal.timeout(1000))).toHaveLength(1);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type ResolvedIndexerConfig,
|
||||
type TorrentFetchResult,
|
||||
} from '../indexer-adapter';
|
||||
import { publicReleaseGuid } from '../release-guid';
|
||||
import { normalizeProviderSeedRatio, normalizeTorznabSeedTimeSeconds } from '../seed-goal.utils';
|
||||
// An ISBN is the one query a tracker almost never indexes, so it stays out of the search text.
|
||||
import { buildSearchText } from '../search-text';
|
||||
@@ -44,6 +45,8 @@ interface TorznabItem {
|
||||
title?: string;
|
||||
guid?: unknown;
|
||||
link?: string;
|
||||
/** The release's details page on the tracker, which Jackett and Prowlarr both publish here. */
|
||||
comments?: unknown;
|
||||
size?: string | number;
|
||||
pubDate?: string;
|
||||
enclosure?: { '@_url'?: string; '@_length'?: string | number } | Array<{ '@_url'?: string; '@_length'?: string | number }>;
|
||||
@@ -265,8 +268,9 @@ function parseItems(xml: string, indexerId: number): ReleaseCandidate[] {
|
||||
|
||||
const attrs = attrMap(item);
|
||||
const enclosure = toArray(item.enclosure)[0];
|
||||
const guid = readGuid(item) ?? enclosure?.['@_url'] ?? title;
|
||||
if (!withinFieldBound(guid)) continue;
|
||||
const sourceGuid = readGuid(item) ?? enclosure?.['@_url'] ?? title;
|
||||
if (!withinFieldBound(sourceGuid)) continue;
|
||||
const guid = publicReleaseGuid(indexerId, sourceGuid);
|
||||
// A proxy may publish the release as a magnet in `link`, in an enclosure, or in an attribute.
|
||||
const link = withinFieldBound(item.link ?? enclosure?.['@_url']) ? (item.link ?? enclosure?.['@_url']) : undefined;
|
||||
const magnet = attrs.get('magneturl') ?? (link?.startsWith('magnet:') ? link : undefined);
|
||||
@@ -280,12 +284,14 @@ function parseItems(xml: string, indexerId: number): ReleaseCandidate[] {
|
||||
const seedRatioGoal = normalizeProviderSeedRatio(toNumber(attrs.get('minimumratio')));
|
||||
const seedTimeMinutes = normalizeTorznabSeedTimeSeconds(toNumber(attrs.get('minimumseedtime')));
|
||||
const bookTitle = attrs.get('booktitle')?.trim();
|
||||
const infoUrl = readText(item.comments);
|
||||
|
||||
releases.push({
|
||||
indexerId,
|
||||
guid,
|
||||
title,
|
||||
...(bookTitle ? { bookTitle } : {}),
|
||||
...(infoUrl && withinFieldBound(infoUrl) ? { infoUrl } : {}),
|
||||
...(downloadUrl ? { downloadUrl } : {}),
|
||||
...(magnet ? { magnet } : {}),
|
||||
...(infoHash ? { infoHash: infoHash.toLowerCase() } : {}),
|
||||
@@ -343,11 +349,15 @@ function withinFieldBound(value: string | undefined): boolean {
|
||||
|
||||
/** `guid` is a bare string on some proxies and `{ '#text', '@_isPermaLink' }` on others. */
|
||||
function readGuid(item: TorznabItem): string | undefined {
|
||||
const guid = item.guid;
|
||||
if (typeof guid === 'string' && guid.trim()) return guid.trim();
|
||||
if (typeof guid === 'number') return String(guid);
|
||||
if (guid && typeof guid === 'object') {
|
||||
const text = (guid as { '#text'?: unknown })['#text'];
|
||||
return readText(item.guid);
|
||||
}
|
||||
|
||||
/** An element's text, whether the parser handed it over bare or as `{ '#text', ...attributes }`. */
|
||||
function readText(value: unknown): string | undefined {
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
if (typeof value === 'number') return String(value);
|
||||
if (value && typeof value === 'object') {
|
||||
const text = (value as { '#text'?: unknown })['#text'];
|
||||
if (typeof text === 'string' && text.trim()) return text.trim();
|
||||
if (typeof text === 'number') return String(text);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,11 @@ export interface ReleaseCandidate {
|
||||
downloadUrl?: string;
|
||||
magnet?: string;
|
||||
infoHash?: string;
|
||||
/**
|
||||
* The release's page on its source, as the adapter read it. Untrusted until the search service
|
||||
* has run it through `safeReleaseInfoUrl`, which is the only thing that may hand it to a client.
|
||||
*/
|
||||
infoUrl?: string;
|
||||
sizeBytes: number | null;
|
||||
/**
|
||||
* Null means the indexer reported no swarm counts, which must not be read as zero seeders: the
|
||||
|
||||
@@ -135,6 +135,27 @@ describe('IndexerSearchService', () => {
|
||||
expect(result.indexers[0]).toMatchObject({ ok: true, count: 2, filtered: 1 });
|
||||
});
|
||||
|
||||
it('hands the picker a vetted page link and drops one carrying the indexer credential', async () => {
|
||||
const adapter = {
|
||||
search: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
release({ guid: 'safe', infoUrl: 'https://tracker.example.com/details/1#comments' }),
|
||||
release({ guid: 'keyed', infoUrl: 'https://tracker.example.com/details/2?ref=stored-credential' }),
|
||||
release({ guid: 'scripted', infoUrl: 'javascript:alert(1)' }),
|
||||
release({ guid: 'none' }),
|
||||
]),
|
||||
};
|
||||
const { service } = makeService([indexer({ adapterType: pluginType('example-plugin'), credential: 'stored-credential' })], {
|
||||
'example-plugin': adapter,
|
||||
});
|
||||
|
||||
const result = await service.search(request());
|
||||
|
||||
const pages = Object.fromEntries(result.releases.map((item) => [item.guid, item.infoUrl]));
|
||||
expect(pages).toEqual({ safe: 'https://tracker.example.com/details/1', keyed: null, scripted: null, none: null });
|
||||
});
|
||||
|
||||
/** The picker for an old comic request says why, rather than asking every tracker for nothing. */
|
||||
it('refuses to search for a request made for the retired comic medium', async () => {
|
||||
const search = vi.fn();
|
||||
|
||||
@@ -26,6 +26,7 @@ import { IndexerSearchException, type ReleaseCandidate, type ReleaseQuery, type
|
||||
import { IndexerConfigService } from './indexer-config.service';
|
||||
import { IndexerOperationLock } from './indexer-operation-lock';
|
||||
import { IndexerRegistry } from './indexer-registry';
|
||||
import { indexerSecrets, withSafeInfoUrl } from './release-info-url';
|
||||
import { rejectRelease, scoreRelease, toReleaseItem, type ScoredRelease, type ScoringRequest } from './release-scoring';
|
||||
import { buildSearchText } from './search-text';
|
||||
|
||||
@@ -427,7 +428,10 @@ export class IndexerSearchService {
|
||||
() => new IndexerSearchException('timeout', `${config.name} did not answer in time`),
|
||||
);
|
||||
}
|
||||
return { config, releases, query: indexerQuery };
|
||||
// Vetted here rather than in each adapter, so a plugin is held to the same rule as the
|
||||
// built-ins and a candidate refreshed for a grab is held to it a second time.
|
||||
const secrets = indexerSecrets(config);
|
||||
return { config, releases: releases.map((release) => withSafeInfoUrl(release, secrets)), query: indexerQuery };
|
||||
} catch (error) {
|
||||
const failure: IndexerSearchFailure = error instanceof IndexerSearchException ? error.failure : 'error';
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -125,6 +125,15 @@ describe('PluginIndexerAdapter', () => {
|
||||
expect(releases.map((r) => r.guid)).toEqual(['g5']);
|
||||
});
|
||||
|
||||
it('carries the page a plugin names for a release, and nothing that is not text', async () => {
|
||||
const releases = await searched([
|
||||
{ guid: 'g1', title: 'Dune', infoUrl: ' https://source.example.org/book/1 ' },
|
||||
{ guid: 'g2', title: 'Dune', infoUrl: { href: 'https://source.example.org/book/2' } },
|
||||
]);
|
||||
|
||||
expect(releases.map((release) => release.infoUrl)).toEqual(['https://source.example.org/book/1', undefined]);
|
||||
});
|
||||
|
||||
/** "1984" and "2001" are books, and a source that states one as a number still means the title. */
|
||||
it('keeps a title or a guid a source stated as a number', async () => {
|
||||
const [release] = await searched([{ guid: 4471, title: 1984 }]);
|
||||
|
||||
@@ -308,6 +308,7 @@ function sanitizeCandidate(raw: PluginReleaseCandidate, indexerId: number): Rele
|
||||
guid,
|
||||
title,
|
||||
...optional('bookTitle', text(raw.bookTitle, MAX_PLUGIN_TEXT_LENGTH)),
|
||||
...optional('infoUrl', text(raw.infoUrl, MAX_PLUGIN_URL_LENGTH)),
|
||||
...optional('downloadUrl', text(raw.downloadUrl, MAX_PLUGIN_URL_LENGTH)),
|
||||
...optional('magnet', text(raw.magnet, MAX_PLUGIN_URL_LENGTH)),
|
||||
...optional('infoHash', text(raw.infoHash, MAX_PLUGIN_TEXT_LENGTH)),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
/**
|
||||
* The identifier a client sees for a release, and the one a grab names it by.
|
||||
*
|
||||
* A source guid shaped like a URL or a magnet can carry a credential: a Newznab guid is often the
|
||||
* API-keyed details link, a Torznab proxy may reuse the tracker's passkeyed download link, and a
|
||||
* private tracker's magnet names its passkeyed announce URL. A digest keeps the identity stable
|
||||
* from one search to the next without the value ever leaving the server. Anything else is
|
||||
* already an opaque id and passes through unchanged.
|
||||
*/
|
||||
export function publicReleaseGuid(indexerId: number, value: string): string {
|
||||
if (!/^(https?|magnet):/i.test(value)) return value;
|
||||
return createHash('sha256').update(String(indexerId)).update('\0release\0').update(value).digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { indexerSecrets, safeReleaseInfoUrl, withSafeInfoUrl } from './release-info-url';
|
||||
|
||||
describe('safeReleaseInfoUrl', () => {
|
||||
it('passes an ordinary details page through', () => {
|
||||
expect(safeReleaseInfoUrl('https://tracker.example.com/details.php?id=123&hit=1', [])).toBe(
|
||||
'https://tracker.example.com/details.php?id=123&hit=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a Newznab comments link at the top of its page', () => {
|
||||
expect(safeReleaseInfoUrl('https://indexer.example.com/details/abc#comments', [])).toBe('https://indexer.example.com/details/abc');
|
||||
});
|
||||
|
||||
it('keeps any other anchor, which a hash-routed tracker needs to land on the release', () => {
|
||||
expect(safeReleaseInfoUrl('https://tracker.example.com/#/torrent/9', [])).toBe('https://tracker.example.com/#/torrent/9');
|
||||
});
|
||||
|
||||
it.each(['javascript:alert(1)', 'data:text/html,hi', 'magnet:?xt=urn:btih:abc', 'ftp://tracker.example.com/1', '/details/1', 'not a url'])(
|
||||
'drops %s, which is not an http(s) page',
|
||||
(raw) => {
|
||||
expect(safeReleaseInfoUrl(raw, [])).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
'https://indexer.example.com/details/1?apikey=abc',
|
||||
'https://indexer.example.com/details/1?api_key=abc',
|
||||
'https://jackett.example.com/dl/x?jackett_apikey=abc',
|
||||
'https://tracker.example.com/details.php?id=1&passkey=abc',
|
||||
'https://tracker.example.com/details.php?id=1&torrent_pass=abc',
|
||||
'https://tracker.example.com/details.php?id=1&authkey=abc',
|
||||
'https://tracker.example.com/details.php?id=1&access_token=abc',
|
||||
'https://tracker.example.com/details.php?id=1&rsskey=abc',
|
||||
'https://cdn.example.com/page?X-Amz-Signature=abc',
|
||||
])('drops %s, whose query names a credential', (raw) => {
|
||||
expect(safeReleaseInfoUrl(raw, [])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not mistake an author parameter for an auth one', () => {
|
||||
expect(safeReleaseInfoUrl('https://library.example.org/search?author=Herbert', [])).toBe('https://library.example.org/search?author=Herbert');
|
||||
});
|
||||
|
||||
it("drops a link carrying the indexer's own secret anywhere, path included", () => {
|
||||
expect(safeReleaseInfoUrl('https://tracker.example.com/details/1/s3cr3t-value', ['s3cr3t-value'])).toBeUndefined();
|
||||
expect(safeReleaseInfoUrl('https://tracker.example.com/details/1?ref=s3cr3t%2Fvalue', ['s3cr3t/value'])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a secret too short to search for without matching ordinary text', () => {
|
||||
expect(safeReleaseInfoUrl('https://tracker.example.com/details/1', ['1'])).toBe('https://tracker.example.com/details/1');
|
||||
});
|
||||
|
||||
it('drops a link carrying user info', () => {
|
||||
expect(safeReleaseInfoUrl('https://user:pass@tracker.example.com/details/1', [])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops a link longer than any page address', () => {
|
||||
expect(safeReleaseInfoUrl(`https://tracker.example.com/${'a'.repeat(2100)}`, [])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('indexerSecrets', () => {
|
||||
it('collects the credential and any key pasted into the base URL', () => {
|
||||
expect(indexerSecrets({ credential: 'stored-key', baseUrl: 'http://127.0.0.1:9117/api?apikey=base-key&t=caps' })).toEqual([
|
||||
'stored-key',
|
||||
'base-key',
|
||||
]);
|
||||
});
|
||||
|
||||
it('survives an unparseable base URL', () => {
|
||||
expect(indexerSecrets({ credential: null, baseUrl: 'not a url' })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('withSafeInfoUrl', () => {
|
||||
const candidate = { indexerId: 1, guid: 'g', title: 'Dune', sizeBytes: null, seeders: null, leechers: null };
|
||||
|
||||
it('returns the same candidate when there is no page link to vet', () => {
|
||||
expect(withSafeInfoUrl(candidate, [])).toBe(candidate);
|
||||
});
|
||||
|
||||
it('removes a page link that may not be shown', () => {
|
||||
expect(withSafeInfoUrl({ ...candidate, infoUrl: 'https://t.example/d/1?passkey=x' }, []).infoUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ReleaseCandidate, ResolvedIndexerConfig } from './indexer-adapter';
|
||||
|
||||
/** A details page is a short address. One longer than this is carrying something other than a page. */
|
||||
export const MAX_RELEASE_INFO_URL_LENGTH = 2_048;
|
||||
|
||||
/** Below this a credential is too short to search a URL for without matching ordinary text. */
|
||||
const MIN_SECRET_LENGTH = 6;
|
||||
|
||||
/**
|
||||
* Whether a query parameter name is one a credential travels under. Matched per word of the name,
|
||||
* so `jackett_apikey`, `api_key`, `torrent_pass` and `access_token` all fall to it while `author`
|
||||
* does not. Deliberately broad: a page link wrongly dropped costs the approver a search on the
|
||||
* tracker, while a key wrongly shown costs the account.
|
||||
*/
|
||||
function isSecretParamName(name: string): boolean {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.some((word) => /key$|^pass|token$|^secret|^auth$|^sig(nature)?$/.test(word));
|
||||
}
|
||||
|
||||
/**
|
||||
* The link a client may be shown for a release's page, or undefined where there is none it may.
|
||||
*
|
||||
* The value comes from a feed or a plugin nobody vets and lands in an `href`, so only http(s)
|
||||
* passes: anything else is either useless or `javascript:`. A link that could be carrying a
|
||||
* credential is dropped rather than cleaned. Rewriting one means knowing every tracker's
|
||||
* parameter names, and a key embedded in a path survives any rewrite; the indexer's own secrets
|
||||
* are matched anywhere in the address instead, which catches a key wherever it sits.
|
||||
*/
|
||||
export function safeReleaseInfoUrl(raw: string | undefined, secrets: readonly string[]): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed || trimmed.length > MAX_RELEASE_INFO_URL_LENGTH) return undefined;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined;
|
||||
if (url.username || url.password) return undefined;
|
||||
for (const name of url.searchParams.keys()) {
|
||||
if (isSecretParamName(name)) return undefined;
|
||||
}
|
||||
|
||||
const haystacks = [url.href, safeDecode(url.href)];
|
||||
for (const secret of secrets) {
|
||||
if (secret.length >= MIN_SECRET_LENGTH && haystacks.some((haystack) => haystack.includes(secret))) return undefined;
|
||||
}
|
||||
|
||||
// Newznab points `comments` at the thread at the foot of the details page. The page itself,
|
||||
// with the description at the top, is what an approver opens it for.
|
||||
if (url.hash.toLowerCase() === '#comments') url.hash = '';
|
||||
return url.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every value the server holds for this indexer that must never reach a client: the stored
|
||||
* credential, and any key the operator pasted into the base URL's own query string, which some
|
||||
* proxies hand out as the endpoint to configure.
|
||||
*/
|
||||
export function indexerSecrets(config: Pick<ResolvedIndexerConfig, 'credential' | 'baseUrl'>): string[] {
|
||||
const secrets = config.credential ? [config.credential] : [];
|
||||
try {
|
||||
for (const [name, value] of new URL(config.baseUrl).searchParams) {
|
||||
if (value && isSecretParamName(name)) secrets.push(value);
|
||||
}
|
||||
} catch {
|
||||
// An unparseable base URL cannot have been searched, so it has nothing to leak.
|
||||
}
|
||||
return secrets;
|
||||
}
|
||||
|
||||
/** The candidate with its page link vetted, or with no page link at all. */
|
||||
export function withSafeInfoUrl(candidate: ReleaseCandidate, secrets: readonly string[]): ReleaseCandidate {
|
||||
if (candidate.infoUrl === undefined) return candidate;
|
||||
const infoUrl = safeReleaseInfoUrl(candidate.infoUrl, secrets);
|
||||
return infoUrl === candidate.infoUrl ? candidate : { ...candidate, infoUrl };
|
||||
}
|
||||
|
||||
function safeDecode(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,7 @@ export function toReleaseItem(scored: ScoredRelease, indexerName: string, reques
|
||||
indexerName,
|
||||
guid: candidate.guid,
|
||||
title: candidate.title,
|
||||
infoUrl: candidate.infoUrl ?? null,
|
||||
sizeBytes: candidate.sizeBytes,
|
||||
seeders: candidate.seeders,
|
||||
leechers: candidate.leechers,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"totalRoutes": 663,
|
||||
"totalRoutes": 665,
|
||||
"byPermission": {
|
||||
"(none)": 287,
|
||||
"Permission.ManageMetadataConfig": 41,
|
||||
@@ -25,9 +25,9 @@
|
||||
"Permission.HardcoverSync": 1,
|
||||
"Permission.ReadwiseSync": 1,
|
||||
"Permission.StorygraphSync": 1,
|
||||
"Permission.BookRequestAccess": 26,
|
||||
"Permission.ManageBookRequests": 20,
|
||||
"Permission.BookRequestSelfFulfill": 11,
|
||||
"Permission.BookRequestAccess": 27,
|
||||
"Permission.ManageBookRequests": 21,
|
||||
"Permission.BookRequestSelfFulfill": 12,
|
||||
"Permission.PodcastManageFeeds": 16,
|
||||
"Permission.PodcastManageRetention": 1,
|
||||
"Permission.PodcastEditMetadata": 5,
|
||||
|
||||
@@ -528,6 +528,18 @@
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/book-request/book-request-admin.controller.ts",
|
||||
"className": "BookRequestAdminController",
|
||||
"methodName": "getReviewCover",
|
||||
"httpMethod": "GET",
|
||||
"path": "/admin/book-requests/:id/review/cover",
|
||||
"permissions": ["Permission.ManageBookRequests"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/book-request/book-request-admin.controller.ts",
|
||||
"className": "BookRequestAdminController",
|
||||
@@ -1056,6 +1068,18 @@
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/book-request/book-request-self-fulfil.controller.ts",
|
||||
"className": "BookRequestSelfFulfilController",
|
||||
"methodName": "getReviewCover",
|
||||
"httpMethod": "GET",
|
||||
"path": "/book-request-fulfilment/:id/review/cover",
|
||||
"permissions": ["Permission.BookRequestAccess", "Permission.BookRequestSelfFulfill"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/book-request/book-request-self-fulfil.controller.ts",
|
||||
"className": "BookRequestSelfFulfilController",
|
||||
|
||||
Reference in New Issue
Block a user