mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
4b90b35748
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.
239 lines
9.3 KiB
TypeScript
239 lines
9.3 KiB
TypeScript
/**
|
|
* Component: Retry Failed Imports Processor
|
|
* Documentation: documentation/backend/services/scheduler.md
|
|
*
|
|
* Retries file organization for requests that are awaiting import.
|
|
* Uses the IDownloadClient interface for client-agnostic path resolution.
|
|
*/
|
|
|
|
import path from 'path';
|
|
import { prisma } from '../db';
|
|
import { RMABLogger } from '../utils/logger';
|
|
import { getJobQueueService } from '../services/job-queue.service';
|
|
import { getConfigService } from '../services/config.service';
|
|
import { getDownloadClientManager, DownloadClientManager } from '../services/download-client-manager.service';
|
|
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
|
|
import { CLIENT_PROTOCOL_MAP, DownloadClientType, ProtocolType } from '../interfaces/download-client.interface';
|
|
|
|
export interface RetryFailedImportsPayload {
|
|
jobId?: string;
|
|
scheduledJobId?: string;
|
|
}
|
|
|
|
export async function processRetryFailedImports(payload: RetryFailedImportsPayload): Promise<any> {
|
|
const { jobId, scheduledJobId } = payload;
|
|
const logger = RMABLogger.forJob(jobId, 'RetryFailedImports');
|
|
|
|
logger.info('Starting retry job for requests awaiting import...');
|
|
|
|
try {
|
|
// Initialize config and download client manager
|
|
const configService = getConfigService();
|
|
const manager = getDownloadClientManager(configService);
|
|
|
|
// Helper function to get path mapping config for a specific download client type
|
|
const getPathMappingForClient = async (clientType: string): Promise<PathMappingConfig> => {
|
|
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent';
|
|
const clientConfig = await manager.getClientForProtocol(protocol);
|
|
|
|
if (clientConfig && clientConfig.remotePathMappingEnabled) {
|
|
return {
|
|
enabled: true,
|
|
remotePath: clientConfig.remotePath || '',
|
|
localPath: clientConfig.localPath || '',
|
|
};
|
|
}
|
|
return { enabled: false, remotePath: '', localPath: '' };
|
|
};
|
|
|
|
// Find all requests in awaiting_import status (both audiobook and ebook)
|
|
// The organize_files processor handles both types with type-based branching
|
|
const requests = await prisma.request.findMany({
|
|
where: {
|
|
status: 'awaiting_import',
|
|
deletedAt: null,
|
|
},
|
|
include: {
|
|
audiobook: true,
|
|
downloadHistory: {
|
|
where: { selected: true },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 1,
|
|
},
|
|
},
|
|
take: 50, // Limit to 50 requests per run
|
|
});
|
|
|
|
logger.info(`Found ${requests.length} requests awaiting import`);
|
|
|
|
if (requests.length === 0) {
|
|
return {
|
|
success: true,
|
|
message: 'No requests awaiting import',
|
|
triggered: 0,
|
|
};
|
|
}
|
|
|
|
// Trigger organize job for each request
|
|
const jobQueue = getJobQueueService();
|
|
let triggered = 0;
|
|
let skipped = 0;
|
|
|
|
for (const request of requests) {
|
|
try {
|
|
// Get the download path from the most recent download history
|
|
const downloadHistory = request.downloadHistory[0];
|
|
|
|
if (!downloadHistory) {
|
|
logger.warn(`No download history found for request ${request.id}, skipping`);
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
let downloadPath: string;
|
|
|
|
// Get path mapping for this specific download client
|
|
const clientType = downloadHistory.downloadClient || 'qbittorrent';
|
|
|
|
// Direct downloads (e.g. Anna's Archive ebooks) have no external download client
|
|
// Use stored path or construct from download_dir directly
|
|
if (clientType === 'direct') {
|
|
const noMapping: PathMappingConfig = { enabled: false, remotePath: '', localPath: '' };
|
|
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, noMapping, request.id, logger);
|
|
} else {
|
|
// Real download client — resolve path via client API with path mapping
|
|
const mappingConfig = await getPathMappingForClient(clientType);
|
|
const clientId = downloadHistory.downloadClientId || downloadHistory.torrentHash || downloadHistory.nzbId;
|
|
|
|
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] as ProtocolType | undefined;
|
|
if (!protocol) {
|
|
logger.warn(`Unknown download client type: ${clientType} for request ${request.id}, skipping`);
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
if (clientId) {
|
|
// Try to get path from download client via unified interface
|
|
const client = await manager.getClientServiceForProtocol(protocol);
|
|
|
|
if (client) {
|
|
try {
|
|
const info = await client.getDownload(clientId);
|
|
if (info?.downloadPath) {
|
|
downloadPath = PathMapper.transform(info.downloadPath, mappingConfig);
|
|
logger.info(
|
|
`Got download path from ${client.clientType} for request ${request.id}: ${info.downloadPath}` +
|
|
(downloadPath !== info.downloadPath ? ` → ${downloadPath} (mapped)` : '')
|
|
);
|
|
} else {
|
|
// Download found but no path — try stored path, then fallback
|
|
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
|
|
}
|
|
} catch (clientError) {
|
|
// Client error — try stored path, then fallback
|
|
logger.warn(`${client.clientType} error for request ${request.id}: ${clientError instanceof Error ? clientError.message : 'Unknown error'}, using fallback path`);
|
|
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
|
|
}
|
|
} else {
|
|
// No client configured — try stored path, then fallback
|
|
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
|
|
}
|
|
} else {
|
|
// No client ID — try stored path, then fallback
|
|
downloadPath = getStoredPath(downloadHistory, request.id, logger) || await getFallbackPath(downloadHistory, configService, mappingConfig, request.id, logger, manager, protocol);
|
|
}
|
|
}
|
|
|
|
// Check if we got a valid path (getFallbackPath returns empty string on failure)
|
|
if (!downloadPath) {
|
|
skipped++;
|
|
continue;
|
|
}
|
|
|
|
await jobQueue.addOrganizeJob(
|
|
request.id,
|
|
request.audiobook.id,
|
|
downloadPath
|
|
);
|
|
triggered++;
|
|
logger.info(`Triggered organize job for ${request.type || 'audiobook'} request ${request.id}: ${request.audiobook.title}`);
|
|
} catch (error) {
|
|
logger.error(`Failed to trigger organize for request ${request.id}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
skipped++;
|
|
}
|
|
}
|
|
|
|
logger.info(`Triggered ${triggered}/${requests.length} organize jobs (${skipped} skipped)`);
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Retry failed imports completed',
|
|
totalRequests: requests.length,
|
|
triggered,
|
|
skipped,
|
|
};
|
|
} catch (error) {
|
|
logger.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return the stored download path from the database (saved at download completion time).
|
|
* Returns empty string if not available (old records won't have this field).
|
|
*/
|
|
function getStoredPath(
|
|
downloadHistory: { downloadPath?: string | null },
|
|
requestId: string,
|
|
logger: RMABLogger
|
|
): string {
|
|
if (downloadHistory.downloadPath) {
|
|
logger.info(`Using stored download path for request ${requestId}: ${downloadHistory.downloadPath}`);
|
|
return downloadHistory.downloadPath;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Construct a fallback download path from config when the download client can't provide one.
|
|
* Returns empty string if path cannot be determined (caller should skip the request).
|
|
*/
|
|
async function getFallbackPath(
|
|
downloadHistory: { torrentName: string | null },
|
|
configService: any,
|
|
mappingConfig: PathMappingConfig,
|
|
requestId: string,
|
|
logger: RMABLogger,
|
|
manager?: DownloadClientManager,
|
|
protocol?: ProtocolType
|
|
): Promise<string> {
|
|
if (!downloadHistory.torrentName) {
|
|
logger.warn(`No download name stored for request ${requestId}, cannot construct fallback path, skipping`);
|
|
return '';
|
|
}
|
|
|
|
const baseDir = await configService.get('download_dir');
|
|
|
|
if (!baseDir) {
|
|
logger.error(`download_dir not configured, cannot retry request ${requestId}, skipping`);
|
|
return '';
|
|
}
|
|
|
|
// Resolve customPath from the client config if available
|
|
let downloadDir = baseDir;
|
|
if (manager && protocol) {
|
|
const clientConfig = await manager.getClientForProtocol(protocol);
|
|
if (clientConfig?.customPath) {
|
|
downloadDir = path.join(baseDir, clientConfig.customPath);
|
|
}
|
|
}
|
|
|
|
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
|
const mappedPath = PathMapper.transform(fallbackPath, mappingConfig);
|
|
logger.info(
|
|
`Using fallback download path for request ${requestId}: ${fallbackPath}` +
|
|
(mappedPath !== fallbackPath ? ` → ${mappedPath} (mapped)` : '')
|
|
);
|
|
return mappedPath;
|
|
}
|