mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-07-14 00:31:11 +00:00
Add Transmission/NZBGet and per-client paths and much more
Extend multi-download-client support to include Transmission and NZBGet and introduce per-client custom download paths. Adds protocol mapping and new client types, Transmission/NZBGet integration services, API CRUD and validation changes, UI components/modal updates and live path previews, and manager routing by protocol. Includes DB migrations (download_path on download_history, interactive_search_access on users), schema updates, and related processor/service fixes and tests to ensure backward compatibility and proper path resolution.
This commit is contained in:
@@ -2,11 +2,13 @@
|
||||
* Component: Cleanup Seeded Torrents Processor
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*
|
||||
* Cleans up torrents that have met their seeding requirements
|
||||
* 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;
|
||||
@@ -22,7 +24,9 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
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) {
|
||||
@@ -44,22 +48,28 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
|
||||
logger.info(`Loaded configuration for ${indexerConfigMap.size} indexers`);
|
||||
|
||||
// Find all completed audiobook requests + soft-deleted audiobook requests (orphaned downloads)
|
||||
// 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: Ebook requests use direct HTTP downloads (no torrent seeding), so they're excluded
|
||||
// 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: {
|
||||
type: 'audiobook', // Only audiobook requests (ebooks don't have torrents to seed)
|
||||
OR: [
|
||||
// Active requests that are fully available (scanned by Plex/ABS)
|
||||
// Audiobook requests that are fully available (matched in Plex/ABS)
|
||||
{
|
||||
type: 'audiobook',
|
||||
status: 'available',
|
||||
deletedAt: null,
|
||||
},
|
||||
// Soft-deleted requests (orphaned downloads)
|
||||
// We'll check if torrent is shared with active requests before deletion
|
||||
// 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 },
|
||||
},
|
||||
@@ -78,11 +88,12 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
take: 100, // Limit to 100 requests per run
|
||||
});
|
||||
|
||||
logger.info(`Found ${completedRequests.length} requests to check (status: 'available' or soft-deleted)`);
|
||||
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<string>(); // Track torrents already deleted this run
|
||||
|
||||
for (const request of completedRequests) {
|
||||
try {
|
||||
@@ -92,18 +103,27 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip SABnzbd downloads - Usenet doesn't have seeding concept
|
||||
// Skip Usenet downloads - no seeding concept
|
||||
if (downloadHistory.nzbId && !downloadHistory.torrentHash) {
|
||||
// For soft-deleted SABnzbd requests, hard delete immediately (no seeding needed)
|
||||
// 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 SABnzbd request ${request.id}`);
|
||||
logger.info(`Hard-deleted orphaned Usenet request ${request.id}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only process torrent downloads
|
||||
if (!downloadHistory.torrentHash) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -126,20 +146,40 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
|
||||
const seedingTimeSeconds = seedingConfig.seedingTimeMinutes * 60;
|
||||
|
||||
// Get torrent info from qBittorrent to check seeding time
|
||||
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
|
||||
const qbt = await getQBittorrentService();
|
||||
// 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;
|
||||
}
|
||||
|
||||
let torrent;
|
||||
// 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 {
|
||||
torrent = await qbt.getTorrent(downloadHistory.torrentHash);
|
||||
downloadInfo = await client.getDownload(clientId);
|
||||
} catch (error) {
|
||||
// Torrent might already be deleted, skip
|
||||
// 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 = torrent.seeding_time || 0;
|
||||
const actualSeedingTime = downloadInfo.seedingTime || 0;
|
||||
const hasMetRequirement = actualSeedingTime >= seedingTimeSeconds;
|
||||
|
||||
if (!hasMetRequirement) {
|
||||
@@ -148,47 +188,49 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info(`Torrent ${torrent.name} (${indexerName}) has met seeding requirement (${Math.floor(actualSeedingTime / 60)}/${seedingConfig.seedingTimeMinutes} minutes)`);
|
||||
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) audiobook request is using this same torrent hash
|
||||
// This prevents deleting shared torrents when user re-requests the same audiobook
|
||||
const otherActiveRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
id: { not: request.id }, // Exclude current request
|
||||
type: 'audiobook', // Only check audiobook requests
|
||||
deletedAt: null, // Only check active requests
|
||||
downloadHistory: {
|
||||
some: {
|
||||
torrentHash: downloadHistory.torrentHash,
|
||||
selected: true,
|
||||
// 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 },
|
||||
});
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
if (otherActiveRequests.length > 0) {
|
||||
logger.info(`Skipping torrent deletion - ${otherActiveRequests.length} other active request(s) still using this torrent (IDs: ${otherActiveRequests.map(r => r.id).join(', ')})`);
|
||||
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 torrent
|
||||
if (request.deletedAt) {
|
||||
await prisma.request.delete({ where: { id: request.id } });
|
||||
logger.info(`Hard-deleted orphaned request ${request.id} (kept shared torrent for active requests)`);
|
||||
// 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;
|
||||
}
|
||||
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Safe to delete - no other active requests using this torrent
|
||||
await qbt.deleteTorrent(downloadHistory.torrentHash, true); // true = delete files
|
||||
// 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 torrent cleanup`);
|
||||
logger.info(`Hard-deleted orphaned request ${request.id} after download cleanup`);
|
||||
} else {
|
||||
logger.info(`Deleted torrent and files for active request ${request.id}`);
|
||||
logger.info(`Deleted download and files for active request ${request.id}`);
|
||||
}
|
||||
|
||||
cleaned++;
|
||||
@@ -197,7 +239,7 @@ export async function processCleanupSeededTorrents(payload: CleanupSeededTorrent
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Cleanup complete: ${cleaned} torrents cleaned, ${skipped} still seeding, ${noConfig} unlimited`);
|
||||
logger.info(`Cleanup complete: ${cleaned} downloads cleaned, ${skipped} still seeding, ${noConfig} unlimited`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
Reference in New Issue
Block a user