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:
@@ -22,7 +22,7 @@ Manages recurring/scheduled jobs providing automated tasks (Plex scans, Audible
|
|||||||
4. **retry_missing_torrents** - Default: daily midnight, processes union of `awaiting_search` ∪ `awaiting_release` (limit 50), handles both audiobook and ebook requests. Bidirectional transitions: `awaiting_search` → `awaiting_release` when release date is future + `indexer.skip_unreleased` ON; `awaiting_release` → `awaiting_search` + run search when release date has passed or setting OFF. Sole owner of these transitions. Enabled by default.
|
4. **retry_missing_torrents** - Default: daily midnight, processes union of `awaiting_search` ∪ `awaiting_release` (limit 50), handles both audiobook and ebook requests. Bidirectional transitions: `awaiting_search` → `awaiting_release` when release date is future + `indexer.skip_unreleased` ON; `awaiting_release` → `awaiting_search` + run search when release date has passed or setting OFF. Sole owner of these transitions. Enabled by default.
|
||||||
5. **retry_failed_imports** - Default: every 6 hours, re-attempts 'awaiting_import' status (limit 50), enabled by default
|
5. **retry_failed_imports** - Default: every 6 hours, re-attempts 'awaiting_import' status (limit 50), enabled by default
|
||||||
6. **find_missing_ebooks** - Default: daily midnight, scans `downloaded` ∪ `available` audiobook requests (limit 50) for missing ebook companions and triggers the existing ebook fetch flow (`addSearchEbookJob`). Gated by `ebook_auto_grab_enabled` AND at least one ebook source enabled (`ebook_annas_archive_enabled` or `ebook_indexer_search_enabled`; legacy `ebook_sidecar_enabled` accepted as Anna's fallback). Skips ebook children in-flight (`pending`, `awaiting_approval`, `searching`, `downloading`, `processing`, `awaiting_search`, `awaiting_release`) or `cancelled`. Retries `failed`/`warn` children up to **5 lifetime auto-retries** per audiobook, tracked in `Request.ebookAutoRetryCount` (nullable; processor-private — manual "Fetch Ebook" never reads/writes it). Per-candidate writes are wrapped in `prisma.$transaction` for race-safety with concurrent auto-grab; counter rolls back if `addSearchEbookJob` throws. Enabled by default. Returns `{ scanned, gapsFound, triggered, created, retried, skippedInFlight, skippedCancelled, skippedCapHit }`.
|
6. **find_missing_ebooks** - Default: daily midnight, scans `downloaded` ∪ `available` audiobook requests (limit 50) for missing ebook companions and triggers the existing ebook fetch flow (`addSearchEbookJob`). Gated by `ebook_auto_grab_enabled` AND at least one ebook source enabled (`ebook_annas_archive_enabled` or `ebook_indexer_search_enabled`; legacy `ebook_sidecar_enabled` accepted as Anna's fallback). Skips ebook children in-flight (`pending`, `awaiting_approval`, `searching`, `downloading`, `processing`, `awaiting_search`, `awaiting_release`) or `cancelled`. Retries `failed`/`warn` children up to **5 lifetime auto-retries** per audiobook, tracked in `Request.ebookAutoRetryCount` (nullable; processor-private — manual "Fetch Ebook" never reads/writes it). Per-candidate writes are wrapped in `prisma.$transaction` for race-safety with concurrent auto-grab; counter rolls back if `addSearchEbookJob` throws. Enabled by default. Returns `{ scanned, gapsFound, triggered, created, retried, skippedInFlight, skippedCancelled, skippedCapHit }`.
|
||||||
7. **cleanup_seeded_torrents** - Default: every 30 mins, deletes torrents after seeding requirements met, respects `seeding_time_minutes` config (0 = never), enabled by default
|
7. **cleanup_seeded_torrents** - Default: every 30 mins, deletes torrents after seeding requirements met. Respects per-indexer `seedingTimeMinutes` AND `ratioLimit` (BOTH required when set; `0` disables that criterion; both `0` = never cleaned up). Undefined ratio with `ratioLimit > 0` = not met (safe-deny). Enabled by default.
|
||||||
8. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against `awaiting_search` requests (audiobook and ebook, limit 100). Query is unchanged — release-date gate is applied AFTER a match is found: if matched book is unreleased + `indexer.skip_unreleased` ON, the match is skipped and request status is NOT mutated (retry job owns transitions). Enabled by default.
|
8. **monitor_rss_feeds** - Default: every 15 mins, checks RSS feeds from enabled indexers, matches against `awaiting_search` requests (audiobook and ebook, limit 100). Query is unchanged — release-date gate is applied AFTER a match is found: if matched book is unreleased + `indexer.skip_unreleased` ON, the match is skipped and request status is NOT mutated (retry job owns transitions). Enabled by default.
|
||||||
|
|
||||||
## Architecture: Bull + Cron
|
## Architecture: Bull + Cron
|
||||||
@@ -157,6 +157,7 @@ interface ScheduledJob {
|
|||||||
- ✅ Failed requests blocking re-requests → allow re-requesting failed/warn/cancelled
|
- ✅ Failed requests blocking re-requests → allow re-requesting failed/warn/cancelled
|
||||||
- ✅ Files deleted immediately → kept until seeding requirements met
|
- ✅ Files deleted immediately → kept until seeding requirements met
|
||||||
- ✅ No seeding time config → added `seeding_time_minutes`
|
- ✅ No seeding time config → added `seeding_time_minutes`
|
||||||
|
- ✅ No ratio-based seeding policy → added per-indexer `ratioLimit` (AND-semantics with `seedingTimeMinutes`; `0` disables; undefined client ratio = safe-deny)
|
||||||
- ✅ Scheduled jobs not running on schedule → implemented Bull repeatable jobs with cron scheduling
|
- ✅ Scheduled jobs not running on schedule → implemented Bull repeatable jobs with cron scheduling
|
||||||
- ✅ MaxListenersExceededWarning → increased maxListeners to 20 on both Redis client and Bull queue
|
- ✅ MaxListenersExceededWarning → increased maxListeners to 20 on both Redis client and Bull queue
|
||||||
- ✅ Cron expressions not user-friendly → added human-readable descriptions and visual schedule builder
|
- ✅ Cron expressions not user-friendly → added human-readable descriptions and visual schedule builder
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ src/app/admin/settings/
|
|||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
**Plex:** Valid HTTP/HTTPS URL, non-empty token, library ID selected
|
**Plex:** Valid HTTP/HTTPS URL, non-empty token, library ID selected
|
||||||
**Prowlarr:** Valid URL, non-empty API key, ≥1 indexer configured, priority 1-25, seedingTimeMinutes ≥0, rssEnabled boolean
|
**Prowlarr:** Valid URL, non-empty API key, ≥1 indexer configured, priority 1-25, seedingTimeMinutes ≥0, ratioLimit ≥0 (torrents only; decimal, `0` = no requirement), rssEnabled boolean
|
||||||
**Download Client:** Valid URL, credentials required, type must be 'qbittorrent', 'transmission', or 'sabnzbd'
|
**Download Client:** Valid URL, credentials required, type must be 'qbittorrent', 'transmission', or 'sabnzbd'
|
||||||
**Paths:** Absolute paths, exist or creatable, writable, cannot be same directory, template must contain `{author}` or `{title}`
|
**Paths:** Absolute paths, exist or creatable, writable, cannot be same directory, template must contain `{author}` or `{title}`
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ export interface IndexerConfig {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
priority: number;
|
priority: number;
|
||||||
seedingTimeMinutes?: number; // Torrents only
|
seedingTimeMinutes?: number; // Torrents only
|
||||||
|
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
|
||||||
removeAfterProcessing?: boolean; // Usenet only
|
removeAfterProcessing?: boolean; // Usenet only
|
||||||
rssEnabled: boolean;
|
rssEnabled: boolean;
|
||||||
audiobookCategories?: number[]; // Category IDs for audiobook searches (default: [3030])
|
audiobookCategories?: number[]; // Category IDs for audiobook searches (default: [3030])
|
||||||
@@ -185,6 +186,7 @@ export interface SavedIndexerConfig {
|
|||||||
protocol: string;
|
protocol: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
seedingTimeMinutes?: number; // Torrents only
|
seedingTimeMinutes?: number; // Torrents only
|
||||||
|
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
|
||||||
removeAfterProcessing?: boolean; // Usenet only
|
removeAfterProcessing?: boolean; // Usenet only
|
||||||
rssEnabled: boolean;
|
rssEnabled: boolean;
|
||||||
audiobookCategories: number[]; // Category IDs for audiobook searches (default: [3030])
|
audiobookCategories: number[]; // Category IDs for audiobook searches (default: [3030])
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface SavedIndexerConfig {
|
|||||||
protocol: string;
|
protocol: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
seedingTimeMinutes?: number; // Torrents only
|
seedingTimeMinutes?: number; // Torrents only
|
||||||
|
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
|
||||||
removeAfterProcessing?: boolean; // Usenet only
|
removeAfterProcessing?: boolean; // Usenet only
|
||||||
rssEnabled?: boolean;
|
rssEnabled?: boolean;
|
||||||
audiobookCategories?: number[]; // Array of category IDs for audiobooks (default: [3030])
|
audiobookCategories?: number[]; // Array of category IDs for audiobooks (default: [3030])
|
||||||
@@ -79,6 +80,7 @@ export async function GET(request: NextRequest) {
|
|||||||
// Add protocol-specific fields
|
// Add protocol-specific fields
|
||||||
if (isTorrent) {
|
if (isTorrent) {
|
||||||
config.seedingTimeMinutes = saved?.seedingTimeMinutes ?? 0;
|
config.seedingTimeMinutes = saved?.seedingTimeMinutes ?? 0;
|
||||||
|
config.ratioLimit = saved?.ratioLimit ?? 0;
|
||||||
} else {
|
} else {
|
||||||
config.removeAfterProcessing = saved?.removeAfterProcessing ?? true;
|
config.removeAfterProcessing = saved?.removeAfterProcessing ?? true;
|
||||||
}
|
}
|
||||||
@@ -134,6 +136,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
const isTorrent = indexer.protocol?.toLowerCase() === 'torrent';
|
const isTorrent = indexer.protocol?.toLowerCase() === 'torrent';
|
||||||
if (isTorrent) {
|
if (isTorrent) {
|
||||||
config.seedingTimeMinutes = indexer.seedingTimeMinutes ?? 0;
|
config.seedingTimeMinutes = indexer.seedingTimeMinutes ?? 0;
|
||||||
|
config.ratioLimit = indexer.ratioLimit ?? 0;
|
||||||
} else {
|
} else {
|
||||||
config.removeAfterProcessing = indexer.removeAfterProcessing ?? true;
|
config.removeAfterProcessing = indexer.removeAfterProcessing ?? true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface SelectedIndexer {
|
|||||||
protocol: string;
|
protocol: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
seedingTimeMinutes?: number; // Torrents only
|
seedingTimeMinutes?: number; // Torrents only
|
||||||
|
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
|
||||||
removeAfterProcessing?: boolean; // Usenet only
|
removeAfterProcessing?: boolean; // Usenet only
|
||||||
rssEnabled: boolean;
|
rssEnabled: boolean;
|
||||||
audiobookCategories: number[]; // Categories for audiobook searches
|
audiobookCategories: number[]; // Categories for audiobook searches
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Component: Indexer Configuration Modal
|
* Component: Indexer Configuration Modal
|
||||||
* Documentation: documentation/frontend/components.md
|
* Documentation: documentation/frontend/components.md
|
||||||
*
|
|
||||||
* Supports separate category configurations for AudioBook and EBook searches
|
|
||||||
* via tabbed interface in the Categories section.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
@@ -13,6 +10,7 @@ import { Modal } from '@/components/ui/Modal';
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { CategoryTreeView } from './CategoryTreeView';
|
import { CategoryTreeView } from './CategoryTreeView';
|
||||||
|
import { TorrentSeedingFields } from './IndexerConfigModalTorrentFields';
|
||||||
import { DEFAULT_AUDIOBOOK_CATEGORIES, DEFAULT_EBOOK_CATEGORIES } from '@/lib/utils/torrent-categories';
|
import { DEFAULT_AUDIOBOOK_CATEGORIES, DEFAULT_EBOOK_CATEGORIES } from '@/lib/utils/torrent-categories';
|
||||||
|
|
||||||
type CategoryTab = 'audiobook' | 'ebook';
|
type CategoryTab = 'audiobook' | 'ebook';
|
||||||
@@ -30,6 +28,7 @@ interface IndexerConfigModalProps {
|
|||||||
initialConfig?: {
|
initialConfig?: {
|
||||||
priority: number;
|
priority: number;
|
||||||
seedingTimeMinutes?: number;
|
seedingTimeMinutes?: number;
|
||||||
|
ratioLimit?: number;
|
||||||
removeAfterProcessing?: boolean;
|
removeAfterProcessing?: boolean;
|
||||||
rssEnabled: boolean;
|
rssEnabled: boolean;
|
||||||
audiobookCategories: number[];
|
audiobookCategories: number[];
|
||||||
@@ -41,6 +40,7 @@ interface IndexerConfigModalProps {
|
|||||||
protocol: string;
|
protocol: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
seedingTimeMinutes?: number;
|
seedingTimeMinutes?: number;
|
||||||
|
ratioLimit?: number;
|
||||||
removeAfterProcessing?: boolean;
|
removeAfterProcessing?: boolean;
|
||||||
rssEnabled: boolean;
|
rssEnabled: boolean;
|
||||||
audiobookCategories: number[];
|
audiobookCategories: number[];
|
||||||
@@ -61,6 +61,7 @@ export function IndexerConfigModal({
|
|||||||
const defaults = {
|
const defaults = {
|
||||||
priority: 10,
|
priority: 10,
|
||||||
seedingTimeMinutes: 0,
|
seedingTimeMinutes: 0,
|
||||||
|
ratioLimit: 0,
|
||||||
removeAfterProcessing: true, // Default to true for Usenet
|
removeAfterProcessing: true, // Default to true for Usenet
|
||||||
rssEnabled: indexer.supportsRss,
|
rssEnabled: indexer.supportsRss,
|
||||||
audiobookCategories: DEFAULT_AUDIOBOOK_CATEGORIES,
|
audiobookCategories: DEFAULT_AUDIOBOOK_CATEGORIES,
|
||||||
@@ -74,6 +75,9 @@ export function IndexerConfigModal({
|
|||||||
const [seedingTimeMinutes, setSeedingTimeMinutes] = useState(
|
const [seedingTimeMinutes, setSeedingTimeMinutes] = useState(
|
||||||
initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes
|
initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes
|
||||||
);
|
);
|
||||||
|
const [ratioLimit, setRatioLimit] = useState(
|
||||||
|
initialConfig?.ratioLimit ?? defaults.ratioLimit
|
||||||
|
);
|
||||||
const [removeAfterProcessing, setRemoveAfterProcessing] = useState(
|
const [removeAfterProcessing, setRemoveAfterProcessing] = useState(
|
||||||
initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing
|
initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing
|
||||||
);
|
);
|
||||||
@@ -89,21 +93,20 @@ export function IndexerConfigModal({
|
|||||||
initialConfig?.ebookCategories ?? defaults.ebookCategories
|
initialConfig?.ebookCategories ?? defaults.ebookCategories
|
||||||
);
|
);
|
||||||
|
|
||||||
// Tab state for categories
|
|
||||||
const [activeTab, setActiveTab] = useState<CategoryTab>('audiobook');
|
const [activeTab, setActiveTab] = useState<CategoryTab>('audiobook');
|
||||||
|
|
||||||
// Validation errors
|
|
||||||
const [errors, setErrors] = useState<{
|
const [errors, setErrors] = useState<{
|
||||||
priority?: string;
|
priority?: string;
|
||||||
seedingTimeMinutes?: string;
|
seedingTimeMinutes?: string;
|
||||||
|
ratioLimit?: string;
|
||||||
}>({});
|
}>({});
|
||||||
|
|
||||||
// Reset form when modal opens or indexer changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
if (mode === 'add') {
|
if (mode === 'add') {
|
||||||
setPriority(defaults.priority);
|
setPriority(defaults.priority);
|
||||||
setSeedingTimeMinutes(defaults.seedingTimeMinutes);
|
setSeedingTimeMinutes(defaults.seedingTimeMinutes);
|
||||||
|
setRatioLimit(defaults.ratioLimit);
|
||||||
setRemoveAfterProcessing(defaults.removeAfterProcessing);
|
setRemoveAfterProcessing(defaults.removeAfterProcessing);
|
||||||
setRssEnabled(defaults.rssEnabled);
|
setRssEnabled(defaults.rssEnabled);
|
||||||
setAudiobookCategories(defaults.audiobookCategories);
|
setAudiobookCategories(defaults.audiobookCategories);
|
||||||
@@ -111,6 +114,7 @@ export function IndexerConfigModal({
|
|||||||
} else {
|
} else {
|
||||||
setPriority(initialConfig?.priority ?? defaults.priority);
|
setPriority(initialConfig?.priority ?? defaults.priority);
|
||||||
setSeedingTimeMinutes(initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes);
|
setSeedingTimeMinutes(initialConfig?.seedingTimeMinutes ?? defaults.seedingTimeMinutes);
|
||||||
|
setRatioLimit(initialConfig?.ratioLimit ?? defaults.ratioLimit);
|
||||||
setRemoveAfterProcessing(initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing);
|
setRemoveAfterProcessing(initialConfig?.removeAfterProcessing ?? defaults.removeAfterProcessing);
|
||||||
setRssEnabled(initialConfig?.rssEnabled ?? defaults.rssEnabled);
|
setRssEnabled(initialConfig?.rssEnabled ?? defaults.rssEnabled);
|
||||||
setAudiobookCategories(initialConfig?.audiobookCategories ?? defaults.audiobookCategories);
|
setAudiobookCategories(initialConfig?.audiobookCategories ?? defaults.audiobookCategories);
|
||||||
@@ -132,6 +136,10 @@ export function IndexerConfigModal({
|
|||||||
newErrors.seedingTimeMinutes = 'Seeding time cannot be negative';
|
newErrors.seedingTimeMinutes = 'Seeding time cannot be negative';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isTorrent && ratioLimit < 0) {
|
||||||
|
newErrors.ratioLimit = 'Ratio limit cannot be negative';
|
||||||
|
}
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
};
|
};
|
||||||
@@ -154,6 +162,7 @@ export function IndexerConfigModal({
|
|||||||
// Add protocol-specific fields
|
// Add protocol-specific fields
|
||||||
if (isTorrent) {
|
if (isTorrent) {
|
||||||
config.seedingTimeMinutes = seedingTimeMinutes;
|
config.seedingTimeMinutes = seedingTimeMinutes;
|
||||||
|
config.ratioLimit = ratioLimit;
|
||||||
} else {
|
} else {
|
||||||
config.removeAfterProcessing = removeAfterProcessing;
|
config.removeAfterProcessing = removeAfterProcessing;
|
||||||
}
|
}
|
||||||
@@ -183,6 +192,17 @@ export function IndexerConfigModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRatioLimitChange = (value: string) => {
|
||||||
|
if (value === '') {
|
||||||
|
setRatioLimit(0);
|
||||||
|
} else {
|
||||||
|
const parsed = parseFloat(value);
|
||||||
|
if (!isNaN(parsed)) {
|
||||||
|
setRatioLimit(Math.max(0, parsed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Get the current categories based on active tab
|
// Get the current categories based on active tab
|
||||||
const currentCategories = activeTab === 'audiobook' ? audiobookCategories : ebookCategories;
|
const currentCategories = activeTab === 'audiobook' ? audiobookCategories : ebookCategories;
|
||||||
const setCurrentCategories = activeTab === 'audiobook' ? setAudiobookCategories : setEbookCategories;
|
const setCurrentCategories = activeTab === 'audiobook' ? setAudiobookCategories : setEbookCategories;
|
||||||
@@ -238,30 +258,15 @@ export function IndexerConfigModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Seeding Time (Torrents only) */}
|
{/* Seeding Time + Ratio Limit (Torrents only) */}
|
||||||
{isTorrent && (
|
{isTorrent && (
|
||||||
<div>
|
<TorrentSeedingFields
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
seedingTimeMinutes={seedingTimeMinutes}
|
||||||
Seeding Time (minutes)
|
ratioLimit={ratioLimit}
|
||||||
</label>
|
errors={errors}
|
||||||
<Input
|
onSeedingTimeChange={handleSeedingTimeChange}
|
||||||
type="number"
|
onRatioLimitChange={handleRatioLimitChange}
|
||||||
min="0"
|
/>
|
||||||
step="1"
|
|
||||||
value={seedingTimeMinutes}
|
|
||||||
onChange={(e) => handleSeedingTimeChange(e.target.value)}
|
|
||||||
placeholder="0"
|
|
||||||
className={errors.seedingTimeMinutes ? 'border-red-500' : ''}
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
0 = unlimited seeding (files remain seeded indefinitely)
|
|
||||||
</p>
|
|
||||||
{errors.seedingTimeMinutes && (
|
|
||||||
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
|
|
||||||
{errors.seedingTimeMinutes}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Remove After Processing (Usenet only) */}
|
{/* Remove After Processing (Usenet only) */}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Component: Indexer Config Modal — Torrent Seeding Fields
|
||||||
|
* Documentation: documentation/frontend/components.md
|
||||||
|
*
|
||||||
|
* Renders the torrent-only "Seeding Time" + "Ratio Limit" inputs used by
|
||||||
|
* IndexerConfigModal. Extracted to keep the parent modal under 400 lines.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
|
||||||
|
export interface TorrentSeedingFieldsProps {
|
||||||
|
seedingTimeMinutes: number;
|
||||||
|
ratioLimit: number;
|
||||||
|
errors: { seedingTimeMinutes?: string; ratioLimit?: string };
|
||||||
|
onSeedingTimeChange: (value: string) => void;
|
||||||
|
onRatioLimitChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TorrentSeedingFields({
|
||||||
|
seedingTimeMinutes,
|
||||||
|
ratioLimit,
|
||||||
|
errors,
|
||||||
|
onSeedingTimeChange,
|
||||||
|
onRatioLimitChange,
|
||||||
|
}: TorrentSeedingFieldsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Seeding Time (minutes)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={seedingTimeMinutes}
|
||||||
|
onChange={(e) => onSeedingTimeChange(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
className={errors.seedingTimeMinutes ? 'border-red-500' : ''}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
0 = unlimited seeding (files remain seeded indefinitely)
|
||||||
|
</p>
|
||||||
|
{errors.seedingTimeMinutes && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||||
|
{errors.seedingTimeMinutes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Ratio Limit
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
value={ratioLimit}
|
||||||
|
onChange={(e) => onRatioLimitChange(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
className={errors.ratioLimit ? 'border-red-500' : ''}
|
||||||
|
/>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Minimum upload/download ratio before files are cleaned up. 0 = no ratio requirement.
|
||||||
|
</p>
|
||||||
|
{errors.ratioLimit && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400 mt-1">
|
||||||
|
{errors.ratioLimit}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ interface SavedIndexerConfig {
|
|||||||
protocol: string;
|
protocol: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
seedingTimeMinutes?: number; // Torrents only
|
seedingTimeMinutes?: number; // Torrents only
|
||||||
|
ratioLimit?: number; // Torrents only (0 = no ratio requirement)
|
||||||
removeAfterProcessing?: boolean; // Usenet only
|
removeAfterProcessing?: boolean; // Usenet only
|
||||||
rssEnabled: boolean;
|
rssEnabled: boolean;
|
||||||
audiobookCategories: number[]; // Categories for audiobook searches
|
audiobookCategories: number[]; // Categories for audiobook searches
|
||||||
|
|||||||
@@ -133,8 +133,12 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
|||||||
// Find matching indexer configuration by name
|
// Find matching indexer configuration by name
|
||||||
const seedingConfig = indexerConfigMap.get(indexerName);
|
const seedingConfig = indexerConfigMap.get(indexerName);
|
||||||
|
|
||||||
// If no config found or seeding time is 0 (unlimited)
|
// Per-indexer thresholds. 0 disables that criterion; both 0 = unlimited.
|
||||||
if (!seedingConfig || seedingConfig.seedingTimeMinutes === 0) {
|
const seedingMin: number = seedingConfig?.seedingTimeMinutes ?? 0;
|
||||||
|
const ratioMin: number = seedingConfig?.ratioLimit ?? 0;
|
||||||
|
|
||||||
|
// If no config found or both criteria are 0 (unlimited)
|
||||||
|
if (!seedingConfig || (seedingMin === 0 && ratioMin === 0)) {
|
||||||
// For soft-deleted requests with unlimited seeding, hard delete immediately
|
// For soft-deleted requests with unlimited seeding, hard delete immediately
|
||||||
if (request.deletedAt) {
|
if (request.deletedAt) {
|
||||||
await prisma.request.delete({ where: { id: request.id } });
|
await prisma.request.delete({ where: { id: request.id } });
|
||||||
@@ -144,7 +148,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
|
const seedingTimeSeconds = seedingMin * 60;
|
||||||
|
|
||||||
// Skip if this torrent was already deleted earlier in this run
|
// Skip if this torrent was already deleted earlier in this run
|
||||||
if (deletedHashes.has(clientId.toLowerCase())) {
|
if (deletedHashes.has(clientId.toLowerCase())) {
|
||||||
@@ -178,17 +182,30 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if seeding time requirement is met
|
// Check seeding requirements: AND-semantics across time and ratio.
|
||||||
const actualSeedingTime = downloadInfo.seedingTime || 0;
|
// Each criterion is "met" when disabled (0) or when actual meets/exceeds it.
|
||||||
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
|
// Undefined ratio with ratioMin > 0 is treated as not-met (safe-deny).
|
||||||
|
const actualSeedingTime = downloadInfo.seedingTime ?? 0;
|
||||||
|
const actualRatio = downloadInfo.ratio;
|
||||||
|
const timeMet = seedingMin === 0 || actualSeedingTime >= seedingTimeSeconds;
|
||||||
|
const ratioMet = ratioMin === 0 || (typeof actualRatio === 'number' && actualRatio >= ratioMin);
|
||||||
|
const hasMetRequirement = timeMet && ratioMet;
|
||||||
|
|
||||||
|
const ratioPart = ratioMin === 0
|
||||||
|
? '--/--'
|
||||||
|
: `${(typeof actualRatio === 'number' ? actualRatio : 0).toFixed(2)}/${ratioMin.toFixed(2)}`;
|
||||||
|
const timePart = seedingMin === 0
|
||||||
|
? '--/--'
|
||||||
|
: `${Math.floor(actualSeedingTime / 60)}/${seedingMin}`;
|
||||||
|
const progress = `ratio ${ratioPart}, time ${timePart} min`;
|
||||||
|
|
||||||
if (!hasMetRequirement) {
|
if (!hasMetRequirement) {
|
||||||
const remaining = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60);
|
logger.debug(`Download ${downloadInfo.name} (${indexerName}) still seeding: ${progress}`);
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Download ${downloadInfo.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
|
logger.info(`Download ${downloadInfo.name} (${indexerName}) has met seeding requirement: ${progress}`);
|
||||||
|
|
||||||
// CRITICAL: Check if any other active (non-deleted) request is using this same download
|
// CRITICAL: Check if any other active (non-deleted) request is using this same download
|
||||||
const hashToCheck = downloadHistory.torrentHash;
|
const hashToCheck = downloadHistory.torrentHash;
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ describe('Admin Prowlarr indexers route', () => {
|
|||||||
|
|
||||||
it('saves indexer configuration', async () => {
|
it('saves indexer configuration', async () => {
|
||||||
authRequest.json.mockResolvedValue({
|
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: [],
|
flagConfigs: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,6 +63,11 @@ describe('Admin Prowlarr indexers route', () => {
|
|||||||
|
|
||||||
expect(payload.success).toBe(true);
|
expect(payload.success).toBe(true);
|
||||||
expect(configServiceMock.setMany).toHaveBeenCalled();
|
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',
|
name: 'Indexer',
|
||||||
priority: 10,
|
priority: 10,
|
||||||
seedingTimeMinutes: 0,
|
seedingTimeMinutes: 0,
|
||||||
|
ratioLimit: 1.5,
|
||||||
rssEnabled: true,
|
rssEnabled: true,
|
||||||
categories: [],
|
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' } });
|
fireEvent.change(priorityInput, { target: { value: '99' } });
|
||||||
expect(priorityInput).toHaveValue(25);
|
expect(priorityInput).toHaveValue(25);
|
||||||
@@ -32,6 +32,12 @@ describe('IndexerConfigModal', () => {
|
|||||||
fireEvent.change(seedingInput, { target: { value: '-5' } });
|
fireEvent.change(seedingInput, { target: { value: '-5' } });
|
||||||
expect(seedingInput).toHaveValue(0);
|
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');
|
const rssToggle = screen.getByRole('checkbox');
|
||||||
fireEvent.click(rssToggle);
|
fireEvent.click(rssToggle);
|
||||||
|
|
||||||
@@ -43,6 +49,7 @@ describe('IndexerConfigModal', () => {
|
|||||||
name: 'Prowlarr',
|
name: 'Prowlarr',
|
||||||
priority: 25,
|
priority: 25,
|
||||||
seedingTimeMinutes: 0,
|
seedingTimeMinutes: 0,
|
||||||
|
ratioLimit: 1.5,
|
||||||
rssEnabled: false,
|
rssEnabled: false,
|
||||||
audiobookCategories: expect.arrayContaining([3030]),
|
audiobookCategories: expect.arrayContaining([3030]),
|
||||||
ebookCategories: expect.arrayContaining([7020]),
|
ebookCategories: expect.arrayContaining([7020]),
|
||||||
|
|||||||
@@ -217,6 +217,251 @@ describe('processCleanupSeededTorrents', () => {
|
|||||||
expect(qbtClientMock.deleteDownload).toHaveBeenCalledWith('hash-ebook-1', true);
|
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 () => {
|
it('detects shared torrents across audiobook and ebook requests', async () => {
|
||||||
configMock.get.mockResolvedValue(
|
configMock.get.mockResolvedValue(
|
||||||
JSON.stringify([{ name: 'SharedIndexer', seedingTimeMinutes: 10 }])
|
JSON.stringify([{ name: 'SharedIndexer', seedingTimeMinutes: 10 }])
|
||||||
|
|||||||
Reference in New Issue
Block a user