From 3bb33b615c374e8b9f386e89083490db6191f0b8 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:40:13 +0000 Subject: [PATCH] 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 --- .../__tests__/RequestSearchPanel.spec.ts | 32 +- .../__tests__/RequestSeedPanel.spec.ts | 27 + .../components/RequestSearchPanel.vue | 8 +- .../components/RequestSeedPanel.vue | 45 +- .../settings/RequestIndexersPanel.vue | 85 +- .../__tests__/RequestIndexersPanel.spec.ts | 152 + .../settings/composables/useIndexerDraft.ts | 142 +- client/src/locales/en.json | 24 + docs/BOOK_REQUESTS.md | 31 + packages/types/src/indexer.ts | 15 + .../0090_add_indexer_seed_goals.sql | 5 + .../src/db/migrations/meta/0090_snapshot.json | 18534 ++++++++++++++++ server/src/db/migrations/meta/_journal.json | 7 + .../db/schema/request-indexers.schema.test.ts | 15 + server/src/db/schema/request-indexers.ts | 10 +- .../adapters/deluge.adapter.test.ts | 9 + .../fulfillment/indexer-seed-policy.test.ts | 44 + .../fulfillment/indexer-seed-policy.ts | 31 + .../request-fulfillment.service.test.ts | 180 + .../request-fulfillment.service.ts | 54 +- .../indexers/adapters/newznab.adapter.test.ts | 3 + .../indexers/adapters/torznab.adapter.test.ts | 34 + .../indexers/adapters/torznab.adapter.ts | 10 +- .../indexers/dto/indexer.dto.test.ts | 44 + .../book-request/indexers/dto/indexer.dto.ts | 27 +- .../book-request/indexers/indexer-adapter.ts | 13 + .../indexers/indexer-config.service.test.ts | 109 + .../indexers/indexer-config.service.ts | 42 +- .../indexer-keepalive.service.test.ts | 3 + .../indexers/indexer-search.service.test.ts | 4 + .../indexers/indexer.repository.ts | 23 + .../indexers/plugins/plugin-host.test.ts | 40 + .../indexers/plugins/plugin-host.ts | 9 +- .../book-request/indexers/seed-goal.utils.ts | 16 + server/test/book-requests.e2e-spec.ts | 136 +- 35 files changed, 19912 insertions(+), 51 deletions(-) create mode 100644 server/src/db/migrations/0090_add_indexer_seed_goals.sql create mode 100644 server/src/db/migrations/meta/0090_snapshot.json create mode 100644 server/src/modules/book-request/fulfillment/indexer-seed-policy.test.ts create mode 100644 server/src/modules/book-request/fulfillment/indexer-seed-policy.ts create mode 100644 server/src/modules/book-request/indexers/dto/indexer.dto.test.ts create mode 100644 server/src/modules/book-request/indexers/seed-goal.utils.ts diff --git a/client/src/features/book-requests/__tests__/RequestSearchPanel.spec.ts b/client/src/features/book-requests/__tests__/RequestSearchPanel.spec.ts index 453f8a9e0..86f7f0b4e 100644 --- a/client/src/features/book-requests/__tests__/RequestSearchPanel.spec.ts +++ b/client/src/features/book-requests/__tests__/RequestSearchPanel.spec.ts @@ -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 diff --git a/client/src/features/book-requests/__tests__/RequestSeedPanel.spec.ts b/client/src/features/book-requests/__tests__/RequestSeedPanel.spec.ts index 10097f3d8..c493bde25 100644 --- a/client/src/features/book-requests/__tests__/RequestSeedPanel.spec.ts +++ b/client/src/features/book-requests/__tests__/RequestSeedPanel.spec.ts @@ -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() diff --git a/client/src/features/book-requests/components/RequestSearchPanel.vue b/client/src/features/book-requests/components/RequestSearchPanel.vue index fc89c3b44..ec912de78 100644 --- a/client/src/features/book-requests/components/RequestSearchPanel.vue +++ b/client/src/features/book-requests/components/RequestSearchPanel.vue @@ -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) {