fix(settings): honor disabled Prowlarr seed limits

Keep disabled Prowlarr limits from being reapplied through tracker metadata,
cached releases, or stale source rows after a failed synchronization.

Cover policy changes, synchronization, both torrent transports, client defaults,
and the PostgreSQL join with regression tests.

Closes #1625
This commit is contained in:
neon
2026-10-12 15:46:26 +00:00
parent b1ffb9fbce
commit 2327847bff
10 changed files with 346 additions and 3 deletions
@@ -100,6 +100,23 @@ describe('QbittorrentAdapter', () => {
expect(form.get('category')).toBe('bookorbit'); 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 () => { it('passes seed goals through so the client, not BookOrbit, enforces them', async () => {
const { calls } = mockFetch(); const { calls } = mockFetch();
@@ -760,6 +760,47 @@ describe('RequestFulfillmentService.grab from a picked release', () => {
expect(payload).toHaveProperty('seedTimeMinutes', 90); 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 () => { it('uses current policy after an inspection cache hit without fetching bytes again', async () => {
const fetchTorrentFile = vi.fn().mockResolvedValue(torrentBytes()); const fetchTorrentFile = vi.fn().mockResolvedValue(torrentBytes());
const resolveSeedPolicy = vi.fn().mockResolvedValue({ const resolveSeedPolicy = vi.fn().mockResolvedValue({
@@ -54,6 +54,8 @@ function makeService(
findAssignedColors: vi.fn().mockResolvedValue([null]), findAssignedColors: vi.fn().mockResolvedValue([null]),
findById: vi.fn().mockResolvedValue(indexerRow()), findById: vi.fn().mockResolvedValue(indexerRow()),
findSeedPolicyById: vi.fn().mockResolvedValue({ findSeedPolicyById: vi.fn().mockResolvedValue({
managerId: null,
inheritSeedLimits: null,
id: 9, id: 9,
adapterType: 'torznab', adapterType: 'torznab',
applyTrackerSeedGoals: true, applyTrackerSeedGoals: true,
@@ -289,6 +291,102 @@ describe('IndexerConfigService', () => {
expect(credentials.decrypt).not.toHaveBeenCalled(); 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 () => { it('assigns a random unused color when a new indexer does not name one', async () => {
const existing = INDEXER_COLORS.filter((color) => color !== 'teal'); const existing = INDEXER_COLORS.filter((color) => color !== 'teal');
const { service, repo } = makeService({ repo: { findAssignedColors: vi.fn().mockResolvedValue(existing) } }); const { service, repo } = makeService({ repo: { findAssignedColors: vi.fn().mockResolvedValue(existing) } });
@@ -252,10 +252,16 @@ export class IndexerConfigService {
const row = await this.repo.findSeedPolicyById(id); const row = await this.repo.findSeedPolicyById(id);
if (!row) throw new NotFoundException('Indexer not found'); if (!row) throw new NotFoundException('Indexer not found');
this.registry.require(row.adapterType); 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 { return {
...row, id: row.id,
adapterType: row.adapterType as IndexerAdapterType, adapterType: row.adapterType as IndexerAdapterType,
seedsBack: this.registry.seedsBack(row.adapterType), seedsBack: this.registry.seedsBack(row.adapterType),
applyTrackerSeedGoals: managed ? inheritSeedLimits : row.applyTrackerSeedGoals,
seedRatioGoal: inheritSeedLimits ? row.seedRatioGoal : null,
seedTimeMinutes: inheritSeedLimits ? row.seedTimeMinutes : null,
}; };
} }
@@ -73,4 +73,61 @@ describe('IndexerManagerRepository', () => {
expect(updates.filter((update) => 'color' in update).map((update) => update.color)).toEqual(['orange', 'blue']); expect(updates.filter((update) => 'color' in update).map((update) => update.color)).toEqual(['orange', 'blue']);
expect(inserts).toEqual([expect.objectContaining({ managerExternalId: '3', color: 'indigo' })]); 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<Partial<RequestIndexerRow>> = [];
const tx = {
select: vi
.fn()
.mockReturnValueOnce({ from: () => ({ where: () => Promise.resolve(existing) }) })
.mockReturnValueOnce({ from: () => Promise.resolve(existing) }),
update: vi.fn(() => ({
set: (data: Partial<RequestIndexerRow>) => {
if ('adapterType' in data) sourceWrites.push(data);
return { where: () => Promise.resolve() };
},
})),
insert: vi.fn(() => ({
values: (data: Partial<RequestIndexerRow>) => {
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,
});
}
},
);
}); });
@@ -159,6 +159,7 @@ export class IndexerManagerRepository {
allowPrivateAddress: manager.allowPrivateAddress, allowPrivateAddress: manager.allowPrivateAddress,
networkProfile: manager.networkProfile, networkProfile: manager.networkProfile,
categories: categories[source.protocol], categories: categories[source.protocol],
applyTrackerSeedGoals: source.protocol !== 'torrent' || manager.inheritSeedLimits,
seedRatioGoal: manager.inheritSeedLimits && source.protocol === 'torrent' ? source.seedRatioGoal : null, seedRatioGoal: manager.inheritSeedLimits && source.protocol === 'torrent' ? source.seedRatioGoal : null,
seedTimeMinutes: manager.inheritSeedLimits && source.protocol === 'torrent' ? source.seedTimeMinutes : null, seedTimeMinutes: manager.inheritSeedLimits && source.protocol === 'torrent' ? source.seedTimeMinutes : null,
}) })
@@ -180,7 +181,7 @@ export class IndexerManagerRepository {
baseUrl, baseUrl,
credentialsEnc: null, credentialsEnc: null,
allowPrivateAddress: manager.allowPrivateAddress, allowPrivateAddress: manager.allowPrivateAddress,
applyTrackerSeedGoals: true, applyTrackerSeedGoals: source.protocol !== 'torrent' || manager.inheritSeedLimits,
seedRatioGoal: manager.inheritSeedLimits && source.protocol === 'torrent' ? source.seedRatioGoal : null, seedRatioGoal: manager.inheritSeedLimits && source.protocol === 'torrent' ? source.seedRatioGoal : null,
seedTimeMinutes: manager.inheritSeedLimits && source.protocol === 'torrent' ? source.seedTimeMinutes : null, seedTimeMinutes: manager.inheritSeedLimits && source.protocol === 'torrent' ? source.seedTimeMinutes : null,
categories: categories[source.protocol], categories: categories[source.protocol],
@@ -163,4 +163,14 @@ describe('IndexerManagerService', () => {
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(repo.create).not.toHaveBeenCalled(); 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();
});
}); });
@@ -23,6 +23,18 @@ describe('IndexerRepository', () => {
expect(queries[0].params).toEqual([3, 8]); 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 () => { it('deletes every indexer with the requested adapter type in one query', async () => {
const { db, queries } = createCapturingDb(); const { db, queries } = createCapturingDb();
const repository = new IndexerRepository(db); const repository = new IndexerRepository(db);
@@ -6,13 +6,15 @@ import type { BookRequestSourceStatus, IndexerColor } from '@bookorbit/types';
import { DB } from '../../../db'; import { DB } from '../../../db';
import * as schema from '../../../db/schema'; 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<typeof schema>; type Db = NodePgDatabase<typeof schema>;
export interface IndexerSeedPolicyRow { export interface IndexerSeedPolicyRow {
id: number; id: number;
adapterType: string; adapterType: string;
managerId: number | null;
inheritSeedLimits: boolean | null;
applyTrackerSeedGoals: boolean; applyTrackerSeedGoals: boolean;
seedRatioGoal: number | null; seedRatioGoal: number | null;
seedTimeMinutes: number | null; seedTimeMinutes: number | null;
@@ -46,11 +48,14 @@ export class IndexerRepository {
.select({ .select({
id: requestIndexers.id, id: requestIndexers.id,
adapterType: requestIndexers.adapterType, adapterType: requestIndexers.adapterType,
managerId: requestIndexers.managerId,
inheritSeedLimits: requestIndexerManagers.inheritSeedLimits,
applyTrackerSeedGoals: requestIndexers.applyTrackerSeedGoals, applyTrackerSeedGoals: requestIndexers.applyTrackerSeedGoals,
seedRatioGoal: requestIndexers.seedRatioGoal, seedRatioGoal: requestIndexers.seedRatioGoal,
seedTimeMinutes: requestIndexers.seedTimeMinutes, seedTimeMinutes: requestIndexers.seedTimeMinutes,
}) })
.from(requestIndexers) .from(requestIndexers)
.leftJoin(requestIndexerManagers, eq(requestIndexers.managerId, requestIndexerManagers.id))
.where(eq(requestIndexers.id, id)) .where(eq(requestIndexers.id, id))
.limit(1); .limit(1);
return row; return row;
+96
View File
@@ -12,6 +12,7 @@ import type {
} from '@bookorbit/types'; } from '@bookorbit/types';
import * as schema from '../src/db/schema'; import * as schema from '../src/db/schema';
import { IndexerConfigService } from '../src/modules/book-request/indexers/indexer-config.service';
import { import {
authHeader, authHeader,
closeAuthorizationMatrixE2EContext, closeAuthorizationMatrixE2EContext,
@@ -1085,4 +1086,99 @@ describe('Book requests (e2e)', () => {
SCENARIO_TIMEOUT_MS, 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,
);
});
}); });