diff --git a/server/src/modules/book-request/download-clients/adapters/qbittorrent.adapter.test.ts b/server/src/modules/book-request/download-clients/adapters/qbittorrent.adapter.test.ts index bcb55ade8..28ef8462e 100644 --- a/server/src/modules/book-request/download-clients/adapters/qbittorrent.adapter.test.ts +++ b/server/src/modules/book-request/download-clients/adapters/qbittorrent.adapter.test.ts @@ -100,6 +100,23 @@ describe('QbittorrentAdapter', () => { expect(form.get('category')).toBe('bookorbit'); }); + it.each(['magnet', 'torrent'] as const)('leaves the client seed defaults untouched when adding a %s without goals', async (transport) => { + const { calls } = mockFetch(); + await adapter.add( + { + clientKey: INFO_HASH, + ...(transport === 'magnet' ? { magnet: `magnet:?xt=urn:btih:${INFO_HASH}` } : { torrentFile: Buffer.from('d4:infod4:name4:duneee') }), + }, + config(), + ); + + const form = calls.find((call) => call.url.includes('/torrents/add'))?.init.body as FormData; + expect(form.get('category')).toBe('bookorbit'); + expect(form.has('ratioLimit')).toBe(false); + expect(form.has('seedingTimeLimit')).toBe(false); + expect(calls.some((call) => call.url.includes('setShareLimits'))).toBe(false); + }); + it('passes seed goals through so the client, not BookOrbit, enforces them', async () => { const { calls } = mockFetch(); diff --git a/server/src/modules/book-request/fulfillment/request-fulfillment.service.test.ts b/server/src/modules/book-request/fulfillment/request-fulfillment.service.test.ts index 3d7cc0318..c487f8743 100644 --- a/server/src/modules/book-request/fulfillment/request-fulfillment.service.test.ts +++ b/server/src/modules/book-request/fulfillment/request-fulfillment.service.test.ts @@ -760,6 +760,47 @@ describe('RequestFulfillmentService.grab from a picked release', () => { expect(payload).toHaveProperty('seedTimeMinutes', 90); }); + it.each([ + { transport: 'torrent', inspected: false }, + { transport: 'torrent', inspected: true }, + { transport: 'magnet', inspected: false }, + { transport: 'magnet', inspected: true }, + ])('omits both tracker limits for a disabled source ($transport, inspected=$inspected)', async ({ transport, inspected }) => { + const fetchTorrentFile = vi.fn().mockResolvedValue(torrentBytes()); + const resolveSeedPolicy = vi.fn().mockResolvedValue({ + id: 9, + adapterType: 'torznab', + seedsBack: true, + applyTrackerSeedGoals: true, + seedRatioGoal: null, + seedTimeMinutes: null, + }); + const { service, adapter } = makeService({ + releases: { + find: vi.fn().mockReturnValue({ ...RELEASE, seedRatioGoal: 1, seedTimeMinutes: 4320, ...(transport === 'magnet' ? { magnet: MAGNET } : {}) }), + }, + indexers: { resolveSeedPolicy }, + indexerAdapter: { fetchTorrentFile }, + }); + + if (inspected) await service.inspectRelease(7, { indexerId: 9, releaseGuid: 'r-1' }); + resolveSeedPolicy.mockResolvedValue({ + id: 9, + adapterType: 'torznab', + seedsBack: true, + applyTrackerSeedGoals: false, + seedRatioGoal: null, + seedTimeMinutes: null, + }); + await service.grab(7, { indexerId: 9, releaseGuid: 'r-1' }, user()); + + expect(resolveSeedPolicy).toHaveBeenCalledExactlyOnceWith(9); + expect(adapter.add).toHaveBeenCalledTimes(1); + expect(adapter.add.mock.calls[0]?.[0]).not.toHaveProperty('seedRatioGoal'); + expect(adapter.add.mock.calls[0]?.[0]).not.toHaveProperty('seedTimeMinutes'); + expect(fetchTorrentFile).toHaveBeenCalledTimes(transport === 'torrent' ? 1 : 0); + }); + 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({ diff --git a/server/src/modules/book-request/indexers/indexer-config.service.test.ts b/server/src/modules/book-request/indexers/indexer-config.service.test.ts index 8c59da773..28f3efcfc 100644 --- a/server/src/modules/book-request/indexers/indexer-config.service.test.ts +++ b/server/src/modules/book-request/indexers/indexer-config.service.test.ts @@ -54,6 +54,8 @@ function makeService( findAssignedColors: vi.fn().mockResolvedValue([null]), findById: vi.fn().mockResolvedValue(indexerRow()), findSeedPolicyById: vi.fn().mockResolvedValue({ + managerId: null, + inheritSeedLimits: null, id: 9, adapterType: 'torznab', applyTrackerSeedGoals: true, @@ -289,6 +291,102 @@ describe('IndexerConfigService', () => { expect(credentials.decrypt).not.toHaveBeenCalled(); }); + it.each([ + { + name: 'disabled manager with stale inherited values', + managerId: 3, + inheritSeedLimits: false, + applyTrackerSeedGoals: true, + expectedTracker: false, + expectedRatio: null, + expectedTime: null, + }, + { + name: 'enabled manager with a stale disabled source', + managerId: 3, + inheritSeedLimits: true, + applyTrackerSeedGoals: false, + expectedTracker: true, + expectedRatio: 1, + expectedTime: 4320, + }, + { + name: 'missing manager', + managerId: 3, + inheritSeedLimits: null, + applyTrackerSeedGoals: true, + expectedTracker: false, + expectedRatio: null, + expectedTime: null, + }, + { + name: 'standalone source with tracker fallback', + managerId: null, + inheritSeedLimits: null, + applyTrackerSeedGoals: true, + expectedTracker: true, + expectedRatio: 1, + expectedTime: 4320, + }, + { + name: 'standalone source with manual overrides only', + managerId: null, + inheritSeedLimits: null, + applyTrackerSeedGoals: false, + expectedTracker: false, + expectedRatio: 1, + expectedTime: 4320, + }, + ])( + 'resolves seed policy for a $name', + async ({ managerId, inheritSeedLimits, applyTrackerSeedGoals, expectedTracker, expectedRatio, expectedTime }) => { + const { service, credentials, managers } = makeService({ + repo: { + findSeedPolicyById: vi.fn().mockResolvedValue({ + id: 9, + adapterType: 'torznab', + managerId, + inheritSeedLimits, + applyTrackerSeedGoals, + seedRatioGoal: 1, + seedTimeMinutes: 4320, + }), + }, + }); + + await expect(service.resolveSeedPolicy(9)).resolves.toEqual({ + id: 9, + adapterType: 'torznab', + seedsBack: true, + applyTrackerSeedGoals: expectedTracker, + seedRatioGoal: expectedRatio, + seedTimeMinutes: expectedTime, + }); + expect(credentials.decrypt).not.toHaveBeenCalled(); + expect(managers.findById).not.toHaveBeenCalled(); + expect(managers.findRowById).not.toHaveBeenCalled(); + }, + ); + + it('uses the current manager toggle on each grab without waiting for a source synchronization', async () => { + const row = { + id: 9, + adapterType: 'torznab', + managerId: 3, + inheritSeedLimits: true, + applyTrackerSeedGoals: true, + seedRatioGoal: 1, + seedTimeMinutes: 4320, + }; + const { service } = makeService({ repo: { findSeedPolicyById: vi.fn().mockImplementation(() => Promise.resolve({ ...row })) } }); + + await expect(service.resolveSeedPolicy(9)).resolves.toMatchObject({ applyTrackerSeedGoals: true, seedRatioGoal: 1, seedTimeMinutes: 4320 }); + row.inheritSeedLimits = false; + await expect(service.resolveSeedPolicy(9)).resolves.toMatchObject({ applyTrackerSeedGoals: false, seedRatioGoal: null, seedTimeMinutes: null }); + row.inheritSeedLimits = true; + await expect(service.resolveSeedPolicy(9)).resolves.toMatchObject({ applyTrackerSeedGoals: true, seedRatioGoal: 1, seedTimeMinutes: 4320 }); + }); + 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) } }); diff --git a/server/src/modules/book-request/indexers/indexer-config.service.ts b/server/src/modules/book-request/indexers/indexer-config.service.ts index e0bffb0df..71e82c9fd 100644 --- a/server/src/modules/book-request/indexers/indexer-config.service.ts +++ b/server/src/modules/book-request/indexers/indexer-config.service.ts @@ -252,10 +252,16 @@ export class IndexerConfigService { const row = await this.repo.findSeedPolicyById(id); if (!row) throw new NotFoundException('Indexer not found'); this.registry.require(row.adapterType); + // Read the manager toggle at grab time even if synchronizing its source rows failed. + const managed = row.managerId !== null; + const inheritSeedLimits = !managed || row.inheritSeedLimits === true; return { - ...row, + id: row.id, adapterType: row.adapterType as IndexerAdapterType, seedsBack: this.registry.seedsBack(row.adapterType), + applyTrackerSeedGoals: managed ? inheritSeedLimits : row.applyTrackerSeedGoals, + seedRatioGoal: inheritSeedLimits ? row.seedRatioGoal : null, + seedTimeMinutes: inheritSeedLimits ? row.seedTimeMinutes : null, }; } diff --git a/server/src/modules/book-request/indexers/indexer-manager.repository.test.ts b/server/src/modules/book-request/indexers/indexer-manager.repository.test.ts index fbc2064ae..0fae3d790 100644 --- a/server/src/modules/book-request/indexers/indexer-manager.repository.test.ts +++ b/server/src/modules/book-request/indexers/indexer-manager.repository.test.ts @@ -73,4 +73,61 @@ describe('IndexerManagerRepository', () => { expect(updates.filter((update) => 'color' in update).map((update) => update.color)).toEqual(['orange', 'blue']); expect(inserts).toEqual([expect.objectContaining({ managerExternalId: '3', color: 'indigo' })]); }); + it.each([ + { protocol: 'torrent' as const, inheritSeedLimits: false, expectedTracker: false, expectedRatio: null, expectedTime: null }, + { protocol: 'torrent' as const, inheritSeedLimits: true, expectedTracker: true, expectedRatio: 1, expectedTime: 4320 }, + { protocol: 'usenet' as const, inheritSeedLimits: false, expectedTracker: true, expectedRatio: null, expectedTime: null }, + { protocol: 'usenet' as const, inheritSeedLimits: true, expectedTracker: true, expectedRatio: null, expectedTime: null }, + ])( + 'synchronizes new and existing $protocol sources with inheritance=$inheritSeedLimits', + async ({ protocol, inheritSeedLimits, expectedTracker, expectedRatio, expectedTime }) => { + const existing = [{ ...source(1, 'blue'), applyTrackerSeedGoals: !expectedTracker, seedRatioGoal: 7, seedTimeMinutes: 120 }]; + const sourceWrites: Array> = []; + const tx = { + select: vi + .fn() + .mockReturnValueOnce({ from: () => ({ where: () => Promise.resolve(existing) }) }) + .mockReturnValueOnce({ from: () => Promise.resolve(existing) }), + update: vi.fn(() => ({ + set: (data: Partial) => { + if ('adapterType' in data) sourceWrites.push(data); + return { where: () => Promise.resolve() }; + }, + })), + insert: vi.fn(() => ({ + values: (data: Partial) => { + sourceWrites.push(data); + return Promise.resolve(); + }, + })), + }; + const db = { transaction: (run: (value: typeof tx) => unknown) => run(tx) }; + const repository = new IndexerManagerRepository(db as never); + await expect( + repository.syncProwlarrSources( + { + id: 3, + enabled: true, + baseUrl: 'http://127.0.0.1:9696', + allowPrivateAddress: true, + inheritSeedLimits, + syncNewIndexers: true, + networkProfile: null, + } as RequestIndexerManagerRow, + [1, 2].map((id) => ({ ...discovered(id), protocol, seedRatioGoal: 1, seedTimeMinutes: 4320 })), + categories, + ), + ).resolves.toEqual({ created: 1, updated: 1, unavailable: 0 }); + + expect(sourceWrites).toHaveLength(2); + for (const write of sourceWrites) { + expect(write).toMatchObject({ + adapterType: protocol === 'torrent' ? 'torznab' : 'newznab', + applyTrackerSeedGoals: expectedTracker, + seedRatioGoal: expectedRatio, + seedTimeMinutes: expectedTime, + }); + } + }, + ); }); diff --git a/server/src/modules/book-request/indexers/indexer-manager.repository.ts b/server/src/modules/book-request/indexers/indexer-manager.repository.ts index 9c766dd7a..919708ad0 100644 --- a/server/src/modules/book-request/indexers/indexer-manager.repository.ts +++ b/server/src/modules/book-request/indexers/indexer-manager.repository.ts @@ -159,6 +159,7 @@ export class IndexerManagerRepository { allowPrivateAddress: manager.allowPrivateAddress, networkProfile: manager.networkProfile, categories: categories[source.protocol], + applyTrackerSeedGoals: source.protocol !== 'torrent' || manager.inheritSeedLimits, seedRatioGoal: manager.inheritSeedLimits && source.protocol === 'torrent' ? source.seedRatioGoal : null, seedTimeMinutes: manager.inheritSeedLimits && source.protocol === 'torrent' ? source.seedTimeMinutes : null, }) @@ -180,7 +181,7 @@ export class IndexerManagerRepository { baseUrl, credentialsEnc: null, allowPrivateAddress: manager.allowPrivateAddress, - applyTrackerSeedGoals: true, + applyTrackerSeedGoals: source.protocol !== 'torrent' || manager.inheritSeedLimits, seedRatioGoal: manager.inheritSeedLimits && source.protocol === 'torrent' ? source.seedRatioGoal : null, seedTimeMinutes: manager.inheritSeedLimits && source.protocol === 'torrent' ? source.seedTimeMinutes : null, categories: categories[source.protocol], diff --git a/server/src/modules/book-request/indexers/indexer-manager.service.test.ts b/server/src/modules/book-request/indexers/indexer-manager.service.test.ts index f0a443957..000646c1e 100644 --- a/server/src/modules/book-request/indexers/indexer-manager.service.test.ts +++ b/server/src/modules/book-request/indexers/indexer-manager.service.test.ts @@ -163,4 +163,14 @@ describe('IndexerManagerService', () => { ).rejects.toBeInstanceOf(BadRequestException); expect(repo.create).not.toHaveBeenCalled(); }); + + it('keeps a disabled seed-limit toggle saved when its source synchronization fails', async () => { + const { service, repo, prowlarr } = makeService(); + prowlarr.indexers.mockRejectedValueOnce(new Error('Prowlarr unavailable')); + + await expect(service.update(3, { inheritSeedLimits: false })).resolves.toMatchObject({ inheritSeedLimits: false }); + expect(repo.update).toHaveBeenCalledWith(3, { inheritSeedLimits: false }); + expect(repo.recordSyncFailure).toHaveBeenCalledWith(3, 'Prowlarr unavailable'); + expect(repo.syncProwlarrSources).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/modules/book-request/indexers/indexer.repository.test.ts b/server/src/modules/book-request/indexers/indexer.repository.test.ts index 67041d5a7..75ba5616d 100644 --- a/server/src/modules/book-request/indexers/indexer.repository.test.ts +++ b/server/src/modules/book-request/indexers/indexer.repository.test.ts @@ -23,6 +23,18 @@ describe('IndexerRepository', () => { expect(queries[0].params).toEqual([3, 8]); }); + it('reads the source and current manager seed policy in one bounded query without credentials', async () => { + const { db, queries } = createCapturingDb(); + await new IndexerRepository(db).findSeedPolicyById(9); + + expect(queries).toHaveLength(1); + expect(queries[0].sql).toContain('left join "request_indexer_managers" on "request_indexers"."manager_id" = "request_indexer_managers"."id"'); + expect(queries[0].sql).toContain('"request_indexer_managers"."inherit_seed_limits"'); + expect(queries[0].sql).toMatch(/where "request_indexers"\."id" = \$1 limit \$2/); + expect(queries[0].sql).not.toContain('credentials_enc'); + expect(queries[0].params).toEqual([9, 1]); + }); + it('deletes every indexer with the requested adapter type in one query', async () => { const { db, queries } = createCapturingDb(); const repository = new IndexerRepository(db); diff --git a/server/src/modules/book-request/indexers/indexer.repository.ts b/server/src/modules/book-request/indexers/indexer.repository.ts index fd35c642c..15c8e58f5 100644 --- a/server/src/modules/book-request/indexers/indexer.repository.ts +++ b/server/src/modules/book-request/indexers/indexer.repository.ts @@ -6,13 +6,15 @@ import type { BookRequestSourceStatus, IndexerColor } from '@bookorbit/types'; import { DB } from '../../../db'; import * as schema from '../../../db/schema'; -import { requestIndexers, type NewRequestIndexerRow, type RequestIndexerRow } from '../../../db/schema'; +import { requestIndexerManagers, requestIndexers, type NewRequestIndexerRow, type RequestIndexerRow } from '../../../db/schema'; type Db = NodePgDatabase; export interface IndexerSeedPolicyRow { id: number; adapterType: string; + managerId: number | null; + inheritSeedLimits: boolean | null; applyTrackerSeedGoals: boolean; seedRatioGoal: number | null; seedTimeMinutes: number | null; @@ -46,11 +48,14 @@ export class IndexerRepository { .select({ id: requestIndexers.id, adapterType: requestIndexers.adapterType, + managerId: requestIndexers.managerId, + inheritSeedLimits: requestIndexerManagers.inheritSeedLimits, applyTrackerSeedGoals: requestIndexers.applyTrackerSeedGoals, seedRatioGoal: requestIndexers.seedRatioGoal, seedTimeMinutes: requestIndexers.seedTimeMinutes, }) .from(requestIndexers) + .leftJoin(requestIndexerManagers, eq(requestIndexers.managerId, requestIndexerManagers.id)) .where(eq(requestIndexers.id, id)) .limit(1); return row; diff --git a/server/test/book-requests.e2e-spec.ts b/server/test/book-requests.e2e-spec.ts index 3a9590ede..bae1f9436 100644 --- a/server/test/book-requests.e2e-spec.ts +++ b/server/test/book-requests.e2e-spec.ts @@ -12,6 +12,7 @@ import type { } from '@bookorbit/types'; import * as schema from '../src/db/schema'; +import { IndexerConfigService } from '../src/modules/book-request/indexers/indexer-config.service'; import { authHeader, closeAuthorizationMatrixE2EContext, @@ -1085,4 +1086,99 @@ describe('Book requests (e2e)', () => { SCENARIO_TIMEOUT_MS, ); }); + describe('managed source seed policy', () => { + it( + 'honors the current manager toggle over unsynchronized legacy source rows', + async () => { + const [manager] = await ctx.db + .insert(schema.requestIndexerManagers) + .values({ + name: `seed-policy-manager-${randomUUID()}`, + type: 'prowlarr', + baseUrl: 'http://127.0.0.1:9696', + credentialsEnc: 'unused-fixture-credential', + inheritSeedLimits: true, + }) + .returning(); + try { + const [source] = await ctx.db + .insert(schema.requestIndexers) + .values({ + name: `seed-policy-source-${randomUUID()}`, + adapterType: 'torznab', + baseUrl: 'http://127.0.0.1:9696/1', + managerId: manager.id, + managerExternalId: '1', + managerMetadata: { displayName: 'Seed policy fixture', implementation: 'Torznab', protocol: 'torrent', priority: 25 }, + applyTrackerSeedGoals: true, + seedRatioGoal: 1, + seedTimeMinutes: 4320, + }) + .returning(); + const service = ctx.app.get(IndexerConfigService); + await expect(service.resolveSeedPolicy(source.id)).resolves.toMatchObject({ + applyTrackerSeedGoals: true, + seedRatioGoal: 1, + seedTimeMinutes: 4320, + }); + + await ctx.db + .update(schema.requestIndexerManagers) + .set({ inheritSeedLimits: false }) + .where(eq(schema.requestIndexerManagers.id, manager.id)); + await expect(service.resolveSeedPolicy(source.id)).resolves.toEqual({ + id: source.id, + adapterType: 'torznab', + seedsBack: true, + applyTrackerSeedGoals: false, + seedRatioGoal: null, + seedTimeMinutes: null, + }); + const [unchanged] = await ctx.db.select().from(schema.requestIndexers).where(eq(schema.requestIndexers.id, source.id)); + expect(unchanged).toMatchObject({ applyTrackerSeedGoals: true, seedRatioGoal: 1, seedTimeMinutes: 4320 }); + + await ctx.db.update(schema.requestIndexers).set({ applyTrackerSeedGoals: false }).where(eq(schema.requestIndexers.id, source.id)); + await ctx.db.update(schema.requestIndexerManagers).set({ inheritSeedLimits: true }).where(eq(schema.requestIndexerManagers.id, manager.id)); + await expect(service.resolveSeedPolicy(source.id)).resolves.toMatchObject({ + applyTrackerSeedGoals: true, + seedRatioGoal: 1, + seedTimeMinutes: 4320, + }); + } finally { + await ctx.db.delete(schema.requestIndexerManagers).where(eq(schema.requestIndexerManagers.id, manager.id)); + } + }, + SCENARIO_TIMEOUT_MS, + ); + + it( + 'preserves standalone manual goals when the manager join has no row', + async () => { + const [source] = await ctx.db + .insert(schema.requestIndexers) + .values({ + name: `standalone-seed-policy-${randomUUID()}`, + adapterType: 'torznab', + baseUrl: 'http://127.0.0.1:9117', + applyTrackerSeedGoals: false, + seedRatioGoal: 2, + seedTimeMinutes: 90, + }) + .returning(); + try { + await expect(ctx.app.get(IndexerConfigService).resolveSeedPolicy(source.id)).resolves.toEqual({ + id: source.id, + adapterType: 'torznab', + seedsBack: true, + applyTrackerSeedGoals: false, + seedRatioGoal: 2, + seedTimeMinutes: 90, + }); + } finally { + await ctx.db.delete(schema.requestIndexers).where(eq(schema.requestIndexers.id, source.id)); + } + }, + SCENARIO_TIMEOUT_MS, + ); + }); });