/** * Component: Cleanup Seeded Torrents Processor * Documentation: documentation/backend/services/scheduler.md * * Cleans up downloads that have met their seeding requirements. * Uses the IDownloadClient interface for client-agnostic operation. */ import { prisma } from '../db'; import { RMABLogger } from '../utils/logger'; import { CLIENT_PROTOCOL_MAP, DownloadClientType } from '../interfaces/download-client.interface'; export interface CleanupSeededTorrentsPayload { jobId?: string; scheduledJobId?: string; } export async function processCleanupSeededTorrents(payload: CleanupSeededTorrentsPayload): Promise { const { jobId, scheduledJobId } = payload; const logger = RMABLogger.forJob(jobId, 'CleanupSeededTorrents'); logger.info('Starting cleanup job for seeded torrents...'); try { // Get indexer configuration with per-indexer seeding times const { getConfigService } = await import('../services/config.service'); const { getDownloadClientManager } = await import('../services/download-client-manager.service'); const configService = getConfigService(); const manager = getDownloadClientManager(configService); const indexersConfigStr = await configService.get('prowlarr_indexers'); if (!indexersConfigStr) { logger.warn('No indexer configuration found, skipping'); return { success: false, message: 'No indexer configuration', skipped: true, }; } const indexersConfig = JSON.parse(indexersConfigStr); // Create a map of indexer name to config for quick lookup const indexerConfigMap = new Map(); for (const indexer of indexersConfig) { indexerConfigMap.set(indexer.name, indexer); } logger.info(`Loaded configuration for ${indexerConfigMap.size} indexers`); // Find all completed requests + soft-deleted requests (orphaned downloads) // IMPORTANT: Only cleanup requests that are truly complete and not being actively processed // NOTE: Multiple requests can share the same torrent hash (e.g., re-requesting same audiobook) // Before deleting torrent, we check if other active requests are using it // NOTE: Ebooks downloaded via indexer search use torrent clients and need seeding cleanup too. // Direct HTTP ebook downloads are naturally skipped (no torrent hash / unknown client type). const completedRequests = await prisma.request.findMany({ where: { OR: [ // Audiobook requests that are fully available (matched in Plex/ABS) { type: 'audiobook', status: 'available', deletedAt: null, }, // Ebook requests that are fully downloaded (terminal state for ebooks) { type: 'ebook', status: 'downloaded', deletedAt: null, }, // Soft-deleted requests of any type (orphaned downloads) { deletedAt: { not: null }, }, ], }, include: { downloadHistory: { where: { selected: true, downloadStatus: 'completed', }, orderBy: { completedAt: 'desc' }, take: 1, }, }, take: 100, // Limit to 100 requests per run }); logger.info(`Found ${completedRequests.length} requests to check (audiobook: available, ebook: downloaded, or soft-deleted)`); let cleaned = 0; let skipped = 0; let noConfig = 0; const deletedHashes = new Set(); // Track torrents already deleted this run for (const request of completedRequests) { try { const downloadHistory = request.downloadHistory[0]; if (!downloadHistory || !downloadHistory.indexerName) { continue; } // Skip Usenet downloads - no seeding concept if (downloadHistory.nzbId && !downloadHistory.torrentHash) { // For soft-deleted Usenet requests, hard delete immediately (no seeding needed) if (request.deletedAt) { await prisma.request.delete({ where: { id: request.id } }); logger.info(`Hard-deleted orphaned Usenet request ${request.id}`); } continue; } // Only process downloads that have a client ID if (!downloadHistory.downloadClientId && !downloadHistory.torrentHash) { continue; } // Determine the download client ID and protocol const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash!; const clientType = downloadHistory.downloadClient || 'qbittorrent'; const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType]; if (!protocol) { logger.warn(`Unknown download client type: ${clientType}, skipping`); continue; } // Get the indexer name from download history const indexerName = downloadHistory.indexerName; // Find matching indexer configuration by name const seedingConfig = indexerConfigMap.get(indexerName); // If no config found or seeding time is 0 (unlimited) if (!seedingConfig || seedingConfig.seedingTimeMinutes === 0) { // For soft-deleted requests with unlimited seeding, hard delete immediately if (request.deletedAt) { await prisma.request.delete({ where: { id: request.id } }); logger.info(`Hard-deleted orphaned request ${request.id} with unlimited seeding`); } noConfig++; continue; } const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60; // Skip if this torrent was already deleted earlier in this run if (deletedHashes.has(clientId.toLowerCase())) { if (request.deletedAt) { await prisma.request.delete({ where: { id: request.id } }); logger.info(`Hard-deleted orphaned request ${request.id} (torrent already cleaned this run)`); } cleaned++; continue; } // Get download info from the appropriate client via the interface const client = await manager.getClientServiceForProtocol(protocol as 'torrent' | 'usenet'); if (!client) { logger.warn(`No ${clientType} client configured, skipping request ${request.id}`); skipped++; continue; } let downloadInfo; try { downloadInfo = await client.getDownload(clientId); } catch (error) { // Download not found in client (already removed), skip continue; } if (!downloadInfo) { // Download not found in client (already removed) continue; } // Check if seeding time requirement is met const actualSeedingTime = downloadInfo.seedingTime || 0; const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds; if (!hasMetRequirement) { const remaining = Math.ceil((seedingTimeSeconds - actualSeedingTime) / 60); skipped++; continue; } logger.info(`Download ${downloadInfo.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`); // CRITICAL: Check if any other active (non-deleted) request is using this same download const hashToCheck = downloadHistory.torrentHash; if (hashToCheck) { const otherActiveRequests = await prisma.request.findMany({ where: { id: { not: request.id }, // Exclude current request deletedAt: null, // Only check active requests downloadHistory: { some: { torrentHash: hashToCheck, selected: true, }, }, }, select: { id: true, status: true }, }); if (otherActiveRequests.length > 0) { logger.info(`Skipping download deletion - ${otherActiveRequests.length} other active request(s) still using this download (IDs: ${otherActiveRequests.map(r => r.id).join(', ')})`); // If this is a soft-deleted request, hard delete it but DON'T delete the download if (request.deletedAt) { await prisma.request.delete({ where: { id: request.id } }); logger.info(`Hard-deleted orphaned request ${request.id} (kept shared download for active requests)`); } skipped++; continue; } } // Safe to delete - no other active requests using this download await client.deleteDownload(clientId, true); // true = delete files deletedHashes.add(clientId.toLowerCase()); // If this is a soft-deleted request (orphaned download), hard delete it now if (request.deletedAt) { await prisma.request.delete({ where: { id: request.id } }); logger.info(`Hard-deleted orphaned request ${request.id} after download cleanup`); } else { logger.info(`Deleted download and files for active request ${request.id}`); } cleaned++; } catch (error) { logger.error(`Failed to cleanup request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`); } } logger.info(`Cleanup complete: ${cleaned} downloads cleaned, ${skipped} still seeding, ${noConfig} unlimited`); return { success: true, message: 'Cleanup seeded torrents completed', totalChecked: completedRequests.length, cleaned, skipped, unlimited: noConfig, }; } catch (error) { logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } }