Add per-indexer ratio-based seeding policy

Introduce a per-indexer ratioLimit alongside seedingTimeMinutes to control torrent cleanup. Updates include: documentation (scheduler and settings pages), types and API (saved indexer config now includes ratioLimit), setup and management UI (new TorrentSeedingFields component, modal wiring, validation and handlers), and processor logic (cleanup-seeded-torrents now requires AND-semantics between time and ratio; 0 disables a criterion, both 0 = never cleaned, undefined client ratio with ratioLimit>0 = not met). Tests were added/updated to cover ratio-only, time+ratio, missing-ratio, and UI interactions. Default behavior: ratioLimit defaults to 0 (no ratio requirement).
This commit is contained in:
kikootwo
2026-05-18 15:07:50 -04:00
parent 01e61f3368
commit 411b5f88a4
13 changed files with 407 additions and 41 deletions
@@ -217,6 +217,251 @@ describe('processCleanupSeededTorrents', () => {
expect(qbtClientMock.deleteDownload).toHaveBeenCalledWith('hash-ebook-1', true);
});
it('cleans up when ratio-only requirement is met', async () => {
configMock.get.mockResolvedValue(
JSON.stringify([{ name: 'RatioIndexer', seedingTimeMinutes: 0, ratioLimit: 1.0 }])
);
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-ratio-1',
name: 'Ratio Torrent',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'seeding',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
seedingTime: 60,
ratio: 1.5,
}),
deleteDownload: vi.fn().mockResolvedValue(undefined),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.request.findMany
.mockResolvedValueOnce([
{
id: 'req-ratio-1',
deletedAt: null,
downloadHistory: [
{
selected: true,
downloadStatus: 'completed',
indexerName: 'RatioIndexer',
torrentHash: 'hash-ratio-1',
downloadClientId: 'hash-ratio-1',
downloadClient: 'qbittorrent',
},
],
},
])
.mockResolvedValueOnce([]);
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
const result = await processCleanupSeededTorrents({ jobId: 'job-ratio-1' });
expect(result.cleaned).toBe(1);
expect(qbtClientMock.deleteDownload).toHaveBeenCalledWith('hash-ratio-1', true);
});
it('skips when both criteria set, time met but ratio not met', async () => {
configMock.get.mockResolvedValue(
JSON.stringify([{ name: 'BothIndexer', seedingTimeMinutes: 30, ratioLimit: 1.0 }])
);
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-both-1',
name: 'Both Torrent',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'seeding',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
seedingTime: 60 * 40,
ratio: 0.5,
}),
deleteDownload: vi.fn(),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.request.findMany
.mockResolvedValueOnce([
{
id: 'req-both-1',
deletedAt: null,
downloadHistory: [
{
selected: true,
downloadStatus: 'completed',
indexerName: 'BothIndexer',
torrentHash: 'hash-both-1',
downloadClientId: 'hash-both-1',
downloadClient: 'qbittorrent',
},
],
},
])
.mockResolvedValueOnce([]);
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
const result = await processCleanupSeededTorrents({ jobId: 'job-both-1' });
expect(result.skipped).toBe(1);
expect(qbtClientMock.deleteDownload).not.toHaveBeenCalled();
});
it('skips when both criteria set, ratio met but time not met', async () => {
configMock.get.mockResolvedValue(
JSON.stringify([{ name: 'BothIndexer', seedingTimeMinutes: 30, ratioLimit: 1.0 }])
);
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-both-2',
name: 'Both Torrent',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'seeding',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
seedingTime: 60 * 10,
ratio: 1.5,
}),
deleteDownload: vi.fn(),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.request.findMany
.mockResolvedValueOnce([
{
id: 'req-both-2',
deletedAt: null,
downloadHistory: [
{
selected: true,
downloadStatus: 'completed',
indexerName: 'BothIndexer',
torrentHash: 'hash-both-2',
downloadClientId: 'hash-both-2',
downloadClient: 'qbittorrent',
},
],
},
])
.mockResolvedValueOnce([]);
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
const result = await processCleanupSeededTorrents({ jobId: 'job-both-2' });
expect(result.skipped).toBe(1);
expect(qbtClientMock.deleteDownload).not.toHaveBeenCalled();
});
it('cleans up when both criteria set and both met', async () => {
configMock.get.mockResolvedValue(
JSON.stringify([{ name: 'BothIndexer', seedingTimeMinutes: 30, ratioLimit: 1.0 }])
);
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-both-3',
name: 'Both Torrent',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'seeding',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
seedingTime: 60 * 40,
ratio: 1.5,
}),
deleteDownload: vi.fn().mockResolvedValue(undefined),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.request.findMany
.mockResolvedValueOnce([
{
id: 'req-both-3',
deletedAt: null,
downloadHistory: [
{
selected: true,
downloadStatus: 'completed',
indexerName: 'BothIndexer',
torrentHash: 'hash-both-3',
downloadClientId: 'hash-both-3',
downloadClient: 'qbittorrent',
},
],
},
])
.mockResolvedValueOnce([]);
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
const result = await processCleanupSeededTorrents({ jobId: 'job-both-3' });
expect(result.cleaned).toBe(1);
expect(qbtClientMock.deleteDownload).toHaveBeenCalledWith('hash-both-3', true);
});
it('skips when ratio-only requirement set but client reports no ratio', async () => {
configMock.get.mockResolvedValue(
JSON.stringify([{ name: 'RatioIndexer', seedingTimeMinutes: 0, ratioLimit: 1.0 }])
);
const qbtClientMock = {
clientType: 'qbittorrent',
protocol: 'torrent',
getDownload: vi.fn().mockResolvedValue({
id: 'hash-noratio-1',
name: 'No-Ratio Torrent',
size: 0,
bytesDownloaded: 0,
progress: 1.0,
status: 'seeding',
downloadSpeed: 0,
eta: 0,
category: 'readmeabook',
seedingTime: 60 * 100,
// ratio intentionally omitted (undefined)
}),
deleteDownload: vi.fn(),
};
downloadClientManagerMock.getClientServiceForProtocol.mockResolvedValue(qbtClientMock);
prismaMock.request.findMany
.mockResolvedValueOnce([
{
id: 'req-noratio-1',
deletedAt: null,
downloadHistory: [
{
selected: true,
downloadStatus: 'completed',
indexerName: 'RatioIndexer',
torrentHash: 'hash-noratio-1',
downloadClientId: 'hash-noratio-1',
downloadClient: 'qbittorrent',
},
],
},
])
.mockResolvedValueOnce([]);
const { processCleanupSeededTorrents } = await import('@/lib/processors/cleanup-seeded-torrents.processor');
const result = await processCleanupSeededTorrents({ jobId: 'job-noratio-1' });
expect(result.skipped).toBe(1);
expect(qbtClientMock.deleteDownload).not.toHaveBeenCalled();
});
it('detects shared torrents across audiobook and ebook requests', async () => {
configMock.get.mockResolvedValue(
JSON.stringify([{ name: 'SharedIndexer', seedingTimeMinutes: 10 }])