mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Add remote path mapping for qBittorrent integration
Implements remote-to-local path mapping for qBittorrent downloads, allowing the app to handle differing filesystem paths between qBittorrent and the local environment (e.g., remote seedboxes, Docker). Adds UI controls in admin settings and setup wizard, validates mapping configuration, and applies path transformation in download and import processors. Updates documentation, API routes, and data models to support the new feature. Also improves library scan logic to remove stale records and reset orphaned audiobooks and requests. Increases minimum torrent score threshold from 30 to 50 in search and ranking logic, and exposes torrent source URLs in the admin UI.
This commit is contained in:
@@ -61,6 +61,8 @@ export async function processDownloadTorrent(payload: DownloadTorrentPayload): P
|
||||
torrentName: torrent.title,
|
||||
torrentHash: torrent.infoHash || torrentHash,
|
||||
torrentSizeBytes: torrent.size,
|
||||
torrentUrl: torrent.guid, // Source URL for the torrent page
|
||||
magnetLink: torrent.downloadUrl, // Download URL (magnet or .torrent)
|
||||
seeders: torrent.seeders,
|
||||
leechers: torrent.leechers || 0,
|
||||
downloadStatus: 'downloading',
|
||||
|
||||
@@ -8,6 +8,8 @@ import { MonitorDownloadPayload, getJobQueueService } from '../services/job-queu
|
||||
import { prisma } from '../db';
|
||||
import { getQBittorrentService } from '../integrations/qbittorrent.service';
|
||||
import { createJobLogger, JobLogger } from '../utils/job-logger';
|
||||
import { PathMapper } from '../utils/path-mapper';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
|
||||
/**
|
||||
* Helper function to retry getTorrent with exponential backoff
|
||||
@@ -94,16 +96,32 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
|
||||
// Determine actual content path for file organization
|
||||
// Priority 1: Use content_path if provided by qBittorrent (most reliable)
|
||||
// Priority 2: Construct path using path.join() for proper normalization
|
||||
const organizePath = torrent.content_path
|
||||
const qbPath = torrent.content_path
|
||||
? torrent.content_path
|
||||
: path.join(torrent.save_path, torrent.name);
|
||||
|
||||
// Load path mapping configuration
|
||||
const configService = getConfigService();
|
||||
const pathMappingConfig = await configService.getMany([
|
||||
'download_client_remote_path_mapping_enabled',
|
||||
'download_client_remote_path',
|
||||
'download_client_local_path',
|
||||
]);
|
||||
|
||||
// Apply remote-to-local path transformation if enabled
|
||||
const organizePath = PathMapper.transform(qbPath, {
|
||||
enabled: pathMappingConfig.download_client_remote_path_mapping_enabled === 'true',
|
||||
remotePath: pathMappingConfig.download_client_remote_path || '',
|
||||
localPath: pathMappingConfig.download_client_local_path || '',
|
||||
});
|
||||
|
||||
await logger?.info(`Download completed`, {
|
||||
filesCount: files.length,
|
||||
torrentName: torrent.name,
|
||||
savePath: torrent.save_path,
|
||||
contentPath: torrent.content_path || '(not provided)',
|
||||
organizePath,
|
||||
qbittorrentPath: qbPath,
|
||||
organizePath: organizePath !== qbPath ? `${organizePath} (mapped)` : organizePath,
|
||||
});
|
||||
|
||||
// Update download history to completed
|
||||
|
||||
@@ -9,6 +9,7 @@ import { prisma } from '../db';
|
||||
import { createJobLogger } from '../utils/job-logger';
|
||||
import { getJobQueueService } from '../services/job-queue.service';
|
||||
import { getConfigService } from '../services/config.service';
|
||||
import { PathMapper } from '../utils/path-mapper';
|
||||
|
||||
export interface RetryFailedImportsPayload {
|
||||
jobId?: string;
|
||||
@@ -22,6 +23,20 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
await logger?.info('Starting retry job for requests awaiting import...');
|
||||
|
||||
try {
|
||||
// Load path mapping configuration once
|
||||
const configService = getConfigService();
|
||||
const pathMappingConfig = await configService.getMany([
|
||||
'download_client_remote_path_mapping_enabled',
|
||||
'download_client_remote_path',
|
||||
'download_client_local_path',
|
||||
]);
|
||||
|
||||
const mappingConfig = {
|
||||
enabled: pathMappingConfig.download_client_remote_path_mapping_enabled === 'true',
|
||||
remotePath: pathMappingConfig.download_client_remote_path || '',
|
||||
localPath: pathMappingConfig.download_client_local_path || '',
|
||||
};
|
||||
|
||||
// Find all active requests in awaiting_import status
|
||||
const requests = await prisma.request.findMany({
|
||||
where: {
|
||||
@@ -73,8 +88,12 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
const { getQBittorrentService } = await import('../integrations/qbittorrent.service');
|
||||
const qbt = await getQBittorrentService();
|
||||
const torrent = await qbt.getTorrent(downloadHistory.downloadClientId);
|
||||
downloadPath = `${torrent.save_path}/${torrent.name}`;
|
||||
await logger?.info(`Got download path from qBittorrent for request ${request.id}: ${downloadPath}`);
|
||||
const qbPath = `${torrent.save_path}/${torrent.name}`;
|
||||
downloadPath = PathMapper.transform(qbPath, mappingConfig);
|
||||
await 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
|
||||
await logger?.warn(`Torrent not found in qBittorrent for request ${request.id}, falling back to configured path`);
|
||||
@@ -85,7 +104,6 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
continue;
|
||||
}
|
||||
|
||||
const configService = getConfigService();
|
||||
const downloadDir = await configService.get('download_dir');
|
||||
|
||||
if (!downloadDir) {
|
||||
@@ -94,8 +112,12 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
continue;
|
||||
}
|
||||
|
||||
downloadPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
await logger?.info(`Using fallback download path for request ${request.id}: ${downloadPath}`);
|
||||
const fallbackPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
downloadPath = PathMapper.transform(fallbackPath, mappingConfig);
|
||||
await logger?.info(
|
||||
`Using fallback download path for request ${request.id}: ${fallbackPath}` +
|
||||
(downloadPath !== fallbackPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No download client ID - use fallback path
|
||||
@@ -105,7 +127,6 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
continue;
|
||||
}
|
||||
|
||||
const configService = getConfigService();
|
||||
const downloadDir = await configService.get('download_dir');
|
||||
|
||||
if (!downloadDir) {
|
||||
@@ -114,8 +135,12 @@ export async function processRetryFailedImports(payload: RetryFailedImportsPaylo
|
||||
continue;
|
||||
}
|
||||
|
||||
downloadPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
await logger?.info(`Using configured download path for request ${request.id}: ${downloadPath}`);
|
||||
const configuredPath = `${downloadDir}/${downloadHistory.torrentName}`;
|
||||
downloadPath = PathMapper.transform(configuredPath, mappingConfig);
|
||||
await logger?.info(
|
||||
`Using configured download path for request ${request.id}: ${configuredPath}` +
|
||||
(downloadPath !== configuredPath ? ` → ${downloadPath} (mapped)` : '')
|
||||
);
|
||||
}
|
||||
|
||||
await jobQueue.addOrganizeJob(
|
||||
|
||||
@@ -137,7 +137,186 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
|
||||
await logger?.info(`Scan complete: ${libraryItems.length} items scanned, ${newCount} new, ${updatedCount} updated, ${skippedCount} skipped`);
|
||||
|
||||
// 5. Match downloaded requests against library
|
||||
// 5. Remove stale records from plex_library (items no longer in the actual library)
|
||||
// This ensures the database is a fresh snapshot of the library state
|
||||
await logger?.info(`Checking for stale library records...`);
|
||||
|
||||
const scannedPlexGuids = libraryItems
|
||||
.filter(item => item.externalId)
|
||||
.map(item => item.externalId);
|
||||
|
||||
let staleRemovedCount = 0;
|
||||
let audiobooksReset = 0;
|
||||
let requestsReset = 0;
|
||||
|
||||
// Safety check: Only remove stale records if we actually scanned items
|
||||
// This prevents accidentally deleting everything if the library scan fails or returns empty
|
||||
if (scannedPlexGuids.length > 0) {
|
||||
// Find all plex_library entries for this library that were NOT seen in this scan
|
||||
const staleLibraryItems = await prisma.plexLibrary.findMany({
|
||||
where: {
|
||||
plexLibraryId: targetLibraryId,
|
||||
plexGuid: {
|
||||
notIn: scannedPlexGuids,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (staleLibraryItems.length > 0) {
|
||||
await logger?.info(`Found ${staleLibraryItems.length} stale library records to remove`);
|
||||
|
||||
// For each stale library item, clean up references
|
||||
for (const staleItem of staleLibraryItems) {
|
||||
try {
|
||||
// Find audiobooks that reference this stale library item
|
||||
const linkedAudiobooks = await prisma.audiobook.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ plexGuid: staleItem.plexGuid },
|
||||
{ absItemId: staleItem.plexGuid },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
requests: {
|
||||
where: { deletedAt: null },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Reset audiobook records and their requests
|
||||
for (const audiobook of linkedAudiobooks) {
|
||||
// Clear library linkage
|
||||
const updateData: any = {
|
||||
status: 'requested',
|
||||
plexGuid: null,
|
||||
absItemId: null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await prisma.audiobook.update({
|
||||
where: { id: audiobook.id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
audiobooksReset++;
|
||||
|
||||
// Reset any 'available' requests back to 'downloaded' or 'failed'
|
||||
for (const request of audiobook.requests) {
|
||||
if (request.status === 'available') {
|
||||
await prisma.request.update({
|
||||
where: { id: request.id },
|
||||
data: {
|
||||
status: 'downloaded', // Back to downloaded state (files may still be there)
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
requestsReset++;
|
||||
}
|
||||
}
|
||||
|
||||
await logger?.info(`Reset audiobook "${staleItem.title}" (no longer in library)`);
|
||||
}
|
||||
|
||||
// Delete the stale library record
|
||||
await prisma.plexLibrary.delete({
|
||||
where: { id: staleItem.id },
|
||||
});
|
||||
|
||||
staleRemovedCount++;
|
||||
} catch (error) {
|
||||
await logger?.error(`Failed to remove stale library item "${staleItem.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
await logger?.info(`Removed ${staleRemovedCount} stale records, reset ${audiobooksReset} audiobooks and ${requestsReset} requests`);
|
||||
} else {
|
||||
await logger?.info(`No stale library records found`);
|
||||
}
|
||||
} else {
|
||||
await logger?.warn(`Scan returned no items - skipping stale record cleanup to prevent data loss`);
|
||||
}
|
||||
|
||||
// 5b. Clean up orphaned audiobooks (audiobooks with plexGuid/absItemId that don't exist in plex_library)
|
||||
// This handles cases where the library record was already deleted but audiobook record wasn't updated
|
||||
await logger?.info(`Checking for orphaned audiobooks...`);
|
||||
|
||||
const allPlexGuidsInLibrary = await prisma.plexLibrary.findMany({
|
||||
select: { plexGuid: true },
|
||||
});
|
||||
const validPlexGuids = allPlexGuidsInLibrary.map(item => item.plexGuid);
|
||||
|
||||
let orphanedAudiobooksReset = 0;
|
||||
let orphanedRequestsReset = 0;
|
||||
|
||||
// Find audiobooks with plexGuid/absItemId that don't exist in plex_library
|
||||
const orphanedAudiobooks = await prisma.audiobook.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
plexGuid: { not: null },
|
||||
},
|
||||
{
|
||||
absItemId: { not: null },
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
requests: {
|
||||
where: { deletedAt: null },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const audiobook of orphanedAudiobooks) {
|
||||
const linkedId = audiobook.plexGuid || audiobook.absItemId;
|
||||
|
||||
// Skip if this audiobook's library ID is valid (exists in plex_library)
|
||||
if (linkedId && validPlexGuids.includes(linkedId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// This audiobook is orphaned - its library link points to nothing
|
||||
try {
|
||||
await logger?.info(`Found orphaned audiobook: "${audiobook.title}" (linked to non-existent library item)`);
|
||||
|
||||
// Clear library linkage
|
||||
await prisma.audiobook.update({
|
||||
where: { id: audiobook.id },
|
||||
data: {
|
||||
status: 'requested',
|
||||
plexGuid: null,
|
||||
absItemId: null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
orphanedAudiobooksReset++;
|
||||
|
||||
// Reset any 'available' requests
|
||||
for (const request of audiobook.requests) {
|
||||
if (request.status === 'available') {
|
||||
await prisma.request.update({
|
||||
where: { id: request.id },
|
||||
data: {
|
||||
status: 'downloaded',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
orphanedRequestsReset++;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await logger?.error(`Failed to reset orphaned audiobook "${audiobook.title}": ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphanedAudiobooksReset > 0) {
|
||||
await logger?.info(`Reset ${orphanedAudiobooksReset} orphaned audiobooks and ${orphanedRequestsReset} requests`);
|
||||
} else {
|
||||
await logger?.info(`No orphaned audiobooks found`);
|
||||
}
|
||||
|
||||
// 6. Match downloaded requests against library
|
||||
await logger?.info(`Checking for downloaded requests to match...`);
|
||||
const downloadedRequests = await prisma.request.findMany({
|
||||
where: {
|
||||
@@ -205,6 +384,11 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
newCount,
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
staleRemovedCount,
|
||||
audiobooksReset,
|
||||
requestsReset,
|
||||
orphanedAudiobooksReset,
|
||||
orphanedRequestsReset,
|
||||
matchedDownloads: matchedCount,
|
||||
});
|
||||
|
||||
@@ -217,6 +401,11 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
|
||||
newCount,
|
||||
updatedCount,
|
||||
skippedCount,
|
||||
staleRemovedCount,
|
||||
audiobooksReset,
|
||||
requestsReset,
|
||||
orphanedAudiobooksReset,
|
||||
orphanedRequestsReset,
|
||||
newAudiobooks: results,
|
||||
matchedDownloads: matchedCount,
|
||||
};
|
||||
|
||||
@@ -98,14 +98,14 @@ export async function processSearchIndexers(payload: SearchIndexersPayload): Pro
|
||||
durationMinutes: undefined, // We don't have duration from Audible
|
||||
});
|
||||
|
||||
// Filter out results below minimum score threshold (30/100)
|
||||
const filteredResults = rankedResults.filter(result => result.score >= 30);
|
||||
// Filter out results below minimum score threshold (50/100)
|
||||
const filteredResults = rankedResults.filter(result => result.score >= 50);
|
||||
|
||||
await logger?.info(`Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (30/100)`);
|
||||
await logger?.info(`Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100)`);
|
||||
|
||||
if (filteredResults.length === 0) {
|
||||
// No quality results found - queue for re-search instead of failing
|
||||
await logger?.warn(`No quality matches found for request ${requestId} (all below 30/100), marking as awaiting_search`);
|
||||
await logger?.warn(`No quality matches found for request ${requestId} (all below 50/100), marking as awaiting_search`);
|
||||
|
||||
await prisma.request.update({
|
||||
where: { id: requestId },
|
||||
|
||||
Reference in New Issue
Block a user