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:
neon
2026-09-13 17:40:13 +00:00
parent 8f1215d331
commit 3bb33b615c
35 changed files with 19912 additions and 51 deletions
@@ -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,
+24
View File
@@ -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"
},