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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user