mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +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,15 +2,18 @@
|
||||
* Component: Retry Failed Imports Processor
|
||||
* Documentation: documentation/backend/services/scheduler.md
|
||||
*
|
||||
* Retries file organization for requests that are awaiting import
|
||||
* 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 } from '../services/download-client-manager.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;
|
||||
@@ -30,7 +33,7 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
|
||||
// Helper function to get path mapping config for a specific download client type
|
||||
const getPathMappingForClient = async (clientType: string): Promise<PathMappingConfig> => {
|
||||
const protocol = clientType === 'sabnzbd' ? 'usenet' : 'torrent';
|
||||
const protocol = CLIENT_PROTOCOL_MAP[clientType as DownloadClientType] || 'torrent';
|
||||
const clientConfig = await manager.getClientForProtocol(protocol);
|
||||
|
||||
if (clientConfig && clientConfig.remotePathMappingEnabled) {
|
||||
@@ -43,11 +46,10 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
return { enabled: false, remotePath: '', localPath: '' };
|
||||
};
|
||||
|
||||
// Find all active audiobook requests in awaiting_import status
|
||||
// Note: Ebook requests use the same organize_files processor but with type branching
|
||||
// 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: {
|
||||
type: 'audiobook', // Only audiobook requests (ebooks handled by same processor but different flow)
|
||||
status: 'awaiting_import',
|
||||
deletedAt: null,
|
||||
},
|
||||
@@ -90,111 +92,62 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
|
||||
let downloadPath: string;
|
||||
|
||||
// Try to get download path from the appropriate download client
|
||||
// Get path mapping for this specific download client
|
||||
const clientType = downloadHistory.downloadClient || 'qbittorrent';
|
||||
const mappingConfig = await getPathMappingForClient(clientType);
|
||||
|
||||
if (downloadHistory.torrentHash) {
|
||||
// qBittorrent download
|
||||
try {
|
||||
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
|
||||
const qbt = await getQBittorrentService();
|
||||
const torrent = await qbt.getTorrent(downloadHistory.torrentHash);
|
||||
const qbPath = `${torrent.save_path}/${torrent.name}`;
|
||||
downloadPath = PathMapper.transform(qbPath, mappingConfig);
|
||||
logger.info(
|
||||
`Got download path from qBittorrent for request ${request.id}: ${qbPath}` +
|
||||
(downloadPath !== qbPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
} catch (qbtError) {
|
||||
// Torrent not found in qBittorrent - try to construct path from config
|
||||
logger.warn(`Torrent not found in qBittorrent for request ${request.id}, falling back to configured path`);
|
||||
|
||||
if (!downloadHistory.torrentName) {
|
||||
logger.warn(`No torrent name stored for request ${request.id}, cannot construct fallback path, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const downloadDir = await configService.get('download_dir');
|
||||
|
||||
if (!downloadDir) {
|
||||
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
downloadPath = PathMapper.transform(fallbackPath, mappingConfig);
|
||||
logger.info(
|
||||
`Using fallback download path for request ${request.id}: ${fallbackPath}` +
|
||||
(downloadPath !== fallbackPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
}
|
||||
} else if (downloadHistory.nzbId) {
|
||||
// SABnzbd download
|
||||
try {
|
||||
const { getSABnzbdService } = await import('../integrations/sabnzbd.service');
|
||||
const sabnzbd = await getSABnzbdService();
|
||||
const nzbInfo = await sabnzbd.getNZB(downloadHistory.nzbId);
|
||||
if (nzbInfo && nzbInfo.downloadPath) {
|
||||
downloadPath = PathMapper.transform(nzbInfo.downloadPath, mappingConfig);
|
||||
logger.info(
|
||||
`Got download path from SABnzbd for request ${request.id}: ${nzbInfo.downloadPath}` +
|
||||
(downloadPath !== nzbInfo.downloadPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
} else {
|
||||
logger.warn(`NZB ${downloadHistory.nzbId} not found or has no download path for request ${request.id}, falling back to configured path`);
|
||||
|
||||
if (!downloadHistory.torrentName) {
|
||||
logger.warn(`No name stored for request ${request.id}, cannot construct fallback path, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const downloadDir = await configService.get('download_dir');
|
||||
|
||||
if (!downloadDir) {
|
||||
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
downloadPath = PathMapper.transform(fallbackPath, mappingConfig);
|
||||
logger.info(
|
||||
`Using fallback download path for request ${request.id}: ${fallbackPath}` +
|
||||
(downloadPath !== fallbackPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
}
|
||||
} catch (sabnzbdError) {
|
||||
logger.warn(`SABnzbd error for request ${request.id}: ${sabnzbdError instanceof Error ? sabnzbdError.message : 'Unknown error'}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// 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 {
|
||||
// No download client ID - use fallback path
|
||||
if (!downloadHistory.torrentName) {
|
||||
logger.warn(`No download client ID or name for request ${request.id}, skipping`);
|
||||
// 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;
|
||||
}
|
||||
|
||||
const downloadDir = await configService.get('download_dir');
|
||||
if (clientId) {
|
||||
// Try to get path from download client via unified interface
|
||||
const client = await manager.getClientServiceForProtocol(protocol);
|
||||
|
||||
if (!downloadDir) {
|
||||
logger.error(`download_dir not configured, cannot retry request ${request.id}, skipping`);
|
||||
skipped++;
|
||||
continue;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const configuredPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
downloadPath = PathMapper.transform(configuredPath, mappingConfig);
|
||||
logger.info(
|
||||
`Using configured download path for request ${request.id}: ${configuredPath}` +
|
||||
(downloadPath !== configuredPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
// Check if we got a valid path (getFallbackPath returns empty string on failure)
|
||||
if (!downloadPath) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await jobQueue.addOrganizeJob(
|
||||
@@ -203,7 +156,7 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
downloadPath
|
||||
);
|
||||
triggered++;
|
||||
logger.info(`Triggered organize job for request ${request.id}: ${request.audiobook.title}`);
|
||||
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++;
|
||||
@@ -224,3 +177,62 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user