mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(book-requests): add per-indexer seed goals
Add configurable seed ratio and seed time goals to request indexers, supporting both static goals and tracker-advertised ratio/seed-time limits via torznab attributes. Enforce indexer-specific seed policy during request fulfillment and display active seed targets in the request seed panel. Closes #1360
This commit is contained in:
@@ -347,6 +347,24 @@ describe('RequestSearchPanel provider sources', () => {
|
||||
expect(document.body.textContent).toContain('Recommended')
|
||||
})
|
||||
|
||||
it('makes the selected media kind visually distinct', async () => {
|
||||
const wrapper = await render()
|
||||
const ebook = wrapper.findAll('button').find((button) => button.text() === 'E-book')!
|
||||
const audiobook = wrapper.findAll('button').find((button) => button.text() === 'Audiobook')!
|
||||
|
||||
expect(ebook.attributes('aria-pressed')).toBe('true')
|
||||
expect(ebook.classes()).toEqual(expect.arrayContaining(['bg-primary', 'text-primary-foreground']))
|
||||
expect(audiobook.attributes('aria-pressed')).toBe('false')
|
||||
expect(audiobook.classes()).toEqual(expect.arrayContaining(['text-foreground', 'hover:bg-accent']))
|
||||
|
||||
await audiobook.trigger('click')
|
||||
|
||||
expect(ebook.attributes('aria-pressed')).toBe('false')
|
||||
expect(ebook.classes()).toEqual(expect.arrayContaining(['text-foreground', 'hover:bg-accent']))
|
||||
expect(audiobook.attributes('aria-pressed')).toBe('true')
|
||||
expect(audiobook.classes()).toEqual(expect.arrayContaining(['bg-primary', 'text-primary-foreground']))
|
||||
})
|
||||
|
||||
it('defaults a user with both fulfillment permissions to automation', async () => {
|
||||
state.hasPermission.mockImplementation(
|
||||
(permission: string) => permission === Permission.BookRequestAutoApprove || permission === Permission.BookRequestSelfFulfill,
|
||||
@@ -358,7 +376,9 @@ describe('RequestSearchPanel provider sources', () => {
|
||||
const automatic = wrapper.findAll('button').find((button) => button.text() === 'Automatic')!
|
||||
const choose = wrapper.findAll('button').find((button) => button.text() === 'Choose a release')!
|
||||
expect(automatic.attributes('aria-pressed')).toBe('true')
|
||||
expect(automatic.classes()).toEqual(expect.arrayContaining(['bg-primary', 'text-primary-foreground']))
|
||||
expect(choose.attributes('aria-pressed')).toBe('false')
|
||||
expect(choose.classes()).toEqual(expect.arrayContaining(['text-foreground', 'hover:bg-accent']))
|
||||
expect(wrapper.find('button[aria-label="How BookOrbit chooses the recommended ISBN"]').exists()).toBe(false)
|
||||
|
||||
await wrapper
|
||||
@@ -385,12 +405,12 @@ describe('RequestSearchPanel provider sources', () => {
|
||||
.find((button) => button.text() === 'Choose a release')!
|
||||
.trigger('click')
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'Choose a release')!
|
||||
.attributes('aria-pressed'),
|
||||
).toBe('true')
|
||||
const automatic = wrapper.findAll('button').find((button) => button.text() === 'Automatic')!
|
||||
const choose = wrapper.findAll('button').find((button) => button.text() === 'Choose a release')!
|
||||
expect(automatic.attributes('aria-pressed')).toBe('false')
|
||||
expect(automatic.classes()).toEqual(expect.arrayContaining(['text-foreground', 'hover:bg-accent']))
|
||||
expect(choose.attributes('aria-pressed')).toBe('true')
|
||||
expect(choose.classes()).toEqual(expect.arrayContaining(['bg-primary', 'text-primary-foreground']))
|
||||
expect(wrapper.find('button[aria-label="How BookOrbit chooses the recommended ISBN"]').exists()).toBe(true)
|
||||
|
||||
await wrapper
|
||||
|
||||
@@ -80,6 +80,33 @@ describe('RequestSeedPanel', () => {
|
||||
expect(wrapper.text()).toContain('1.25 of 2')
|
||||
})
|
||||
|
||||
it('shows an active transfer as downloading rather than stopped', async () => {
|
||||
apiMock.mockResolvedValue(response(JSON.stringify(seed({ seeding: false, ratio: 0, seedingTimeSeconds: 0, uploadedBytes: 0 }))))
|
||||
|
||||
const wrapper = await mountPanel({
|
||||
request: request({ download: { ...request().download!, status: 'downloading' } }),
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Downloading')
|
||||
expect(wrapper.text()).not.toContain('Stopped')
|
||||
})
|
||||
|
||||
it('re-reads the seed state when the download advances', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(response(JSON.stringify(seed({ seeding: false, ratio: 0, seedingTimeSeconds: 0, uploadedBytes: 0 }))))
|
||||
.mockResolvedValueOnce(response(JSON.stringify(seed())))
|
||||
const wrapper = await mountPanel({
|
||||
request: request({ download: { ...request().download!, status: 'downloading' } }),
|
||||
})
|
||||
|
||||
await wrapper.setProps({ request: request() })
|
||||
await flushPromises()
|
||||
|
||||
expect(apiMock).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('Seeding')
|
||||
expect(wrapper.text()).not.toContain('Stopped')
|
||||
})
|
||||
|
||||
it('shows the ratio and the time against the goals the client was given', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
|
||||
|
||||
@@ -601,7 +601,7 @@ function mediaIconFor(kind: BookRequestMediaKind) {
|
||||
:key="kind"
|
||||
type="button"
|
||||
class="inline-flex h-full items-center gap-1.5 rounded-md px-2.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none"
|
||||
:class="mediaKind === kind ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'"
|
||||
:class="mediaKind === kind ? 'bg-primary text-primary-foreground shadow-sm' : 'text-foreground hover:bg-accent'"
|
||||
:aria-pressed="mediaKind === kind"
|
||||
@click="selectMediaKind(kind)"
|
||||
>
|
||||
@@ -618,7 +618,7 @@ function mediaIconFor(kind: BookRequestMediaKind) {
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-full flex-1 items-center justify-center rounded-md px-2.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none sm:flex-none"
|
||||
:class="fulfillmentMode === 'automatic' ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'"
|
||||
:class="fulfillmentMode === 'automatic' ? 'bg-primary text-primary-foreground shadow-sm' : 'text-foreground hover:bg-accent'"
|
||||
:aria-pressed="fulfillmentMode === 'automatic'"
|
||||
@click="selectAutomaticFulfillment"
|
||||
>
|
||||
@@ -627,9 +627,7 @@ function mediaIconFor(kind: BookRequestMediaKind) {
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-full flex-1 items-center justify-center rounded-md px-2.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none sm:flex-none"
|
||||
:class="
|
||||
fulfillmentMode === 'choose_release' ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
:class="fulfillmentMode === 'choose_release' ? 'bg-primary text-primary-foreground shadow-sm' : 'text-foreground hover:bg-accent'"
|
||||
:aria-pressed="fulfillmentMode === 'choose_release'"
|
||||
@click="selectReleaseFulfillment"
|
||||
>
|
||||
|
||||
@@ -33,6 +33,17 @@ const removalInFlight = ref(false)
|
||||
const download = computed(() => props.request.download)
|
||||
const isTorrentDownload = computed(() => download.value?.source === 'magnet' || download.value?.source === 'torrent_file')
|
||||
|
||||
const clientActivity = computed(() => {
|
||||
if (!status.value) return null
|
||||
if (status.value.seeding) {
|
||||
return { label: t('bookRequests.seed.seeding'), className: 'border-success/40 bg-success/10 text-success' }
|
||||
}
|
||||
if (download.value?.status === 'queued' || download.value?.status === 'downloading') {
|
||||
return { label: t(`bookRequests.download.status.${download.value.status}`), className: 'border-info/40 bg-info/10 text-info' }
|
||||
}
|
||||
return { label: t('bookRequests.seed.stopped'), className: 'border-border bg-muted text-muted-foreground' }
|
||||
})
|
||||
|
||||
/**
|
||||
* Ratio and seeding time are not two more figures: they are the two goals the client stops on, so
|
||||
* whichever of them the client actually reported a target for is drawn as a fraction of it. A goal
|
||||
@@ -87,14 +98,27 @@ const meters = computed<SeedMeter[]>(() => {
|
||||
return rows
|
||||
})
|
||||
|
||||
// A fresh request means a fresh read: a ratio from the request before this one is worse than none.
|
||||
// A fresh request means a fresh read, and an advancing attempt replaces any pre-seeding snapshot.
|
||||
watch(
|
||||
[() => props.request.id, () => download.value?.id, () => download.value?.source],
|
||||
([id]) => {
|
||||
deleteFiles.value = false
|
||||
removalInFlight.value = false
|
||||
reset()
|
||||
if (isTorrentDownload.value) void fetchStatus(id)
|
||||
() => ({
|
||||
requestId: props.request.id,
|
||||
downloadId: download.value?.id,
|
||||
source: download.value?.source,
|
||||
status: download.value?.status,
|
||||
}),
|
||||
(current, previous) => {
|
||||
const identityChanged =
|
||||
!previous || current.requestId !== previous.requestId || current.downloadId !== previous.downloadId || current.source !== previous.source
|
||||
|
||||
if (identityChanged) {
|
||||
deleteFiles.value = false
|
||||
removalInFlight.value = false
|
||||
reset()
|
||||
}
|
||||
|
||||
if (isTorrentDownload.value && (identityChanged || current.status !== previous.status)) {
|
||||
void fetchStatus(current.requestId)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -142,12 +166,9 @@ function confirmRemove() {
|
||||
<template v-else>
|
||||
<!-- Whether it is still seeding is the whole question, so it leads and never rides on colour alone. -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium"
|
||||
:class="status.seeding ? 'border-success/40 bg-success/10 text-success' : 'border-border bg-muted text-muted-foreground'"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium" :class="clientActivity?.className">
|
||||
<span class="size-1.5 rounded-full bg-current" aria-hidden="true" />
|
||||
{{ status.seeding ? t('bookRequests.seed.seeding') : t('bookRequests.seed.stopped') }}
|
||||
{{ clientActivity?.label }}
|
||||
</span>
|
||||
<span v-if="status.downloadClientName" class="truncate text-xs text-muted-foreground">{{ status.downloadClientName }}</span>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Loader2, Plug, Plus, RefreshCw, Server as ServerIcon, Trash2, TriangleAlert, Upload } from '@lucide/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { BOOK_REQUEST_MEDIA_KINDS, INDEXER_ADAPTER_TYPES } from '@bookorbit/types'
|
||||
import { BOOK_REQUEST_MEDIA_KINDS, INDEXER_ADAPTER_TYPES, MAX_INDEXER_SEED_TIME_MINUTES } from '@bookorbit/types'
|
||||
import type { IndexerAdapterDescriptor, IndexerItem, IndexerSettingsField } from '@bookorbit/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
@@ -85,6 +85,12 @@ const {
|
||||
cancelEdit,
|
||||
handleNameInput,
|
||||
handleBaseUrlInput,
|
||||
handleSeedRatioInput,
|
||||
handleSeedTimeInput,
|
||||
seedingAtDefaults,
|
||||
resetSeedingSettings,
|
||||
ratioSeedSummary,
|
||||
timeSeedSummary,
|
||||
markCredentialTouched,
|
||||
canClearCredential,
|
||||
toggleClearCredential,
|
||||
@@ -741,6 +747,83 @@ function handleTestCurrent() {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection v-if="currentAdapter?.seedsBack" :title="t('settings.system.requests.sections.seeding')">
|
||||
<SettingsToggleField
|
||||
v-model="draft.applyTrackerSeedGoals"
|
||||
:label="t('settings.system.requests.indexers.seeding.applyTracker')"
|
||||
input-id="indexer-apply-tracker-seed-goals"
|
||||
:brief="t('settings.system.requests.indexers.seeding.applyTrackerBrief')"
|
||||
/>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<SettingsField
|
||||
:label="t('settings.system.requests.indexers.seeding.ratio')"
|
||||
input-id="indexer-seed-ratio"
|
||||
:brief="t('settings.system.requests.indexers.seeding.ratioBrief')"
|
||||
:error="fieldErrors.seedRatioGoal"
|
||||
>
|
||||
<template #default="{ describedBy, invalid }">
|
||||
<input
|
||||
id="indexer-seed-ratio"
|
||||
:value="draft.seedRatioGoal"
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
inputmode="decimal"
|
||||
class="settings-control"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="invalid || undefined"
|
||||
@input="handleSeedRatioInput"
|
||||
/>
|
||||
</template>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
:label="t('settings.system.requests.indexers.seeding.time')"
|
||||
input-id="indexer-seed-time"
|
||||
:brief="t('settings.system.requests.indexers.seeding.timeBrief')"
|
||||
:error="fieldErrors.seedTimeMinutes"
|
||||
>
|
||||
<template #default="{ describedBy, invalid }">
|
||||
<input
|
||||
id="indexer-seed-time"
|
||||
:value="draft.seedTimeMinutes"
|
||||
type="number"
|
||||
min="1"
|
||||
:max="MAX_INDEXER_SEED_TIME_MINUTES"
|
||||
step="1"
|
||||
inputmode="numeric"
|
||||
class="settings-control"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="invalid || undefined"
|
||||
@input="handleSeedTimeInput"
|
||||
/>
|
||||
</template>
|
||||
</SettingsField>
|
||||
</div>
|
||||
|
||||
<p class="settings-hint">{{ t('settings.system.requests.indexers.seeding.clientSupport') }}</p>
|
||||
<p class="settings-hint">{{ t('settings.system.requests.indexers.seeding.futureGrabs') }}</p>
|
||||
|
||||
<div class="rounded-md border border-border bg-muted/30 p-3">
|
||||
<p class="settings-label">{{ t('settings.system.requests.indexers.seeding.summary.title') }}</p>
|
||||
<dl class="mt-2 grid gap-2 text-xs sm:grid-cols-2">
|
||||
<div>
|
||||
<dt class="text-muted-foreground">{{ t('settings.system.requests.indexers.seeding.summary.ratio') }}</dt>
|
||||
<dd class="text-foreground">{{ ratioSeedSummary }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted-foreground">{{ t('settings.system.requests.indexers.seeding.summary.time') }}</dt>
|
||||
<dd class="text-foreground">{{ timeSeedSummary }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<Button type="button" size="sm" variant="outline" :disabled="seedingAtDefaults" @click="resetSeedingSettings">
|
||||
{{ t('settings.system.requests.indexers.seeding.reset') }}
|
||||
</Button>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection v-if="currentAdapter?.settingsFields.length" :title="t('settings.system.requests.sections.access')">
|
||||
<template v-for="field in currentAdapter.settingsFields" :key="field.key">
|
||||
<SettingsToggleField
|
||||
|
||||
@@ -95,6 +95,9 @@ function indexer(overrides: Partial<IndexerItem> = {}): IndexerItem {
|
||||
baseUrl: 'http://127.0.0.1:9696/1',
|
||||
hasCredential: true,
|
||||
allowPrivateAddress: true,
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: null,
|
||||
categories: { ebook: [7020], audiobook: [3030], comic: [7030] },
|
||||
disabledMediaKinds: [],
|
||||
isbnSearchDisabled: false,
|
||||
@@ -770,6 +773,155 @@ describe('RequestIndexersPanel', () => {
|
||||
expect(sheet().querySelector('#indexer-categories-ebook')).not.toBeNull()
|
||||
})
|
||||
|
||||
describe('seeding policy', () => {
|
||||
it('shows controls for torrent adapters but not non-seeding sources', async () => {
|
||||
const torrent = await mountPanel({ adapters: [descriptor()] })
|
||||
await openCreate(torrent)
|
||||
expect(sheet().querySelector('#indexer-seed-ratio')).not.toBeNull()
|
||||
torrent.unmount()
|
||||
document.body.innerHTML = ''
|
||||
|
||||
const plugin = await mountPanel({ adapters: [PLUGIN] })
|
||||
await openCreate(plugin, 'demo-tracker')
|
||||
expect(sheet().querySelector('#indexer-seed-time')).not.toBeNull()
|
||||
plugin.unmount()
|
||||
document.body.innerHTML = ''
|
||||
|
||||
const direct = await mountPanel({ adapters: [OPEN_LIBRARY] })
|
||||
await openCreate(direct, 'open-library')
|
||||
expect(sheet().querySelector('#indexer-seed-ratio')).toBeNull()
|
||||
})
|
||||
|
||||
it('starts enabled with blank manual values and sends exact DTO field names', async () => {
|
||||
const wrapper = await mountPanel({ adapters: [descriptor()] })
|
||||
await openCreate(wrapper)
|
||||
expect(sheet().querySelector('#indexer-apply-tracker-seed-goals')?.getAttribute('aria-checked')).toBe('true')
|
||||
expect(sheet().querySelector<HTMLInputElement>('#indexer-seed-ratio')?.value).toBe('')
|
||||
typeInto('#indexer-name', 'MAM')
|
||||
typeInto('#indexer-url', 'https://tracker.example')
|
||||
typeInto('#indexer-seed-ratio', '1.5')
|
||||
typeInto('#indexer-seed-time', '60')
|
||||
|
||||
clickInSheet('Save')
|
||||
await flushPromises()
|
||||
|
||||
const post = apiMock.mock.calls.find(([, init]) => init?.method === 'POST')!
|
||||
expect(JSON.parse(String((post[1] as RequestInit).body))).toMatchObject({
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: 1.5,
|
||||
seedTimeMinutes: 60,
|
||||
})
|
||||
})
|
||||
|
||||
it('populates values and serializes a clear as null rather than zero', async () => {
|
||||
const wrapper = await mountPanel({
|
||||
adapters: [descriptor()],
|
||||
indexers: [indexer({ applyTrackerSeedGoals: false, seedRatioGoal: 2.5, seedTimeMinutes: 90 })],
|
||||
})
|
||||
await clickInPanel(wrapper, 'Edit My Prowlarr')
|
||||
expect(sheet().querySelector<HTMLInputElement>('#indexer-seed-ratio')?.value).toBe('2.5')
|
||||
expect(sheet().querySelector('#indexer-apply-tracker-seed-goals')?.getAttribute('aria-checked')).toBe('false')
|
||||
typeInto('#indexer-seed-ratio', '')
|
||||
|
||||
clickInSheet('Save')
|
||||
await flushPromises()
|
||||
|
||||
const put = apiMock.mock.calls.find(([, init]) => init?.method === 'PUT')!
|
||||
expect(JSON.parse(String((put[1] as RequestInit).body))).toMatchObject({ seedRatioGoal: null, seedTimeMinutes: 90 })
|
||||
})
|
||||
|
||||
it('keeps manual inputs usable when tracker fallback is off', async () => {
|
||||
const wrapper = await mountPanel({ adapters: [descriptor()] })
|
||||
await openCreate(wrapper)
|
||||
sheet().querySelector<HTMLButtonElement>('#indexer-apply-tracker-seed-goals')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(sheet().querySelector<HTMLInputElement>('#indexer-seed-time')?.disabled).toBe(false)
|
||||
expect(sheet().textContent).toContain('Client default')
|
||||
})
|
||||
|
||||
it('associates invalid values, blocks saving, and focuses the first invalid field', async () => {
|
||||
const wrapper = await mountPanel({ adapters: [descriptor()] })
|
||||
await openCreate(wrapper)
|
||||
typeInto('#indexer-name', 'MAM')
|
||||
typeInto('#indexer-url', 'https://tracker.example')
|
||||
typeInto('#indexer-seed-ratio', '0')
|
||||
typeInto('#indexer-seed-time', '1.5')
|
||||
apiMock.mockClear()
|
||||
|
||||
clickInSheet('Save')
|
||||
await flushPromises()
|
||||
|
||||
expect(sheet().querySelector('#indexer-seed-ratio-error')).not.toBeNull()
|
||||
expect(sheet().querySelector('#indexer-seed-time-error')).not.toBeNull()
|
||||
expect(sheet().querySelector('#indexer-seed-ratio')?.getAttribute('aria-describedby')).toBe('indexer-seed-ratio-error')
|
||||
expect(document.activeElement?.id).toBe('indexer-seed-ratio')
|
||||
expect(apiMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets only the draft and updates the summaries', async () => {
|
||||
const wrapper = await mountPanel({
|
||||
adapters: [descriptor()],
|
||||
indexers: [indexer({ applyTrackerSeedGoals: false, seedRatioGoal: 2, seedTimeMinutes: 90 })],
|
||||
})
|
||||
await clickInPanel(wrapper, 'Edit My Prowlarr')
|
||||
expect(sheet().textContent).toContain('Manual: 90')
|
||||
|
||||
clickInSheet('Reset seeding settings')
|
||||
await flushPromises()
|
||||
|
||||
expect(sheet().querySelector<HTMLInputElement>('#indexer-seed-time')?.value).toBe('')
|
||||
expect(sheet().textContent).toContain('Tracker fallback')
|
||||
expect(apiMock.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not serialize hidden fields when a saved plugin descriptor is missing', async () => {
|
||||
const wrapper = await mountPanel({
|
||||
adapters: [descriptor()],
|
||||
indexers: [indexer({ adapterType: 'missing-plugin', seedRatioGoal: 2, seedTimeMinutes: 90 })],
|
||||
})
|
||||
await clickInPanel(wrapper, 'Edit My Prowlarr')
|
||||
expect(sheet().querySelector('#indexer-seed-ratio')).toBeNull()
|
||||
|
||||
clickInSheet('Save')
|
||||
await flushPromises()
|
||||
|
||||
const put = apiMock.mock.calls.find(([, init]) => init?.method === 'PUT')!
|
||||
const body = JSON.parse(String((put[1] as RequestInit).body))
|
||||
expect(body).not.toHaveProperty('seedRatioGoal')
|
||||
expect(body).not.toHaveProperty('seedTimeMinutes')
|
||||
})
|
||||
|
||||
it('retains a changed seed draft after save failure and restores persisted values after cancel', async () => {
|
||||
const stored = indexer({ seedRatioGoal: 2 })
|
||||
const wrapper = await mountPanel({ adapters: [descriptor()], indexers: [stored] })
|
||||
await clickInPanel(wrapper, 'Edit My Prowlarr')
|
||||
typeInto('#indexer-seed-ratio', '3')
|
||||
await flushPromises()
|
||||
|
||||
expect(sheet().querySelector('button[aria-label="Test connection"]')).toBeNull()
|
||||
apiMock.mockImplementation((url: string, init?: RequestInit) =>
|
||||
Promise.resolve(
|
||||
init?.method === 'PUT'
|
||||
? response({ errorCode: 'INDEXER_SETTINGS_INVALID', message: 'invalid policy' }, false)
|
||||
: url.endsWith('/adapters')
|
||||
? response({ adapters: [descriptor()], pluginFailures: [] })
|
||||
: response({ indexers: [stored], encryptionConfigured: true }),
|
||||
),
|
||||
)
|
||||
|
||||
clickInSheet('Save')
|
||||
await flushPromises()
|
||||
|
||||
expect(sheet().querySelector<HTMLInputElement>('#indexer-seed-ratio')?.value).toBe('3')
|
||||
expect(toastMock.error).toHaveBeenCalledWith('One or more source settings are invalid. Review the available choices.')
|
||||
|
||||
clickInSheet('Cancel')
|
||||
await clickInPanel(wrapper, 'Edit My Prowlarr')
|
||||
expect(sheet().querySelector<HTMLInputElement>('#indexer-seed-ratio')?.value).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
/** A plugin declares its own fields, and the form has never heard of them at build time. */
|
||||
it('renders the settings fields an adapter declared', async () => {
|
||||
const wrapper = await mountPanel({ adapters: [PLUGIN] })
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { computed, reactive, ref, type Ref } from 'vue'
|
||||
import { computed, nextTick, reactive, ref, type Ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { BOOK_REQUEST_MEDIA_KINDS, INDEXER_ADAPTER_TYPES, pickUnusedIndexerColor } from '@bookorbit/types'
|
||||
import { BOOK_REQUEST_MEDIA_KINDS, INDEXER_ADAPTER_TYPES, MAX_INDEXER_SEED_TIME_MINUTES, pickUnusedIndexerColor } from '@bookorbit/types'
|
||||
import { formatNumber } from '@/i18n/formatters'
|
||||
import { useSettingsDraft } from './useSettingsDraft'
|
||||
import type {
|
||||
BookRequestMediaKind,
|
||||
@@ -32,6 +33,11 @@ export interface IndexerDraft {
|
||||
credentialCleared: boolean
|
||||
enabled: boolean
|
||||
allowPrivateAddress: boolean
|
||||
applyTrackerSeedGoals: boolean
|
||||
seedRatioGoal: string
|
||||
seedTimeMinutes: string
|
||||
seedRatioBadInput: boolean
|
||||
seedTimeBadInput: boolean
|
||||
categories: CategoryDraft
|
||||
/**
|
||||
* Held as what the source *is* searched for, because that is what the form shows and what an
|
||||
@@ -47,7 +53,7 @@ export interface IndexerDraft {
|
||||
}
|
||||
|
||||
/** The fields a save can be rejected for, so the diagnosis lands on the box that caused it. */
|
||||
export type FieldKey = 'name' | 'baseUrl' | 'credential'
|
||||
export type FieldKey = 'name' | 'baseUrl' | 'credential' | 'seedRatioGoal' | 'seedTimeMinutes'
|
||||
|
||||
/**
|
||||
* Server codes carry the copy; the English `message` is a last resort for anything unmapped, which
|
||||
@@ -151,6 +157,11 @@ export function useIndexerDraft(options: IndexerDraftOptions) {
|
||||
credentialCleared: false,
|
||||
enabled: true,
|
||||
allowPrivateAddress: false,
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: '',
|
||||
seedTimeMinutes: '',
|
||||
seedRatioBadInput: false,
|
||||
seedTimeBadInput: false,
|
||||
categories: toCategoryDraft(adapter?.defaultCategories ?? { ebook: [], audiobook: [], comic: [] }),
|
||||
searchedMediaKinds: [...BOOK_REQUEST_MEDIA_KINDS],
|
||||
searchByIsbn: true,
|
||||
@@ -200,6 +211,11 @@ export function useIndexerDraft(options: IndexerDraftOptions) {
|
||||
credentialCleared: false,
|
||||
enabled: indexer.enabled,
|
||||
allowPrivateAddress: indexer.allowPrivateAddress,
|
||||
applyTrackerSeedGoals: indexer.applyTrackerSeedGoals,
|
||||
seedRatioGoal: indexer.seedRatioGoal === null ? '' : String(indexer.seedRatioGoal),
|
||||
seedTimeMinutes: indexer.seedTimeMinutes === null ? '' : String(indexer.seedTimeMinutes),
|
||||
seedRatioBadInput: false,
|
||||
seedTimeBadInput: false,
|
||||
categories: toCategoryDraft(indexer.categories),
|
||||
searchedMediaKinds: BOOK_REQUEST_MEDIA_KINDS.filter((mediaKind) => !indexer.disabledMediaKinds.includes(mediaKind)),
|
||||
searchByIsbn: !indexer.isbnSearchDisabled,
|
||||
@@ -230,6 +246,70 @@ export function useIndexerDraft(options: IndexerDraftOptions) {
|
||||
clearFieldError('baseUrl')
|
||||
}
|
||||
|
||||
type SeedDraftKey = 'seedRatioGoal' | 'seedTimeMinutes'
|
||||
type SeedBadInputKey = 'seedRatioBadInput' | 'seedTimeBadInput'
|
||||
|
||||
function handleSeedInput(event: Event, field: SeedDraftKey, badInputField: SeedBadInputKey) {
|
||||
const current = draft.value
|
||||
if (!current) return
|
||||
const input = event.target as HTMLInputElement
|
||||
current[badInputField] = input.validity.badInput
|
||||
current[field] = input.value
|
||||
clearFieldError(field)
|
||||
}
|
||||
|
||||
function handleSeedRatioInput(event: Event) {
|
||||
handleSeedInput(event, 'seedRatioGoal', 'seedRatioBadInput')
|
||||
}
|
||||
|
||||
function handleSeedTimeInput(event: Event) {
|
||||
handleSeedInput(event, 'seedTimeMinutes', 'seedTimeBadInput')
|
||||
}
|
||||
|
||||
const seedingAtDefaults = computed(() => {
|
||||
const current = draft.value
|
||||
return (
|
||||
!current ||
|
||||
(current.applyTrackerSeedGoals &&
|
||||
current.seedRatioGoal === '' &&
|
||||
current.seedTimeMinutes === '' &&
|
||||
!current.seedRatioBadInput &&
|
||||
!current.seedTimeBadInput)
|
||||
)
|
||||
})
|
||||
|
||||
function resetSeedingSettings() {
|
||||
const current = draft.value
|
||||
if (!current) return
|
||||
current.applyTrackerSeedGoals = true
|
||||
current.seedRatioGoal = ''
|
||||
current.seedTimeMinutes = ''
|
||||
current.seedRatioBadInput = false
|
||||
current.seedTimeBadInput = false
|
||||
clearFieldError('seedRatioGoal')
|
||||
clearFieldError('seedTimeMinutes')
|
||||
}
|
||||
|
||||
function summaryFor(raw: string, trackerFallback: boolean): string {
|
||||
const parsed = Number(raw)
|
||||
if (raw !== '' && Number.isFinite(parsed) && parsed > 0) {
|
||||
return t('settings.system.requests.indexers.seeding.summary.manual', { value: formatNumber(parsed) })
|
||||
}
|
||||
return trackerFallback
|
||||
? t('settings.system.requests.indexers.seeding.summary.tracker')
|
||||
: t('settings.system.requests.indexers.seeding.summary.client')
|
||||
}
|
||||
|
||||
const timeSeedSummary = computed(() => {
|
||||
const current = draft.value
|
||||
return current ? summaryFor(current.seedTimeMinutes, current.applyTrackerSeedGoals) : ''
|
||||
})
|
||||
|
||||
const ratioSeedSummary = computed(() => {
|
||||
const current = draft.value
|
||||
return current ? summaryFor(current.seedRatioGoal, current.applyTrackerSeedGoals) : ''
|
||||
})
|
||||
|
||||
function markCredentialTouched() {
|
||||
if (!draft.value) return
|
||||
draft.value.credentialTouched = draft.value.credential.length > 0
|
||||
@@ -378,6 +458,13 @@ export function useIndexerDraft(options: IndexerDraftOptions) {
|
||||
...(current.credentialCleared ? { credential: '' } : current.credentialTouched ? { credential: current.credential } : {}),
|
||||
enabled: current.enabled,
|
||||
allowPrivateAddress: current.allowPrivateAddress,
|
||||
...(currentAdapter.value?.seedsBack
|
||||
? {
|
||||
applyTrackerSeedGoals: current.applyTrackerSeedGoals,
|
||||
seedRatioGoal: current.seedRatioGoal === '' ? null : Number(current.seedRatioGoal),
|
||||
seedTimeMinutes: current.seedTimeMinutes === '' ? null : Number(current.seedTimeMinutes),
|
||||
}
|
||||
: {}),
|
||||
categories: {
|
||||
ebook: parseCategories(current.categories.ebook),
|
||||
audiobook: parseCategories(current.categories.audiobook),
|
||||
@@ -405,7 +492,11 @@ export function useIndexerDraft(options: IndexerDraftOptions) {
|
||||
clearFieldErrors()
|
||||
if (!current.name.trim()) fieldErrors.name = t('settings.system.requests.indexers.errors.nameRequired')
|
||||
if (!current.baseUrl.trim()) fieldErrors.baseUrl = t('settings.system.requests.indexers.errors.urlRequired')
|
||||
if (Object.keys(fieldErrors).length > 0) return
|
||||
if (currentAdapter.value?.seedsBack) validateSeedDraft(current)
|
||||
if (Object.keys(fieldErrors).length > 0) {
|
||||
await focusFirstInvalidField()
|
||||
return
|
||||
}
|
||||
|
||||
const failure = await save(current.id, toPayload(current))
|
||||
if (failure) {
|
||||
@@ -418,6 +509,43 @@ export function useIndexerDraft(options: IndexerDraftOptions) {
|
||||
cancelEdit()
|
||||
}
|
||||
|
||||
function validateSeedDraft(current: IndexerDraft) {
|
||||
if (current.seedRatioBadInput || !validRatio(current.seedRatioGoal)) {
|
||||
fieldErrors.seedRatioGoal = t('settings.system.requests.indexers.seeding.errors.ratio')
|
||||
}
|
||||
if (current.seedTimeBadInput || !validMinutes(current.seedTimeMinutes)) {
|
||||
fieldErrors.seedTimeMinutes = t('settings.system.requests.indexers.seeding.errors.minutes')
|
||||
}
|
||||
}
|
||||
|
||||
function validRatio(raw: string): boolean {
|
||||
if (raw === '') return true
|
||||
if (!/^(?:\d+(?:\.\d*)?|\.\d+)$/.test(raw)) return false
|
||||
const value = Number(raw)
|
||||
return Number.isFinite(value) && value > 0
|
||||
}
|
||||
|
||||
function validMinutes(raw: string): boolean {
|
||||
if (raw === '') return true
|
||||
if (!/^\d+$/.test(raw)) return false
|
||||
const value = Number(raw)
|
||||
return Number.isSafeInteger(value) && value >= 1 && value <= MAX_INDEXER_SEED_TIME_MINUTES
|
||||
}
|
||||
|
||||
const FIELD_IDS: Record<FieldKey, string> = {
|
||||
name: 'indexer-name',
|
||||
baseUrl: 'indexer-url',
|
||||
credential: 'indexer-credential',
|
||||
seedRatioGoal: 'indexer-seed-ratio',
|
||||
seedTimeMinutes: 'indexer-seed-time',
|
||||
}
|
||||
|
||||
async function focusFirstInvalidField() {
|
||||
await nextTick()
|
||||
const field = (Object.keys(fieldErrors) as FieldKey[])[0]
|
||||
if (field) document.getElementById(FIELD_IDS[field])?.focus()
|
||||
}
|
||||
|
||||
return {
|
||||
draft,
|
||||
editorOpen,
|
||||
@@ -436,6 +564,12 @@ export function useIndexerDraft(options: IndexerDraftOptions) {
|
||||
cancelEdit,
|
||||
handleNameInput,
|
||||
handleBaseUrlInput,
|
||||
handleSeedRatioInput,
|
||||
handleSeedTimeInput,
|
||||
seedingAtDefaults,
|
||||
resetSeedingSettings,
|
||||
timeSeedSummary,
|
||||
ratioSeedSummary,
|
||||
markCredentialTouched,
|
||||
canClearCredential,
|
||||
toggleClearCredential,
|
||||
|
||||
@@ -3034,6 +3034,29 @@
|
||||
"settings": {
|
||||
"restoreDefault": "Restore defaults"
|
||||
},
|
||||
"seeding": {
|
||||
"applyTracker": "Apply tracker seed goals",
|
||||
"applyTrackerBrief": "When a manual value is blank, use a valid goal reported by the tracker. When this is off, blank values use the download client's defaults. Manual values still apply.",
|
||||
"ratio": "Seed ratio",
|
||||
"ratioBrief": "A positive decimal. Blank inherits from the tracker or client.",
|
||||
"time": "Seed time (minutes)",
|
||||
"timeBrief": "A positive whole number. Blank inherits from the tracker or client.",
|
||||
"clientSupport": "qBittorrent accepts ratio and total seed time. Deluge and Transmission accept ratio only. Client global rules and whichever limit is reached first can stop seeding, so these values do not guarantee that every tracker minimum is met.",
|
||||
"futureGrabs": "Changes apply to future grabs. Existing or adopted torrents can retain client-specific limits. Turning tracker fallback off leaves client defaults active; it does not mean unlimited seeding.",
|
||||
"reset": "Reset seeding settings",
|
||||
"summary": {
|
||||
"title": "Policy summary",
|
||||
"ratio": "Ratio",
|
||||
"time": "Seed time",
|
||||
"manual": "Manual: {value}",
|
||||
"tracker": "Tracker fallback",
|
||||
"client": "Client default"
|
||||
},
|
||||
"errors": {
|
||||
"ratio": "Enter a finite number greater than zero, or leave this blank.",
|
||||
"minutes": "Enter a whole number from 1 through 2,147,483,647, or leave this blank."
|
||||
}
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Delete this source?",
|
||||
"description": "{name}'s address, settings and stored credential are deleted. Turn it off instead to stop searching it without losing them.",
|
||||
@@ -3249,6 +3272,7 @@
|
||||
"connection": "Connection",
|
||||
"search": "What to search",
|
||||
"access": "Access",
|
||||
"seeding": "Seeding",
|
||||
"availability": "Availability",
|
||||
"behaviour": "Behaviour"
|
||||
},
|
||||
|
||||
@@ -86,6 +86,24 @@ Two health facts are shown per row, and they answer different questions:
|
||||
refuse every search, and the badge counts consecutive failures so one blip reads differently
|
||||
from a source that has been broken for a week.
|
||||
|
||||
### Per-indexer seed goals
|
||||
|
||||
Torrent sources can set a manual seed ratio and total seed time. Each manual field is independent.
|
||||
Leaving one blank means inheritance; zero and negative values are not special modes. **Apply tracker
|
||||
seed goals** controls only whether a valid goal reported by Torznab or a torrent plugin fills a
|
||||
blank manual field. Turning it off does not disable manual values and does not request unlimited
|
||||
seeding.
|
||||
|
||||
For ratio, BookOrbit uses the manual source value, then the tracker value when tracker fallback is
|
||||
enabled, then omits the value so the download client uses its own default. Seed time follows the
|
||||
same order: manual source value, tracker value when tracker fallback is enabled, then the download
|
||||
client's default.
|
||||
|
||||
The policy is read when a new grab attempt starts, so a saved edit applies even when BookOrbit is
|
||||
reusing recently inspected torrent bytes. It applies only to releases selected from that configured
|
||||
source. A pasted magnet or uploaded torrent file has no indexer policy. Changes do not reconfigure
|
||||
torrents already in a client.
|
||||
|
||||
## Download clients
|
||||
|
||||
Settings > System > Requests > Download clients. qBittorrent, Transmission and Deluge accept
|
||||
@@ -119,6 +137,19 @@ to copying it and the book uses space twice while the original keeps seeding.
|
||||
BookOrbit never stops a seed on its own. Removing a client-managed download is an explicit action
|
||||
with its own confirmation, and removing one that is still working also fails the request.
|
||||
|
||||
qBittorrent accepts a per-torrent ratio and total seeding-time limit through fields documented
|
||||
since Web API 2.8.1. When both exist, its own stop rules can act when either limit is reached; the
|
||||
pair is not an AND condition and does not guarantee both tracker minimums. Deluge accepts the ratio
|
||||
with stop-at-ratio enabled and removal disabled.
|
||||
Transmission accepts the ratio in a best-effort call after adding the torrent; its idle-time option
|
||||
is not used as total seed time. A failure in that Transmission policy update leaves the tracked
|
||||
torrent in place and is logged as a warning.
|
||||
|
||||
Client-wide ratio, time, idle, and completion settings still apply. qBittorrent and Deluge can
|
||||
adopt a duplicate torrent without replacing its existing limits; Transmission attempts to apply
|
||||
an explicit ratio to an adopted torrent. BookOrbit does not delete and re-add an adopted torrent,
|
||||
reconcile limits across clients, or run a background seed stopper.
|
||||
|
||||
## Automation
|
||||
|
||||
Settings > System > Requests > Automation. Everything here is off or unset by default.
|
||||
|
||||
@@ -79,6 +79,9 @@ export const INDEXER_SEEDS_BACK: Record<IndexerAdapterType, boolean> = {
|
||||
newznab: false,
|
||||
};
|
||||
|
||||
/** Largest whole-minute seed goal that can be stored in PostgreSQL's integer type. */
|
||||
export const MAX_INDEXER_SEED_TIME_MINUTES = 2_147_483_647;
|
||||
|
||||
/** How a selected release from each built-in reaches BookOrbit. */
|
||||
export const INDEXER_DELIVERY: Record<IndexerAdapterType, DownloadDelivery> = {
|
||||
torznab: "torrent",
|
||||
@@ -260,6 +263,12 @@ export interface IndexerItem {
|
||||
* exception, so it is an explicit per-row opt-in rather than a blanket relaxation.
|
||||
*/
|
||||
allowPrivateAddress: boolean;
|
||||
/** Whether valid tracker-provided goals fill manual dimensions that are left unset. */
|
||||
applyTrackerSeedGoals: boolean;
|
||||
/** Null explicitly leaves this manual dimension unset. */
|
||||
seedRatioGoal: number | null;
|
||||
/** Null explicitly leaves manual torrent seed time unset. */
|
||||
seedTimeMinutes: number | null;
|
||||
categories: IndexerCategoryMap;
|
||||
/**
|
||||
* Media this source is not to be searched for, on the operator's say-so rather than the
|
||||
@@ -324,6 +333,12 @@ export interface CreateIndexerPayload {
|
||||
credential?: string | null;
|
||||
enabled?: boolean;
|
||||
allowPrivateAddress?: boolean;
|
||||
/** Controls tracker fallback only; configured manual values still apply when false. */
|
||||
applyTrackerSeedGoals?: boolean;
|
||||
/** Null explicitly clears the manual ratio. */
|
||||
seedRatioGoal?: number | null;
|
||||
/** Null explicitly clears the manual seed time. */
|
||||
seedTimeMinutes?: number | null;
|
||||
categories?: Partial<IndexerCategoryMap>;
|
||||
disabledMediaKinds?: BookRequestMediaKind[];
|
||||
isbnSearchDisabled?: boolean;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "request_indexers" ADD COLUMN "apply_tracker_seed_goals" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "request_indexers" ADD COLUMN "seed_ratio_goal" double precision;--> statement-breakpoint
|
||||
ALTER TABLE "request_indexers" ADD COLUMN "seed_time_minutes" integer;--> statement-breakpoint
|
||||
ALTER TABLE "request_indexers" ADD CONSTRAINT "request_indexers_seed_ratio_goal_chk" CHECK ("request_indexers"."seed_ratio_goal" is null or ("request_indexers"."seed_ratio_goal" > 0 and "request_indexers"."seed_ratio_goal" < 'Infinity'::double precision));--> statement-breakpoint
|
||||
ALTER TABLE "request_indexers" ADD CONSTRAINT "request_indexers_seed_time_minutes_chk" CHECK ("request_indexers"."seed_time_minutes" is null or "request_indexers"."seed_time_minutes" > 0);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -631,6 +631,13 @@
|
||||
"when": 1789273281909,
|
||||
"tag": "0089_add_newznab_nzbget",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 90,
|
||||
"version": "7",
|
||||
"when": 1789316739511,
|
||||
"tag": "0090_add_indexer_seed_goals",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,4 +43,19 @@ describe('request indexer schema SQL matches the shared constants', () => {
|
||||
expect(slug.test(bad)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults tracker fallback on and keeps manual goals nullable', () => {
|
||||
expect(requestIndexers.applyTrackerSeedGoals.notNull).toBe(true);
|
||||
expect(requestIndexers.applyTrackerSeedGoals.hasDefault).toBe(true);
|
||||
expect(requestIndexers.applyTrackerSeedGoals.default).toBe(true);
|
||||
expect(requestIndexers.seedRatioGoal.notNull).toBe(false);
|
||||
expect(requestIndexers.seedTimeMinutes.notNull).toBe(false);
|
||||
});
|
||||
|
||||
it('declares finite-positive ratio and positive-minute checks', () => {
|
||||
const checks = new Map(config.checks.map((constraint) => [constraint.name, toSql(constraint.value)]));
|
||||
expect(checks.get('request_indexers_seed_ratio_goal_chk')).toContain("< 'Infinity'::double precision");
|
||||
expect(checks.get('request_indexers_seed_ratio_goal_chk')).toContain('> 0');
|
||||
expect(checks.get('request_indexers_seed_time_minutes_chk')).toContain('> 0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { boolean, check, index, integer, jsonb, pgTable, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
import { boolean, check, doublePrecision, index, integer, jsonb, pgTable, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
|
||||
import type { BookRequestMediaKind, IndexerCategoryMap, IndexerColor, IndexerSettings, NetworkProfile } from '@bookorbit/types';
|
||||
|
||||
/**
|
||||
@@ -29,6 +29,9 @@ export const requestIndexers = pgTable(
|
||||
* is an explicit per-row opt-in with the implication stated in the UI.
|
||||
*/
|
||||
allowPrivateAddress: boolean('allow_private_address').notNull().default(false),
|
||||
applyTrackerSeedGoals: boolean('apply_tracker_seed_goals').notNull().default(true),
|
||||
seedRatioGoal: doublePrecision('seed_ratio_goal'),
|
||||
seedTimeMinutes: integer('seed_time_minutes'),
|
||||
|
||||
/** Which of this indexer's own categories to search per requested medium. */
|
||||
categories: jsonb('categories').$type<IndexerCategoryMap>(),
|
||||
@@ -90,6 +93,11 @@ export const requestIndexers = pgTable(
|
||||
// externally loaded adapters name themselves. `IndexerRegistry.require()` is what actually
|
||||
// rejects an unknown type, loudly, at search and grab time.
|
||||
check('request_indexers_adapter_type_chk', sql`${t.adapterType} ~ '^[a-z0-9][a-z0-9-]{0,29}$'`),
|
||||
check(
|
||||
'request_indexers_seed_ratio_goal_chk',
|
||||
sql`${t.seedRatioGoal} is null or (${t.seedRatioGoal} > 0 and ${t.seedRatioGoal} < 'Infinity'::double precision)`,
|
||||
),
|
||||
check('request_indexers_seed_time_minutes_chk', sql`${t.seedTimeMinutes} is null or ${t.seedTimeMinutes} > 0`),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -207,6 +207,15 @@ describe('DelugeAdapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not synthesize a ratio from an unsupported seed-time goal', async () => {
|
||||
const { calls, handlers } = mockRpc();
|
||||
handlers.set('core.add_torrent_magnet', () => result(INFO_HASH));
|
||||
|
||||
await adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH, seedTimeMinutes: 4320 }, config());
|
||||
|
||||
expect(calls.find((call) => call.method === 'core.add_torrent_magnet')?.params[1]).toEqual({ add_paused: false });
|
||||
});
|
||||
|
||||
/**
|
||||
* An earlier attempt on this release leaves its torrent behind when the import fails. Deluge
|
||||
* refuses it outright rather than adopting it, so without this every retry of that release
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ResolvedIndexerSeedPolicy } from '../indexers/indexer-adapter';
|
||||
import { resolveEffectiveSeedGoals } from './indexer-seed-policy';
|
||||
|
||||
describe('resolveEffectiveSeedGoals', () => {
|
||||
const policy: ResolvedIndexerSeedPolicy = {
|
||||
id: 1,
|
||||
adapterType: 'torznab',
|
||||
seedsBack: true,
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: null,
|
||||
};
|
||||
|
||||
it('resolves ratio and time independently', () => {
|
||||
expect(resolveEffectiveSeedGoals({ ...policy, seedRatioGoal: 1.5 }, { seedRatioGoal: 2, seedTimeMinutes: 61 })).toEqual({
|
||||
seedRatioGoal: 1.5,
|
||||
seedTimeMinutes: 61,
|
||||
ratioOrigin: 'manual',
|
||||
timeOrigin: 'tracker',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses manual time ahead of tracker time', () => {
|
||||
expect(resolveEffectiveSeedGoals({ ...policy, seedTimeMinutes: 120 }, { seedTimeMinutes: 60 })).toEqual({
|
||||
seedTimeMinutes: 120,
|
||||
ratioOrigin: 'client_default',
|
||||
timeOrigin: 'manual',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps manual values and drops tracker fallback when disabled', () => {
|
||||
expect(
|
||||
resolveEffectiveSeedGoals({ ...policy, applyTrackerSeedGoals: false, seedRatioGoal: 1.25 }, { seedRatioGoal: 2, seedTimeMinutes: 60 }),
|
||||
).toEqual({ seedRatioGoal: 1.25, ratioOrigin: 'manual', timeOrigin: 'client_default' });
|
||||
});
|
||||
|
||||
it('omits invalid tracker dimensions independently', () => {
|
||||
expect(resolveEffectiveSeedGoals(policy, { seedRatioGoal: 0, seedTimeMinutes: 1.2 })).toEqual({
|
||||
seedTimeMinutes: 2,
|
||||
ratioOrigin: 'client_default',
|
||||
timeOrigin: 'tracker',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { ResolvedIndexerSeedPolicy } from '../indexers/indexer-adapter';
|
||||
import { normalizeProviderSeedRatio, normalizeProviderSeedTimeMinutes } from '../indexers/seed-goal.utils';
|
||||
|
||||
export type SeedGoalOrigin = 'manual' | 'tracker' | 'client_default';
|
||||
|
||||
export interface EffectiveSeedGoals {
|
||||
seedRatioGoal?: number;
|
||||
seedTimeMinutes?: number;
|
||||
ratioOrigin: SeedGoalOrigin;
|
||||
timeOrigin: SeedGoalOrigin;
|
||||
}
|
||||
|
||||
export function resolveEffectiveSeedGoals(
|
||||
policy: ResolvedIndexerSeedPolicy,
|
||||
tracker: { seedRatioGoal?: unknown; seedTimeMinutes?: unknown },
|
||||
): EffectiveSeedGoals {
|
||||
const manualRatio = normalizeProviderSeedRatio(policy.seedRatioGoal);
|
||||
const trackerRatio = policy.applyTrackerSeedGoals ? normalizeProviderSeedRatio(tracker.seedRatioGoal) : undefined;
|
||||
const ratio = manualRatio ?? trackerRatio;
|
||||
|
||||
const manualTime = normalizeProviderSeedTimeMinutes(policy.seedTimeMinutes);
|
||||
const trackerTime = policy.applyTrackerSeedGoals ? normalizeProviderSeedTimeMinutes(tracker.seedTimeMinutes) : undefined;
|
||||
const time = manualTime ?? trackerTime;
|
||||
|
||||
return {
|
||||
...(ratio !== undefined ? { seedRatioGoal: ratio } : {}),
|
||||
...(time !== undefined ? { seedTimeMinutes: time } : {}),
|
||||
ratioOrigin: manualRatio !== undefined ? 'manual' : trackerRatio !== undefined ? 'tracker' : 'client_default',
|
||||
timeOrigin: manualTime !== undefined ? 'manual' : trackerTime !== undefined ? 'tracker' : 'client_default',
|
||||
};
|
||||
}
|
||||
@@ -85,6 +85,14 @@ function makeService(
|
||||
const direct = { add: vi.fn().mockResolvedValue({ clientKey: INFO_HASH }) };
|
||||
const indexers = {
|
||||
resolveConfig: vi.fn().mockResolvedValue({ id: 9, name: 'tracker', adapterType: 'torznab' }),
|
||||
resolveSeedPolicy: vi.fn().mockResolvedValue({
|
||||
id: 9,
|
||||
adapterType: 'torznab',
|
||||
seedsBack: true,
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: null,
|
||||
}),
|
||||
...overrides.indexers,
|
||||
};
|
||||
const indexerAdapter = { fetchTorrentFile: vi.fn(), ...overrides.indexerAdapter };
|
||||
@@ -651,6 +659,178 @@ describe('RequestFulfillmentService.grab from a picked release', () => {
|
||||
expect(adapter.add).toHaveBeenCalledWith(expect.objectContaining({ seedRatioGoal: 2, seedTimeMinutes: 4320 }), expect.anything());
|
||||
});
|
||||
|
||||
it('applies manual dimensions independently over tracker fallback', async () => {
|
||||
const { service, adapter } = makeService({
|
||||
releases: { find: vi.fn().mockReturnValue(RELEASE) },
|
||||
indexers: {
|
||||
resolveSeedPolicy: vi.fn().mockResolvedValue({
|
||||
id: 9,
|
||||
adapterType: 'torznab',
|
||||
seedsBack: true,
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: 1.25,
|
||||
seedTimeMinutes: null,
|
||||
}),
|
||||
},
|
||||
indexerAdapter: { fetchTorrentFile: vi.fn().mockResolvedValue(torrentBytes()) },
|
||||
});
|
||||
|
||||
await service.grab(7, { indexerId: 9, releaseGuid: 'r-1' }, user());
|
||||
|
||||
expect(adapter.add).toHaveBeenCalledWith(expect.objectContaining({ seedRatioGoal: 1.25, seedTimeMinutes: 4320 }), expect.anything());
|
||||
});
|
||||
|
||||
it('disables tracker fallback without disabling manual values', async () => {
|
||||
const { service, adapter } = makeService({
|
||||
releases: { find: vi.fn().mockReturnValue(RELEASE) },
|
||||
indexers: {
|
||||
resolveSeedPolicy: vi.fn().mockResolvedValue({
|
||||
id: 9,
|
||||
adapterType: 'torznab',
|
||||
seedsBack: true,
|
||||
applyTrackerSeedGoals: false,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: 90,
|
||||
}),
|
||||
},
|
||||
indexerAdapter: { fetchTorrentFile: vi.fn().mockResolvedValue(torrentBytes()) },
|
||||
});
|
||||
|
||||
await service.grab(7, { indexerId: 9, releaseGuid: 'r-1' }, user());
|
||||
|
||||
const payload = adapter.add.mock.calls[0]?.[0];
|
||||
expect(payload).not.toHaveProperty('seedRatioGoal');
|
||||
expect(payload).toHaveProperty('seedTimeMinutes', 90);
|
||||
});
|
||||
|
||||
it('uses current policy after an inspection cache hit without fetching bytes again', async () => {
|
||||
const fetchTorrentFile = vi.fn().mockResolvedValue(torrentBytes());
|
||||
const resolveSeedPolicy = vi.fn().mockResolvedValue({
|
||||
id: 9,
|
||||
adapterType: 'torznab',
|
||||
seedsBack: true,
|
||||
applyTrackerSeedGoals: false,
|
||||
seedRatioGoal: 3,
|
||||
seedTimeMinutes: 45,
|
||||
});
|
||||
const { service, adapter } = makeService({
|
||||
releases: { find: vi.fn().mockReturnValue(RELEASE) },
|
||||
indexers: { resolveSeedPolicy },
|
||||
indexerAdapter: { fetchTorrentFile },
|
||||
});
|
||||
|
||||
await service.inspectRelease(7, { indexerId: 9, releaseGuid: 'r-1' });
|
||||
await service.grab(7, { indexerId: 9, releaseGuid: 'r-1' }, user());
|
||||
|
||||
expect(fetchTorrentFile).toHaveBeenCalledTimes(1);
|
||||
expect(resolveSeedPolicy).toHaveBeenCalledTimes(1);
|
||||
expect(adapter.add).toHaveBeenCalledWith(expect.objectContaining({ seedRatioGoal: 3, seedTimeMinutes: 45 }), expect.anything());
|
||||
});
|
||||
|
||||
it.each([
|
||||
['deleted', new NotFoundException('Indexer not found')],
|
||||
['unavailable', new BadRequestException('Unknown indexer type: demo-tracker')],
|
||||
])('refuses a cached release when its source is %s', async (_state, sourceError) => {
|
||||
const fetchTorrentFile = vi.fn().mockResolvedValue(torrentBytes());
|
||||
const { service, adapter } = makeService({
|
||||
releases: { find: vi.fn().mockReturnValue(RELEASE) },
|
||||
indexers: { resolveSeedPolicy: vi.fn().mockRejectedValue(sourceError) },
|
||||
indexerAdapter: { fetchTorrentFile },
|
||||
});
|
||||
|
||||
await service.inspectRelease(7, { indexerId: 9, releaseGuid: 'r-1' });
|
||||
await expect(service.grab(7, { indexerId: 9, releaseGuid: 'r-1' }, user())).rejects.toMatchObject({
|
||||
status: 400,
|
||||
response: { errorCode: 'GRAB_RELEASE_REFUSED' },
|
||||
});
|
||||
|
||||
expect(fetchTorrentFile).toHaveBeenCalledTimes(1);
|
||||
expect(adapter.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a cached release when its configured adapter type changed', async () => {
|
||||
const fetchTorrentFile = vi.fn().mockResolvedValue(torrentBytes());
|
||||
const { service, adapter } = makeService({
|
||||
releases: { find: vi.fn().mockReturnValue(RELEASE) },
|
||||
indexers: {
|
||||
resolveSeedPolicy: vi.fn().mockResolvedValue({
|
||||
id: 9,
|
||||
adapterType: 'other-torrent',
|
||||
seedsBack: true,
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: null,
|
||||
}),
|
||||
},
|
||||
indexerAdapter: { fetchTorrentFile },
|
||||
});
|
||||
|
||||
await service.inspectRelease(7, { indexerId: 9, releaseGuid: 'r-1' });
|
||||
await expect(service.grab(7, { indexerId: 9, releaseGuid: 'r-1' }, user())).rejects.toMatchObject({
|
||||
status: 400,
|
||||
response: { errorCode: 'GRAB_RELEASE_REFUSED' },
|
||||
});
|
||||
|
||||
expect(fetchTorrentFile).toHaveBeenCalledTimes(1);
|
||||
expect(adapter.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates a transient policy read failure without misreporting the source as unavailable', async () => {
|
||||
const policyError = new Error('database unavailable');
|
||||
const { service, adapter } = makeService({
|
||||
releases: { find: vi.fn().mockReturnValue(RELEASE) },
|
||||
indexers: { resolveSeedPolicy: vi.fn().mockRejectedValue(policyError) },
|
||||
indexerAdapter: { fetchTorrentFile: vi.fn().mockResolvedValue(torrentBytes()) },
|
||||
});
|
||||
|
||||
await expect(service.grab(7, { indexerId: 9, releaseGuid: 'r-1' }, user())).rejects.toBe(policyError);
|
||||
expect(adapter.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps one policy snapshot across a transient client retry', async () => {
|
||||
const resolveSeedPolicy = vi.fn().mockResolvedValue({
|
||||
id: 9,
|
||||
adapterType: 'torznab',
|
||||
seedsBack: true,
|
||||
applyTrackerSeedGoals: false,
|
||||
seedRatioGoal: 3,
|
||||
seedTimeMinutes: null,
|
||||
});
|
||||
const { service, adapter } = makeService({
|
||||
releases: { find: vi.fn().mockReturnValue(RELEASE) },
|
||||
indexers: { resolveSeedPolicy },
|
||||
indexerAdapter: { fetchTorrentFile: vi.fn().mockResolvedValue(torrentBytes()) },
|
||||
});
|
||||
adapter.add.mockRejectedValueOnce(new ServiceUnavailableException('restarting')).mockResolvedValueOnce({ clientKey: INFO_HASH });
|
||||
|
||||
await service.grab(7, { indexerId: 9, releaseGuid: 'r-1' }, null);
|
||||
|
||||
expect(resolveSeedPolicy).toHaveBeenCalledTimes(1);
|
||||
expect(adapter.add).toHaveBeenCalledTimes(2);
|
||||
expect(adapter.add.mock.calls[0]?.[0]).toHaveProperty('seedRatioGoal', 3);
|
||||
expect(adapter.add.mock.calls[1]?.[0]).toHaveProperty('seedRatioGoal', 3);
|
||||
});
|
||||
|
||||
it('uses manual time for a configured-indexer magnet', async () => {
|
||||
const { service, adapter } = makeService({
|
||||
releases: { find: vi.fn().mockReturnValue({ ...RELEASE, magnet: MAGNET }) },
|
||||
indexers: {
|
||||
resolveSeedPolicy: vi.fn().mockResolvedValue({
|
||||
id: 9,
|
||||
adapterType: 'torznab',
|
||||
seedsBack: true,
|
||||
applyTrackerSeedGoals: false,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: 30,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await service.grab(7, { indexerId: 9, releaseGuid: 'r-1' }, user());
|
||||
|
||||
expect(adapter.add).toHaveBeenCalledWith(expect.objectContaining({ seedTimeMinutes: 30 }), expect.anything());
|
||||
});
|
||||
|
||||
/** Torznab states no format, so what the approver saw came off the release name. Record that. */
|
||||
it('snapshots the format the picker showed rather than the empty field the indexer sent', async () => {
|
||||
const { service, downloads } = makeService({
|
||||
|
||||
@@ -76,6 +76,7 @@ import { newznabClientKey } from '../indexers/adapters/newznab.adapter';
|
||||
import type { InspectBookRequestReleaseDto } from '../dto/inspect-book-request-release.dto';
|
||||
import type { GrabBookRequestDto } from '../dto/grab-book-request.dto';
|
||||
import type { SearchBookRequestReleasesDto } from '../dto/search-book-request-releases.dto';
|
||||
import { resolveEffectiveSeedGoals } from './indexer-seed-policy';
|
||||
|
||||
/** One metadata lookup against the source, spent only on the release an approver actually picked. */
|
||||
const RESOLVE_FILE_TIMEOUT_MS = 20_000;
|
||||
@@ -551,10 +552,10 @@ export class RequestFulfillmentService {
|
||||
|
||||
if (!named[0]) return parseGrabPayload(dto);
|
||||
|
||||
return this.resolvePickedRelease(requestId, dto.indexerId!, dto.releaseGuid!.trim());
|
||||
return this.resolvePickedRelease(requestId, dto.indexerId!, dto.releaseGuid!.trim(), true);
|
||||
}
|
||||
|
||||
private async resolvePickedRelease(requestId: number, indexerId: number, releaseGuid: string): Promise<ResolvedGrab> {
|
||||
private async resolvePickedRelease(requestId: number, indexerId: number, releaseGuid: string, applyCurrentPolicy = false): Promise<ResolvedGrab> {
|
||||
const release = this.releases.find(requestId, indexerId, releaseGuid);
|
||||
if (!release) {
|
||||
throw grabError('GRAB_RELEASE_REFUSED', 'That release is no longer in the search results. Search again and pick one.');
|
||||
@@ -562,11 +563,38 @@ export class RequestFulfillmentService {
|
||||
|
||||
const key = `${requestId}:${indexerId}:${releaseGuid}`;
|
||||
const cached = this.resolvedReleases.get(key);
|
||||
if (cached && cached.candidate === release && cached.expiresAt > Date.now()) return cached.grab;
|
||||
let grab: ResolvedGrab;
|
||||
if (cached && cached.candidate === release && cached.expiresAt > Date.now()) grab = cached.grab;
|
||||
else {
|
||||
grab = await this.resolveRelease(requestId, release);
|
||||
this.rememberResolvedRelease(key, release, grab);
|
||||
}
|
||||
return applyCurrentPolicy ? this.applyCurrentSeedPolicy(grab) : grab;
|
||||
}
|
||||
|
||||
const grab = await this.resolveRelease(requestId, release);
|
||||
this.rememberResolvedRelease(key, release, grab);
|
||||
return grab;
|
||||
private async applyCurrentSeedPolicy(grab: ResolvedGrab): Promise<ResolvedGrab> {
|
||||
if (grab.indexerId === undefined || (grab.source !== 'magnet' && grab.source !== 'torrent_file')) return withoutSeedGoals(grab);
|
||||
|
||||
let policy;
|
||||
try {
|
||||
policy = await this.indexers.resolveSeedPolicy(grab.indexerId);
|
||||
} catch (error) {
|
||||
if (error instanceof NotFoundException || error instanceof BadRequestException) {
|
||||
throw grabError('GRAB_RELEASE_REFUSED', 'That release source is no longer available. Search again and pick a release.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (policy.adapterType !== grab.indexerAdapterType) {
|
||||
throw grabError('GRAB_RELEASE_REFUSED', 'That release source has changed. Search again and pick a release.');
|
||||
}
|
||||
if (!policy.seedsBack) return withoutSeedGoals(grab);
|
||||
|
||||
const goals = resolveEffectiveSeedGoals(policy, grab);
|
||||
return {
|
||||
...withoutSeedGoals(grab),
|
||||
...(goals.seedRatioGoal !== undefined ? { seedRatioGoal: goals.seedRatioGoal } : {}),
|
||||
...(goals.seedTimeMinutes !== undefined ? { seedTimeMinutes: goals.seedTimeMinutes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveRelease(requestId: number, release: ReleaseCandidate): Promise<ResolvedGrab> {
|
||||
@@ -589,6 +617,7 @@ export class RequestFulfillmentService {
|
||||
const snapshot = {
|
||||
indexerId: release.indexerId,
|
||||
indexerName: indexer.name,
|
||||
indexerAdapterType: indexer.adapterType,
|
||||
releaseGuid: release.guid,
|
||||
releaseTitle: release.title.slice(0, 500),
|
||||
releaseSizeBytes: release.sizeBytes,
|
||||
@@ -597,9 +626,8 @@ export class RequestFulfillmentService {
|
||||
// release name, which is what the picker showed the approver.
|
||||
releaseFormat: resolveFormat(release)?.slice(0, 20) ?? null,
|
||||
freeleech: release.freeleech ?? false,
|
||||
// Both come from the tracker itself, via torznab's `minimumratio` and `minimumseedtime`.
|
||||
// There is no per-source override for either: the download client's own defaults are the
|
||||
// fallback where a feed states nothing.
|
||||
// Raw tracker metadata is cached with the transport. Current host policy is applied later,
|
||||
// once per grab attempt, so a settings edit does not require another credentialed fetch.
|
||||
seedRatioGoal: release.seedRatioGoal ?? null,
|
||||
seedTimeMinutes: release.seedTimeMinutes ?? null,
|
||||
};
|
||||
@@ -898,6 +926,7 @@ interface ParsedGrab {
|
||||
interface ResolvedGrab extends ParsedGrab {
|
||||
indexerId?: number;
|
||||
indexerName?: string;
|
||||
indexerAdapterType?: string;
|
||||
releaseGuid?: string;
|
||||
releaseSeeders?: number | null;
|
||||
releaseFormat?: string | null;
|
||||
@@ -978,6 +1007,13 @@ function torrentInspection(metadata: TorrentFileMetadata): ReleaseFileInspection
|
||||
};
|
||||
}
|
||||
|
||||
function withoutSeedGoals(grab: ResolvedGrab): ResolvedGrab {
|
||||
const copy = { ...grab };
|
||||
delete copy.seedRatioGoal;
|
||||
delete copy.seedTimeMinutes;
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* A .torrent prefixes every entry with the torrent's own name, which is a delivery detail rather
|
||||
* than part of the layout. Stripping it here is what makes the manifest and the finished directory
|
||||
|
||||
@@ -25,6 +25,9 @@ function config(overrides: Partial<ResolvedIndexerConfig> = {}): ResolvedIndexer
|
||||
credential: 'secret-key',
|
||||
credentialError: null,
|
||||
allowPrivateAddress: false,
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: null,
|
||||
categories: DEFAULT_INDEXER_CATEGORIES.newznab,
|
||||
disabledMediaKinds: [],
|
||||
isbnSearchDisabled: false,
|
||||
|
||||
@@ -61,9 +61,16 @@ function config(overrides: Partial<ResolvedIndexerConfig> = {}): ResolvedIndexer
|
||||
// A Jackett on the LAN, which is the deployment the private-address opt-in exists for.
|
||||
baseUrl: 'http://127.0.0.1:9117/api/v2.0/indexers/all/results/torznab',
|
||||
credential: 'secret-key',
|
||||
credentialError: null,
|
||||
allowPrivateAddress: true,
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: null,
|
||||
categories: DEFAULT_INDEXER_CATEGORIES.torznab,
|
||||
disabledMediaKinds: [],
|
||||
isbnSearchDisabled: false,
|
||||
settings: null,
|
||||
networkProfile: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -290,6 +297,33 @@ describe('TorznabAdapter', () => {
|
||||
expect(release.seedRatioGoal).toBeUndefined();
|
||||
expect(release.seedTimeMinutes).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['1', 1],
|
||||
['60', 1],
|
||||
['61', 2],
|
||||
])('rounds a positive minimum seed time of %s seconds upward to %s minutes', async (seconds, minutes) => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(feed(`<item><title>Dune</title><guid>g</guid><torznab:attr name="minimumseedtime" value="${seconds}" /></item>`)),
|
||||
);
|
||||
|
||||
const [release] = await new TorznabAdapter().search(query(), config(), AbortSignal.timeout(1000));
|
||||
expect(release.seedTimeMinutes).toBe(minutes);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['minimumratio', '0'],
|
||||
['minimumratio', '-1'],
|
||||
['minimumratio', 'Infinity'],
|
||||
['minimumseedtime', '0'],
|
||||
['minimumseedtime', '-1'],
|
||||
['minimumseedtime', String(2_147_483_647 * 60 + 1)],
|
||||
])('omits invalid tracker goal %s=%s', async (name, value) => {
|
||||
fetchMock.mockResolvedValue(new Response(feed(`<item><title>Dune</title><guid>g</guid><torznab:attr name="${name}" value="${value}" /></item>`)));
|
||||
|
||||
const [release] = await new TorznabAdapter().search(query(), config(), AbortSignal.timeout(1000));
|
||||
expect(name === 'minimumratio' ? release.seedRatioGoal : release.seedTimeMinutes).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type ResolvedIndexerConfig,
|
||||
type TorrentFetchResult,
|
||||
} from '../indexer-adapter';
|
||||
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';
|
||||
import { infoHashFromMagnet, MAX_TORRENT_FILE_BYTES } from '../../fulfillment/torrent.utils';
|
||||
@@ -276,9 +277,8 @@ function parseItems(xml: string, indexerId: number): ReleaseCandidate[] {
|
||||
const publishedAt = parseDate(item.pubDate);
|
||||
// What the tracker asks of a grab, which a proxy passes through from the tracker's own rules.
|
||||
// Torznab states the seed time in seconds; our column and the download clients want minutes.
|
||||
const seedRatioGoal = toNumber(attrs.get('minimumratio'));
|
||||
const seedTimeSeconds = toNumber(attrs.get('minimumseedtime'));
|
||||
const seedTimeMinutes = seedTimeSeconds === null ? null : Math.round(seedTimeSeconds / 60);
|
||||
const seedRatioGoal = normalizeProviderSeedRatio(toNumber(attrs.get('minimumratio')));
|
||||
const seedTimeMinutes = normalizeTorznabSeedTimeSeconds(toNumber(attrs.get('minimumseedtime')));
|
||||
const bookTitle = attrs.get('booktitle')?.trim();
|
||||
|
||||
releases.push({
|
||||
@@ -298,8 +298,8 @@ function parseItems(xml: string, indexerId: number): ReleaseCandidate[] {
|
||||
freeleech: toNumber(attrs.get('downloadvolumefactor')) === 0,
|
||||
...(publishedAt ? { publishedAt } : {}),
|
||||
...(fileCount !== null ? { fileCount } : {}),
|
||||
...(seedRatioGoal !== null ? { seedRatioGoal } : {}),
|
||||
...(seedTimeMinutes !== null ? { seedTimeMinutes } : {}),
|
||||
...(seedRatioGoal !== undefined ? { seedRatioGoal } : {}),
|
||||
...(seedTimeMinutes !== undefined ? { seedTimeMinutes } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||
|
||||
import { CreateIndexerDto, UpdateIndexerDto } from './indexer.dto';
|
||||
|
||||
const pipe = new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true });
|
||||
const create = { name: 'Source', adapterType: 'torznab', baseUrl: 'https://indexer.example' };
|
||||
|
||||
async function validate<T extends CreateIndexerDto | UpdateIndexerDto>(type: new () => T, value: unknown): Promise<T> {
|
||||
return pipe.transform(value, { type: 'body', metatype: type }) as Promise<T>;
|
||||
}
|
||||
|
||||
describe.each([CreateIndexerDto, UpdateIndexerDto])('%s seed policy validation', (Dto) => {
|
||||
const base = Dto === CreateIndexerDto ? create : {};
|
||||
|
||||
it('accepts null clears and the numeric boundaries', async () => {
|
||||
await expect(
|
||||
validate(Dto, {
|
||||
...base,
|
||||
applyTrackerSeedGoals: false,
|
||||
seedRatioGoal: 0.0001,
|
||||
seedTimeMinutes: 2_147_483_647,
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
await expect(validate(Dto, { ...base, seedRatioGoal: null, seedTimeMinutes: null })).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ seedRatioGoal: 0 },
|
||||
{ seedRatioGoal: -1 },
|
||||
{ seedRatioGoal: '1.5' },
|
||||
{ seedRatioGoal: Number.NaN },
|
||||
{ seedRatioGoal: Number.POSITIVE_INFINITY },
|
||||
{ seedTimeMinutes: 1.5 },
|
||||
{ seedTimeMinutes: 0 },
|
||||
{ seedTimeMinutes: -1 },
|
||||
{ seedTimeMinutes: '60' },
|
||||
{ seedTimeMinutes: 2_147_483_648 },
|
||||
{ applyTrackerSeedGoals: null },
|
||||
{ applyTrackerSeedGoals: 'false' },
|
||||
{ applyTrackerSeedGoals: 0 },
|
||||
])('rejects invalid input %j', async (invalid) => {
|
||||
await expect(validate(Dto, { ...base, ...invalid })).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -6,21 +6,42 @@ import {
|
||||
IsIP,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsPositive,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
Max,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { BOOK_REQUEST_MEDIA_KINDS, INDEXER_COLORS } from '@bookorbit/types';
|
||||
import { BOOK_REQUEST_MEDIA_KINDS, INDEXER_COLORS, MAX_INDEXER_SEED_TIME_MINUTES } from '@bookorbit/types';
|
||||
import type { BookRequestMediaKind, IndexerAdapterTypeName, IndexerColor } from '@bookorbit/types';
|
||||
|
||||
/** The same slug shape the `adapter_type` CHECK enforces. */
|
||||
const ADAPTER_TYPE_SLUG = /^[a-z0-9][a-z0-9-]{0,29}$/;
|
||||
|
||||
abstract class IndexerSeedPolicyDto {
|
||||
@ValidateIf((_object, value) => value !== undefined)
|
||||
@IsBoolean()
|
||||
applyTrackerSeedGoals?: boolean;
|
||||
|
||||
@ValidateIf((_object, value) => value !== undefined && value !== null)
|
||||
@IsNumber({ allowNaN: false, allowInfinity: false })
|
||||
@IsPositive()
|
||||
seedRatioGoal?: number | null;
|
||||
|
||||
@ValidateIf((_object, value) => value !== undefined && value !== null)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_INDEXER_SEED_TIME_MINUTES)
|
||||
seedTimeMinutes?: number | null;
|
||||
}
|
||||
|
||||
/** Category ids are the indexer's own numbering, so the only thing to validate is the shape. */
|
||||
class IndexerCategoriesDto {
|
||||
@IsOptional()
|
||||
@@ -64,7 +85,7 @@ class NetworkProfileDto {
|
||||
proxyUrl?: string;
|
||||
}
|
||||
|
||||
export class CreateIndexerDto {
|
||||
export class CreateIndexerDto extends IndexerSeedPolicyDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
@@ -144,7 +165,7 @@ export class CreateIndexerDto {
|
||||
* means the accepted field list has to be exact, and an inherited-then-loosened field is easy to
|
||||
* get subtly wrong.
|
||||
*/
|
||||
export class UpdateIndexerDto {
|
||||
export class UpdateIndexerDto extends IndexerSeedPolicyDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
|
||||
@@ -29,6 +29,9 @@ export interface ResolvedIndexerConfig {
|
||||
*/
|
||||
credentialError: string | null;
|
||||
allowPrivateAddress: boolean;
|
||||
applyTrackerSeedGoals: boolean;
|
||||
seedRatioGoal: number | null;
|
||||
seedTimeMinutes: number | null;
|
||||
categories: IndexerCategoryMap;
|
||||
/** Media the operator took this source out of, on top of what its adapter declares it carries. */
|
||||
disabledMediaKinds: readonly BookRequestMediaKind[];
|
||||
@@ -43,6 +46,16 @@ export interface ResolvedIndexerConfig {
|
||||
networkProfile: NetworkProfile | null;
|
||||
}
|
||||
|
||||
/** Non-secret host policy read immediately before a configured-indexer torrent is added. */
|
||||
export interface ResolvedIndexerSeedPolicy {
|
||||
id: number;
|
||||
adapterType: IndexerAdapterType;
|
||||
seedsBack: boolean;
|
||||
applyTrackerSeedGoals: boolean;
|
||||
seedRatioGoal: number | null;
|
||||
seedTimeMinutes: number | null;
|
||||
}
|
||||
|
||||
export interface ReleaseQuery {
|
||||
title: string;
|
||||
author: string | null;
|
||||
|
||||
@@ -14,11 +14,21 @@ function indexerRow(overrides: Partial<RequestIndexerRow> = {}): RequestIndexerR
|
||||
baseUrl: 'http://203.0.113.10:9117',
|
||||
credentialsEnc: 'cipher',
|
||||
allowPrivateAddress: false,
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: null,
|
||||
categories: null,
|
||||
disabledMediaKinds: [],
|
||||
isbnSearchDisabled: false,
|
||||
settings: null,
|
||||
networkProfile: null,
|
||||
lastTestedAt: null,
|
||||
lastTestOk: null,
|
||||
lastErrorMessage: null,
|
||||
lastSearchAt: null,
|
||||
lastSearchOk: null,
|
||||
lastSearchError: null,
|
||||
searchFailureStreak: 0,
|
||||
createdAt: new Date('2026-08-18T00:00:00Z'),
|
||||
updatedAt: new Date('2026-08-18T00:00:00Z'),
|
||||
...overrides,
|
||||
@@ -37,6 +47,13 @@ function makeService(
|
||||
findAll: vi.fn().mockResolvedValue([indexerRow()]),
|
||||
findAssignedColors: vi.fn().mockResolvedValue([null]),
|
||||
findById: vi.fn().mockResolvedValue(indexerRow()),
|
||||
findSeedPolicyById: vi.fn().mockResolvedValue({
|
||||
id: 9,
|
||||
adapterType: 'torznab',
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: null,
|
||||
}),
|
||||
findAllEnabled: vi.fn().mockResolvedValue([indexerRow()]),
|
||||
create: vi.fn().mockResolvedValue(indexerRow()),
|
||||
update: vi.fn().mockResolvedValue(indexerRow()),
|
||||
@@ -61,6 +78,7 @@ function makeService(
|
||||
require: vi.fn().mockReturnValue(adapter),
|
||||
find: vi.fn().mockReturnValue(adapter),
|
||||
defaultCategories: vi.fn().mockReturnValue(DEFAULT_INDEXER_CATEGORIES.torznab),
|
||||
seedsBack: vi.fn().mockReturnValue(true),
|
||||
...overrides.registry,
|
||||
};
|
||||
return {
|
||||
@@ -93,6 +111,97 @@ describe('IndexerConfigService', () => {
|
||||
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ credentialsEnc: 'cipher' }));
|
||||
});
|
||||
|
||||
it('creates explicit seed policy and defaults unset policy safely', async () => {
|
||||
const { service, repo } = makeService();
|
||||
|
||||
await service.create({
|
||||
...createDto,
|
||||
applyTrackerSeedGoals: false,
|
||||
seedRatioGoal: 1.25,
|
||||
seedTimeMinutes: 60,
|
||||
});
|
||||
expect(repo.create).toHaveBeenLastCalledWith(expect.objectContaining({ applyTrackerSeedGoals: false, seedRatioGoal: 1.25, seedTimeMinutes: 60 }));
|
||||
|
||||
await service.create(createDto);
|
||||
expect(repo.create).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ applyTrackerSeedGoals: true, seedRatioGoal: null, seedTimeMinutes: null }),
|
||||
);
|
||||
});
|
||||
|
||||
it('patches and clears each seed dimension independently', async () => {
|
||||
const { service, repo } = makeService();
|
||||
|
||||
await service.update(9, { seedRatioGoal: 2 });
|
||||
expect(repo.update).toHaveBeenLastCalledWith(9, { seedRatioGoal: 2 });
|
||||
|
||||
await service.update(9, { seedRatioGoal: null, seedTimeMinutes: null });
|
||||
expect(repo.update).toHaveBeenLastCalledWith(9, { seedRatioGoal: null, seedTimeMinutes: null });
|
||||
});
|
||||
|
||||
it.each([{ seedTimeMinutes: 60 }, { applyTrackerSeedGoals: false }])(
|
||||
'rejects non-default seed policy for a non-seeding adapter before writing',
|
||||
async (policy) => {
|
||||
const { service, repo } = makeService({ registry: { seedsBack: vi.fn().mockReturnValue(false) } });
|
||||
|
||||
await expect(service.update(9, policy)).rejects.toMatchObject({
|
||||
response: expect.objectContaining({ errorCode: 'INDEXER_SETTINGS_INVALID' }),
|
||||
});
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('clears inert policy on a successful save to a loaded non-seeding adapter', async () => {
|
||||
const { service, repo } = makeService({ registry: { seedsBack: vi.fn().mockReturnValue(false) } });
|
||||
|
||||
await service.update(9, { enabled: false });
|
||||
|
||||
expect(repo.update).toHaveBeenCalledWith(9, {
|
||||
enabled: false,
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses saves without clearing policy while a plugin adapter is unavailable', async () => {
|
||||
const { service, repo } = makeService({
|
||||
registry: {
|
||||
require: vi.fn().mockImplementation(() => {
|
||||
throw new BadRequestException('Unknown indexer type: missing-plugin');
|
||||
}),
|
||||
seedsBack: vi.fn().mockReturnValue(false),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.update(9, { enabled: false })).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves host seed policy across a seeding-to-seeding adapter change when omitted', async () => {
|
||||
const { service, repo } = makeService();
|
||||
|
||||
await service.update(9, { adapterType: 'other-torrent' });
|
||||
|
||||
const patch = repo.update.mock.calls.at(-1)?.[1];
|
||||
expect(patch).not.toHaveProperty('seedRatioGoal');
|
||||
expect(patch).not.toHaveProperty('seedTimeMinutes');
|
||||
});
|
||||
|
||||
it('reads current seed policy without decrypting credentials', async () => {
|
||||
const { service, repo, credentials } = makeService();
|
||||
|
||||
await expect(service.resolveSeedPolicy(9)).resolves.toEqual({
|
||||
id: 9,
|
||||
adapterType: 'torznab',
|
||||
seedsBack: true,
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: null,
|
||||
});
|
||||
expect(repo.findSeedPolicyById).toHaveBeenCalledWith(9);
|
||||
expect(credentials.decrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('assigns a random unused color when a new indexer does not name one', async () => {
|
||||
const existing = INDEXER_COLORS.filter((color) => color !== 'teal');
|
||||
const { service, repo } = makeService({ repo: { findAssignedColors: vi.fn().mockResolvedValue(existing) } });
|
||||
|
||||
@@ -18,7 +18,7 @@ import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
|
||||
import { PrivateAddressException, ensureSafeUrl } from '../../../common/utils/ssrf.utils';
|
||||
import type { RequestIndexerRow } from '../../../db/schema';
|
||||
import { RequestCredentialService } from '../request-credential.service';
|
||||
import type { ResolvedIndexerConfig } from './indexer-adapter';
|
||||
import type { ResolvedIndexerConfig, ResolvedIndexerSeedPolicy } from './indexer-adapter';
|
||||
import { IndexerRegistry } from './indexer-registry';
|
||||
import { IndexerRepository } from './indexer.repository';
|
||||
import type { CreateIndexerDto, UpdateIndexerDto } from './dto/indexer.dto';
|
||||
@@ -65,6 +65,8 @@ export class IndexerConfigService {
|
||||
// not leave a half-made indexer behind whose name the operator then cannot save over.
|
||||
await this.assertReachableUrl(dto.baseUrl, dto.allowPrivateAddress ?? false);
|
||||
this.assertCredentialPresent(dto.adapterType, Boolean(dto.credential?.trim()));
|
||||
const seedsBack = this.registry.seedsBack(dto.adapterType);
|
||||
this.assertSeedPolicyCompatible(seedsBack, dto);
|
||||
const credentialsEnc = dto.credential ? this.credentials.encrypt(dto.credential) : null;
|
||||
const color = dto.color === undefined ? pickUnusedIndexerColor(await this.repo.findAssignedColors()) : dto.color;
|
||||
|
||||
@@ -77,6 +79,9 @@ export class IndexerConfigService {
|
||||
credentialsEnc,
|
||||
enabled: dto.enabled ?? true,
|
||||
allowPrivateAddress: dto.allowPrivateAddress ?? false,
|
||||
applyTrackerSeedGoals: seedsBack ? (dto.applyTrackerSeedGoals ?? true) : true,
|
||||
seedRatioGoal: seedsBack ? (dto.seedRatioGoal ?? null) : null,
|
||||
seedTimeMinutes: seedsBack ? (dto.seedTimeMinutes ?? null) : null,
|
||||
categories: mergeCategories(this.registry.defaultCategories(dto.adapterType), dto.categories),
|
||||
disabledMediaKinds: normalizeDisabledMediaKinds(dto.disabledMediaKinds),
|
||||
isbnSearchDisabled: dto.isbnSearchDisabled ?? false,
|
||||
@@ -107,6 +112,8 @@ export class IndexerConfigService {
|
||||
const changesAdapter = adapterType !== existing.adapterType;
|
||||
const keepsCredential = dto.credential === undefined ? !changesAdapter && existing.credentialsEnc !== null : Boolean(dto.credential.trim());
|
||||
this.assertCredentialPresent(adapterType, keepsCredential);
|
||||
const seedsBack = this.registry.seedsBack(adapterType);
|
||||
this.assertSeedPolicyCompatible(seedsBack, dto);
|
||||
|
||||
const patch: Partial<RequestIndexerRow> = {};
|
||||
if (dto.name !== undefined) patch.name = dto.name.trim();
|
||||
@@ -115,6 +122,15 @@ export class IndexerConfigService {
|
||||
if (dto.baseUrl !== undefined) patch.baseUrl = baseUrl;
|
||||
if (dto.enabled !== undefined) patch.enabled = dto.enabled;
|
||||
if (dto.allowPrivateAddress !== undefined) patch.allowPrivateAddress = dto.allowPrivateAddress;
|
||||
if (seedsBack) {
|
||||
if (dto.applyTrackerSeedGoals !== undefined) patch.applyTrackerSeedGoals = dto.applyTrackerSeedGoals;
|
||||
if (dto.seedRatioGoal !== undefined) patch.seedRatioGoal = dto.seedRatioGoal;
|
||||
if (dto.seedTimeMinutes !== undefined) patch.seedTimeMinutes = dto.seedTimeMinutes;
|
||||
} else {
|
||||
patch.applyTrackerSeedGoals = true;
|
||||
patch.seedRatioGoal = null;
|
||||
patch.seedTimeMinutes = null;
|
||||
}
|
||||
if (dto.categories !== undefined) patch.categories = mergeCategories(this.registry.defaultCategories(adapterType), dto.categories);
|
||||
if (dto.disabledMediaKinds !== undefined) patch.disabledMediaKinds = normalizeDisabledMediaKinds(dto.disabledMediaKinds);
|
||||
if (dto.isbnSearchDisabled !== undefined) patch.isbnSearchDisabled = dto.isbnSearchDisabled;
|
||||
@@ -219,6 +235,17 @@ export class IndexerConfigService {
|
||||
return this.toConfig(await this.requireIndexer(id));
|
||||
}
|
||||
|
||||
async resolveSeedPolicy(id: number): Promise<ResolvedIndexerSeedPolicy> {
|
||||
const row = await this.repo.findSeedPolicyById(id);
|
||||
if (!row) throw new NotFoundException('Indexer not found');
|
||||
this.registry.require(row.adapterType);
|
||||
return {
|
||||
...row,
|
||||
adapterType: row.adapterType as IndexerAdapterType,
|
||||
seedsBack: this.registry.seedsBack(row.adapterType),
|
||||
};
|
||||
}
|
||||
|
||||
private toConfig(row: RequestIndexerRow): ResolvedIndexerConfig {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -229,6 +256,9 @@ export class IndexerConfigService {
|
||||
credential: row.credentialsEnc ? this.credentials.decrypt(row.credentialsEnc) : null,
|
||||
credentialError: null,
|
||||
allowPrivateAddress: row.allowPrivateAddress,
|
||||
applyTrackerSeedGoals: row.applyTrackerSeedGoals,
|
||||
seedRatioGoal: row.seedRatioGoal,
|
||||
seedTimeMinutes: row.seedTimeMinutes,
|
||||
categories: this.resolveCategories(row),
|
||||
disabledMediaKinds: normalizeDisabledMediaKinds(row.disabledMediaKinds),
|
||||
isbnSearchDisabled: row.isbnSearchDisabled,
|
||||
@@ -315,6 +345,13 @@ export class IndexerConfigService {
|
||||
}
|
||||
}
|
||||
|
||||
private assertSeedPolicyCompatible(seedsBack: boolean, dto: CreateIndexerDto | UpdateIndexerDto): void {
|
||||
if (seedsBack) return;
|
||||
if (dto.applyTrackerSeedGoals === false || dto.seedRatioGoal != null || dto.seedTimeMinutes != null) {
|
||||
throw indexerError('INDEXER_SETTINGS_INVALID', 'Seed goals can only be configured for an indexer that returns torrents');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlike a download client this defaults to off: a public tracker has no business resolving to
|
||||
* a private address, and a self-hosted torznab proxy is the one case worth opting into.
|
||||
@@ -378,6 +415,9 @@ function toItem(row: RequestIndexerRow, categories: IndexerCategoryMap): Indexer
|
||||
baseUrl: row.baseUrl,
|
||||
hasCredential: row.credentialsEnc !== null,
|
||||
allowPrivateAddress: row.allowPrivateAddress,
|
||||
applyTrackerSeedGoals: row.applyTrackerSeedGoals,
|
||||
seedRatioGoal: row.seedRatioGoal,
|
||||
seedTimeMinutes: row.seedTimeMinutes,
|
||||
categories,
|
||||
disabledMediaKinds: normalizeDisabledMediaKinds(row.disabledMediaKinds),
|
||||
isbnSearchDisabled: row.isbnSearchDisabled,
|
||||
|
||||
@@ -11,6 +11,9 @@ function config(overrides: Partial<ResolvedIndexerConfig> = {}): ResolvedIndexer
|
||||
baseUrl: 'https://www.myanonamouse.net',
|
||||
credential: 'a-session',
|
||||
allowPrivateAddress: false,
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: null,
|
||||
categories: { ebook: [], audiobook: [], comic: [] },
|
||||
disabledMediaKinds: [],
|
||||
isbnSearchDisabled: false,
|
||||
|
||||
@@ -35,10 +35,14 @@ function indexer(overrides: Partial<ResolvedIndexerConfig> = {}): ResolvedIndexe
|
||||
baseUrl: 'http://127.0.0.1:9117',
|
||||
credential: null,
|
||||
allowPrivateAddress: true,
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: null,
|
||||
categories: { ebook: [7020], audiobook: [3030], comic: [7030] },
|
||||
disabledMediaKinds: [],
|
||||
isbnSearchDisabled: false,
|
||||
settings: null,
|
||||
networkProfile: null,
|
||||
credentialError: null,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -10,6 +10,14 @@ import { requestIndexers, type NewRequestIndexerRow, type RequestIndexerRow } fr
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
export interface IndexerSeedPolicyRow {
|
||||
id: number;
|
||||
adapterType: string;
|
||||
applyTrackerSeedGoals: boolean;
|
||||
seedRatioGoal: number | null;
|
||||
seedTimeMinutes: number | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class IndexerRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
@@ -28,6 +36,21 @@ export class IndexerRepository {
|
||||
return row;
|
||||
}
|
||||
|
||||
async findSeedPolicyById(id: number): Promise<IndexerSeedPolicyRow | undefined> {
|
||||
const [row] = await this.db
|
||||
.select({
|
||||
id: requestIndexers.id,
|
||||
adapterType: requestIndexers.adapterType,
|
||||
applyTrackerSeedGoals: requestIndexers.applyTrackerSeedGoals,
|
||||
seedRatioGoal: requestIndexers.seedRatioGoal,
|
||||
seedTimeMinutes: requestIndexers.seedTimeMinutes,
|
||||
})
|
||||
.from(requestIndexers)
|
||||
.where(eq(requestIndexers.id, id))
|
||||
.limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Both counts in one pass. Read on every visit to the requests page by anybody who may file a
|
||||
* request, so it never selects the rows themselves: a count is all that surface is allowed.
|
||||
|
||||
@@ -30,9 +30,16 @@ function config(overrides: Partial<ResolvedIndexerConfig> = {}): ResolvedIndexer
|
||||
adapterType: 'torznab',
|
||||
baseUrl: 'https://tracker.example.com',
|
||||
credential: 'a-key',
|
||||
credentialError: null,
|
||||
allowPrivateAddress: false,
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: null,
|
||||
categories: { ebook: [7020], audiobook: [3030], comic: [7030] },
|
||||
disabledMediaKinds: [],
|
||||
isbnSearchDisabled: false,
|
||||
settings: null,
|
||||
networkProfile: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -146,6 +153,39 @@ describe('PluginIndexerAdapter', () => {
|
||||
expect(release.leechers).toBeNull();
|
||||
});
|
||||
|
||||
it('normalizes seed goals without coercing plugin values or converting minutes twice', async () => {
|
||||
const releases = await searched([
|
||||
{ guid: 'valid', title: 'Dune', seedRatioGoal: 1.5, seedTimeMinutes: 60.2 },
|
||||
{ guid: 'string', title: 'Dune', seedRatioGoal: '2', seedTimeMinutes: '120' },
|
||||
{ guid: 'zero', title: 'Dune', seedRatioGoal: 0, seedTimeMinutes: 0 },
|
||||
{ guid: 'oversized', title: 'Dune', seedRatioGoal: 2, seedTimeMinutes: 2_147_483_648 },
|
||||
]);
|
||||
|
||||
expect(releases[0]).toMatchObject({ seedRatioGoal: 1.5, seedTimeMinutes: 61 });
|
||||
expect(releases[1]).not.toHaveProperty('seedRatioGoal');
|
||||
expect(releases[1]).not.toHaveProperty('seedTimeMinutes');
|
||||
expect(releases[2]).not.toHaveProperty('seedRatioGoal');
|
||||
expect(releases[2]).not.toHaveProperty('seedTimeMinutes');
|
||||
expect(releases[3]).toHaveProperty('seedRatioGoal', 2);
|
||||
expect(releases[3]).not.toHaveProperty('seedTimeMinutes');
|
||||
});
|
||||
|
||||
it('does not expose host seed policy to a plugin', async () => {
|
||||
let seen: Record<string, unknown> | undefined;
|
||||
const { adapter } = makeAdapter({
|
||||
search: (_query, given) => {
|
||||
seen = given as unknown as Record<string, unknown>;
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
});
|
||||
|
||||
await adapter.search(query(), config({ applyTrackerSeedGoals: false, seedRatioGoal: 3, seedTimeMinutes: 60 }), AbortSignal.timeout(1000));
|
||||
|
||||
expect(seen).not.toHaveProperty('applyTrackerSeedGoals');
|
||||
expect(seen).not.toHaveProperty('seedRatioGoal');
|
||||
expect(seen).not.toHaveProperty('seedTimeMinutes');
|
||||
});
|
||||
|
||||
it('reads a flag as the boolean it is meant to be', async () => {
|
||||
const [release] = await searched([{ guid: 'g1', title: 'Dune', freeleech: 'yes', vipOnly: 1, alreadyGrabbed: null }]);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type ReleaseQuery,
|
||||
type ResolvedIndexerConfig,
|
||||
} from '../indexer-adapter';
|
||||
import { normalizeProviderSeedRatio, normalizeProviderSeedTimeMinutes } from '../seed-goal.utils';
|
||||
|
||||
/**
|
||||
* A ceiling on any one request a plugin makes, and one it cannot opt out of.
|
||||
@@ -323,8 +324,8 @@ function sanitizeCandidate(raw: PluginReleaseCandidate, indexerId: number): Rele
|
||||
vipOnly: raw.vipOnly === true,
|
||||
...optionalNumber('primaryFileCount', raw.primaryFileCount),
|
||||
...optionalNumber('fileCount', raw.fileCount),
|
||||
...optionalNumber('seedRatioGoal', raw.seedRatioGoal),
|
||||
...optionalNumber('seedTimeMinutes', raw.seedTimeMinutes),
|
||||
...optionalNormalizedNumber('seedRatioGoal', normalizeProviderSeedRatio(raw.seedRatioGoal)),
|
||||
...optionalNormalizedNumber('seedTimeMinutes', normalizeProviderSeedTimeMinutes(raw.seedTimeMinutes)),
|
||||
...(raw.audio && typeof raw.audio === 'object' ? { audio: sanitizeAudio(raw.audio) } : {}),
|
||||
};
|
||||
}
|
||||
@@ -366,6 +367,10 @@ function optionalNumber<K extends string>(key: K, value: unknown): Record<K, num
|
||||
return parsed === null ? {} : ({ [key]: parsed } as Record<K, number>);
|
||||
}
|
||||
|
||||
function optionalNormalizedNumber<K extends string>(key: K, value: number | undefined): Record<K, number> | Record<string, never> {
|
||||
return value === undefined ? {} : ({ [key]: value } as Record<K, number>);
|
||||
}
|
||||
|
||||
function toPluginQuery(query: ReleaseQuery): PluginReleaseQuery {
|
||||
return {
|
||||
title: query.title,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { MAX_INDEXER_SEED_TIME_MINUTES } from '@bookorbit/types';
|
||||
|
||||
export function normalizeProviderSeedRatio(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export function normalizeProviderSeedTimeMinutes(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined;
|
||||
const minutes = Math.ceil(value);
|
||||
return minutes <= MAX_INDEXER_SEED_TIME_MINUTES ? minutes : undefined;
|
||||
}
|
||||
|
||||
export function normalizeTorznabSeedTimeSeconds(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined;
|
||||
return normalizeProviderSeedTimeMinutes(value / 60);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from 'crypto';
|
||||
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import type { BookRequestBulkResult, BookRequestItem, BookRequestPage, BookRequestSubmitResult } from '@bookorbit/types';
|
||||
import type { BookRequestBulkResult, BookRequestItem, BookRequestPage, BookRequestSubmitResult, IndexerItem } from '@bookorbit/types';
|
||||
|
||||
import * as schema from '../src/db/schema';
|
||||
import {
|
||||
@@ -88,7 +88,7 @@ describe('Book requests (e2e)', () => {
|
||||
await closeAuthorizationMatrixE2EContext(ctx);
|
||||
});
|
||||
|
||||
function inject(method: 'GET' | 'POST' | 'DELETE', url: string, session: TestUserSession, payload?: unknown) {
|
||||
function inject(method: 'GET' | 'POST' | 'PUT' | 'DELETE', url: string, session: TestUserSession, payload?: unknown) {
|
||||
return ctx.app.inject({
|
||||
method,
|
||||
url: `/api/v1${url}`,
|
||||
@@ -633,6 +633,124 @@ describe('Book requests (e2e)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('indexer seed policy', () => {
|
||||
it(
|
||||
'round-trips independent updates and null clears for a settings administrator',
|
||||
async () => {
|
||||
const created = await inject('POST', '/admin/request-indexers', settingsAdmin, {
|
||||
name: `seed-policy-${randomUUID()}`,
|
||||
adapterType: 'torznab',
|
||||
baseUrl: 'http://203.0.113.10:9117',
|
||||
applyTrackerSeedGoals: false,
|
||||
seedRatioGoal: 1.25,
|
||||
seedTimeMinutes: 60,
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
const item = created.json() as IndexerItem;
|
||||
expect(item).toMatchObject({
|
||||
applyTrackerSeedGoals: false,
|
||||
seedRatioGoal: 1.25,
|
||||
seedTimeMinutes: 60,
|
||||
});
|
||||
expect(created.body).not.toContain('credentialsEnc');
|
||||
|
||||
try {
|
||||
const updated = await inject('PUT', `/admin/request-indexers/${item.id}`, settingsAdmin, { seedTimeMinutes: 90 });
|
||||
expect(updated.statusCode).toBe(200);
|
||||
expect(updated.json()).toMatchObject({ seedRatioGoal: 1.25, seedTimeMinutes: 90 });
|
||||
|
||||
const cleared = await inject('PUT', `/admin/request-indexers/${item.id}`, settingsAdmin, {
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: null,
|
||||
});
|
||||
expect(cleared.statusCode).toBe(200);
|
||||
expect(cleared.json()).toMatchObject({ seedRatioGoal: null, seedTimeMinutes: null });
|
||||
} finally {
|
||||
await inject('DELETE', `/admin/request-indexers/${item.id}`, settingsAdmin);
|
||||
}
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'enforces settings permission and rejects invalid policy before writing',
|
||||
async () => {
|
||||
const payload = {
|
||||
name: `seed-policy-denied-${randomUUID()}`,
|
||||
adapterType: 'torznab',
|
||||
baseUrl: 'http://203.0.113.10:9117',
|
||||
seedTimeMinutes: 60,
|
||||
};
|
||||
expect((await inject('POST', '/admin/request-indexers', moderator, payload)).statusCode).toBe(403);
|
||||
expect((await inject('POST', '/admin/request-indexers', requester, payload)).statusCode).toBe(403);
|
||||
|
||||
const invalid = await inject('POST', '/admin/request-indexers', settingsAdmin, { ...payload, seedTimeMinutes: 2_147_483_648 });
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
expect(
|
||||
await ctx.db.select({ id: schema.requestIndexers.id }).from(schema.requestIndexers).where(eq(schema.requestIndexers.name, payload.name)),
|
||||
).toHaveLength(0);
|
||||
|
||||
const nonTorrentName = `seed-policy-non-torrent-${randomUUID()}`;
|
||||
const nonTorrent = await inject('POST', '/admin/request-indexers', settingsAdmin, {
|
||||
name: nonTorrentName,
|
||||
adapterType: 'newznab',
|
||||
baseUrl: 'http://203.0.113.10:9117',
|
||||
credential: 'api-key',
|
||||
applyTrackerSeedGoals: false,
|
||||
});
|
||||
expect(nonTorrent.statusCode).toBe(400);
|
||||
expect(
|
||||
await ctx.db.select({ id: schema.requestIndexers.id }).from(schema.requestIndexers).where(eq(schema.requestIndexers.name, nonTorrentName)),
|
||||
).toHaveLength(0);
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'enforces seed-goal constraints in PostgreSQL',
|
||||
async () => {
|
||||
const validName = `seed-policy-db-${randomUUID()}`;
|
||||
const [valid] = await ctx.db
|
||||
.insert(schema.requestIndexers)
|
||||
.values({
|
||||
name: validName,
|
||||
adapterType: 'torznab',
|
||||
baseUrl: 'http://203.0.113.10:9117',
|
||||
seedRatioGoal: 1.25,
|
||||
seedTimeMinutes: 2_147_483_647,
|
||||
})
|
||||
.returning({ id: schema.requestIndexers.id, applyTrackerSeedGoals: schema.requestIndexers.applyTrackerSeedGoals });
|
||||
expect(valid?.applyTrackerSeedGoals).toBe(true);
|
||||
|
||||
try {
|
||||
for (const ratio of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
|
||||
await expect(
|
||||
ctx.db.insert(schema.requestIndexers).values({
|
||||
name: `seed-policy-bad-ratio-${randomUUID()}`,
|
||||
adapterType: 'torznab',
|
||||
baseUrl: 'http://203.0.113.10:9117',
|
||||
seedRatioGoal: ratio,
|
||||
}),
|
||||
).rejects.toBeDefined();
|
||||
}
|
||||
for (const minutes of [0, -1, 2_147_483_648]) {
|
||||
await expect(
|
||||
ctx.db.insert(schema.requestIndexers).values({
|
||||
name: `seed-policy-bad-time-${randomUUID()}`,
|
||||
adapterType: 'torznab',
|
||||
baseUrl: 'http://203.0.113.10:9117',
|
||||
seedTimeMinutes: minutes,
|
||||
}),
|
||||
).rejects.toBeDefined();
|
||||
}
|
||||
} finally {
|
||||
if (valid) await ctx.db.delete(schema.requestIndexers).where(eq(schema.requestIndexers.id, valid.id));
|
||||
}
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe('request contract', () => {
|
||||
/**
|
||||
* `forbidNonWhitelisted` is assumed by every DTO in this feature, and an unknown field
|
||||
@@ -654,6 +772,20 @@ describe('Book requests (e2e)', () => {
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'refuses browser-supplied seed goals on a grab',
|
||||
async () => {
|
||||
const { request } = await submitOk(selfFulfiller, { selfServe: true });
|
||||
const response = await inject('POST', `/book-request-fulfilment/${request.id}/grab`, selfFulfiller, {
|
||||
magnet: 'magnet:?xt=urn:btih:c9e15763f722f23e98a29decdfae341b98d53056',
|
||||
seedRatioGoal: 1,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'stores the format preference and note the request form now sends',
|
||||
async () => {
|
||||
|
||||
Reference in New Issue
Block a user