mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
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:
@@ -53,7 +53,7 @@ describe('Admin Prowlarr indexers route', () => {
|
||||
|
||||
it('saves indexer configuration', async () => {
|
||||
authRequest.json.mockResolvedValue({
|
||||
indexers: [{ id: 1, name: 'Indexer', protocol: 'torrent', enabled: true, priority: 10, seedingTimeMinutes: 0 }],
|
||||
indexers: [{ id: 1, name: 'Indexer', protocol: 'torrent', enabled: true, priority: 10, seedingTimeMinutes: 0, ratioLimit: 1.5 }],
|
||||
flagConfigs: [],
|
||||
});
|
||||
|
||||
@@ -63,6 +63,11 @@ describe('Admin Prowlarr indexers route', () => {
|
||||
|
||||
expect(payload.success).toBe(true);
|
||||
expect(configServiceMock.setMany).toHaveBeenCalled();
|
||||
const setManyArg = configServiceMock.setMany.mock.calls[0][0];
|
||||
const indexersEntry = setManyArg.find((e: any) => e.key === 'prowlarr_indexers');
|
||||
expect(indexersEntry).toBeDefined();
|
||||
const persisted = JSON.parse(indexersEntry.value);
|
||||
expect(persisted[0]).toMatchObject({ id: 1, ratioLimit: 1.5, seedingTimeMinutes: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ const indexersMock = [
|
||||
name: 'Indexer',
|
||||
priority: 10,
|
||||
seedingTimeMinutes: 0,
|
||||
ratioLimit: 1.5,
|
||||
rssEnabled: true,
|
||||
categories: [],
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('IndexerConfigModal', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const [priorityInput, seedingInput] = screen.getAllByRole('spinbutton');
|
||||
const [priorityInput, seedingInput, ratioInput] = screen.getAllByRole('spinbutton');
|
||||
|
||||
fireEvent.change(priorityInput, { target: { value: '99' } });
|
||||
expect(priorityInput).toHaveValue(25);
|
||||
@@ -32,6 +32,12 @@ describe('IndexerConfigModal', () => {
|
||||
fireEvent.change(seedingInput, { target: { value: '-5' } });
|
||||
expect(seedingInput).toHaveValue(0);
|
||||
|
||||
fireEvent.change(ratioInput, { target: { value: '-0.5' } });
|
||||
expect(ratioInput).toHaveValue(0);
|
||||
|
||||
fireEvent.change(ratioInput, { target: { value: '1.5' } });
|
||||
expect(ratioInput).toHaveValue(1.5);
|
||||
|
||||
const rssToggle = screen.getByRole('checkbox');
|
||||
fireEvent.click(rssToggle);
|
||||
|
||||
@@ -43,6 +49,7 @@ describe('IndexerConfigModal', () => {
|
||||
name: 'Prowlarr',
|
||||
priority: 25,
|
||||
seedingTimeMinutes: 0,
|
||||
ratioLimit: 1.5,
|
||||
rssEnabled: false,
|
||||
audiobookCategories: expect.arrayContaining([3030]),
|
||||
ebookCategories: expect.arrayContaining([7020]),
|
||||
|
||||
@@ -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 }])
|
||||
|
||||
Reference in New Issue
Block a user