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:
kikootwo
2026-01-04 06:28:17 -05:00
parent d617e26c92
commit ca7cac0c88
26 changed files with 1108 additions and 75 deletions
@@ -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(
+190 -1
View File
@@ -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 },
+129 -9
View File
@@ -49,6 +49,9 @@ export async function deleteRequest(
id: true,
title: true,
author: true,
audibleAsin: true,
plexGuid: true,
absItemId: true,
},
},
downloadHistory: {
@@ -168,12 +171,39 @@ export async function deleteRequest(
const configService = getConfigService();
const mediaDir = (await configService.get('media_dir')) || '/media/audiobooks';
// Sanitize author and title for path
// Sanitize author and title for path (same logic as file-organizer.ts)
const sanitizedAuthor = sanitizePath(request.audiobook.author);
const sanitizedTitle = sanitizePath(request.audiobook.title);
// Build path: [media_dir]/[author]/[title]/
const titleFolderPath = path.join(mediaDir, sanitizedAuthor, sanitizedTitle);
// Build folder name with optional year and ASIN (matches file-organizer.ts logic)
let folderName = sanitizedTitle;
// Get ASIN and check for year in AudibleCache
const asin = request.audiobook.audibleAsin;
let year: number | undefined;
if (asin) {
// Try to get year from AudibleCache if it exists
const audibleCache = await prisma.audibleCache.findUnique({
where: { asin },
select: { releaseDate: true },
});
if (audibleCache?.releaseDate) {
year = new Date(audibleCache.releaseDate).getFullYear();
}
}
if (year) {
folderName = `${folderName} (${year})`;
}
if (asin) {
folderName = `${folderName} ${asin}`;
}
// Build path: [media_dir]/[author]/[title (year) asin]/
const titleFolderPath = path.join(mediaDir, sanitizedAuthor, folderName);
// Check if folder exists
try {
@@ -185,11 +215,20 @@ export async function deleteRequest(
console.log(`[RequestDelete] Deleted media directory: ${titleFolderPath}`);
filesDeleted = true;
} catch (accessError) {
// Folder doesn't exist - that's okay
console.log(
`[RequestDelete] Media directory not found (already deleted?): ${titleFolderPath}`
);
filesDeleted = false;
// Folder doesn't exist - try without year/ASIN (fallback for older files)
const fallbackPath = path.join(mediaDir, sanitizedAuthor, sanitizedTitle);
try {
await fs.access(fallbackPath);
await fs.rm(fallbackPath, { recursive: true, force: true });
console.log(`[RequestDelete] Deleted media directory (fallback path): ${fallbackPath}`);
filesDeleted = true;
} catch (fallbackError) {
// Neither path exists - that's okay
console.log(
`[RequestDelete] Media directory not found (tried: ${titleFolderPath}, ${fallbackPath})`
);
filesDeleted = false;
}
}
} catch (error) {
console.error(
@@ -199,7 +238,88 @@ export async function deleteRequest(
// Continue with soft delete even if file deletion fails
}
// 4. Soft delete request
// 4. Delete from plex_library table and clear audiobook availability
// This ensures the book immediately shows as NOT available when searching
try {
const { getConfigService } = await import('./config.service');
const configService = getConfigService();
const backendMode = await configService.getBackendMode();
// Delete ALL plex_library records matching this audiobook's title and author
// This handles cases where there might be duplicate library records
// and ensures the book doesn't show as "In Your Library" during searches
try {
// Find all matching library records (by title/author fuzzy match)
const matchingLibraryRecords = await prisma.plexLibrary.findMany({
where: {
title: {
contains: request.audiobook.title.substring(0, 20),
mode: 'insensitive',
},
},
});
// Filter to exact matches (case-insensitive title and author)
const exactMatches = matchingLibraryRecords.filter((record) => {
const titleMatch = record.title.toLowerCase() === request.audiobook.title.toLowerCase();
const authorMatch = record.author.toLowerCase() === request.audiobook.author.toLowerCase();
return titleMatch && authorMatch;
});
if (exactMatches.length > 0) {
// Delete all exact matches
const deletePromises = exactMatches.map((record) =>
prisma.plexLibrary.delete({ where: { id: record.id } })
);
await Promise.all(deletePromises);
console.log(
`[RequestDelete] Deleted ${exactMatches.length} plex_library record(s) for "${request.audiobook.title}"`
);
} else {
console.log(
`[RequestDelete] No plex_library records found for "${request.audiobook.title}"`
);
}
} catch (libError) {
console.error(
`[RequestDelete] Error deleting plex_library records:`,
libError instanceof Error ? libError.message : 'Unknown error'
);
// Continue with deletion even if library cleanup fails
}
// Clear audiobook record linkage
const updateData: any = {
status: 'requested', // Reset to requested state
updatedAt: new Date(),
};
// Clear library linkage based on backend mode
if (backendMode === 'audiobookshelf') {
updateData.absItemId = null;
} else {
updateData.plexGuid = null;
}
await prisma.audiobook.update({
where: { id: request.audiobook.id },
data: updateData,
});
console.log(
`[RequestDelete] Cleared availability status for audiobook ${request.audiobook.id}`
);
} catch (error) {
console.error(
`[RequestDelete] Error clearing audiobook status:`,
error instanceof Error ? error.message : 'Unknown error'
);
// Continue with deletion even if this fails
}
// 5. Soft delete request
await prisma.request.update({
where: { id: requestId },
data: {
+128
View File
@@ -0,0 +1,128 @@
/**
* Path Mapper Utility
* Documentation: documentation/phase3/qbittorrent.md
*
* Handles remote-to-local path mapping for qBittorrent downloads.
* Use case: qBittorrent on remote seedbox or different mount points.
*/
import path from 'path';
export interface PathMappingConfig {
enabled: boolean;
remotePath: string;
localPath: string;
}
export class PathMapper {
/**
* Transforms a qBittorrent path using remote-to-local mapping
*
* Example:
* qBittorrent reports: /remote/mnt/d/done/Audiobook.Name
* Config: { enabled: true, remotePath: '/remote/mnt/d/done', localPath: '/downloads' }
* Returns: /downloads/Audiobook.Name
*
* @param qbittorrentPath - Path reported by qBittorrent
* @param config - Path mapping configuration
* @returns Transformed path (or original if mapping disabled/no match)
*/
static transform(qbittorrentPath: string, config: PathMappingConfig): string {
// 1. If mapping disabled, return original
if (!config.enabled) {
return qbittorrentPath;
}
// 2. Handle empty paths
if (!qbittorrentPath || !config.remotePath || !config.localPath) {
console.warn('PathMapper: Empty path or config, returning original');
return qbittorrentPath;
}
// 3. Normalize paths (handle trailing slashes, backslashes)
// Convert all backslashes to forward slashes for consistency
const normalizedRemote = this.normalizePath(config.remotePath);
const normalizedLocal = this.normalizePath(config.localPath);
const normalizedQbPath = this.normalizePath(qbittorrentPath);
// 4. Check if qBittorrent path starts with remote path
if (!normalizedQbPath.startsWith(normalizedRemote)) {
console.warn(
`PathMapper: Path "${qbittorrentPath}" does not start with remote path "${config.remotePath}". ` +
`Returning original path unchanged.`
);
return qbittorrentPath;
}
// 5. Replace remote prefix with local prefix
const relativePath = normalizedQbPath.substring(normalizedRemote.length);
// Join local path with relative path, ensuring proper path separators
const transformedPath = path.join(normalizedLocal, relativePath);
console.log(`PathMapper: Transformed "${qbittorrentPath}" → "${transformedPath}"`);
return transformedPath;
}
/**
* Validates path mapping configuration
*
* @param config - Path mapping configuration to validate
* @throws Error if paths are invalid (empty, malformed, etc.)
*/
static validate(config: PathMappingConfig): void {
if (!config.enabled) {
return; // No validation needed if disabled
}
if (!config.remotePath || config.remotePath.trim() === '') {
throw new Error('Remote path cannot be empty when path mapping is enabled');
}
if (!config.localPath || config.localPath.trim() === '') {
throw new Error('Local path cannot be empty when path mapping is enabled');
}
// Check for obviously invalid paths
const invalidChars = /[<>"|?*]/;
if (invalidChars.test(config.remotePath)) {
throw new Error('Remote path contains invalid characters');
}
if (invalidChars.test(config.localPath)) {
throw new Error('Local path contains invalid characters');
}
// Warn if paths look suspicious (but don't throw)
if (config.remotePath === config.localPath) {
console.warn('PathMapper: Remote and local paths are identical - path mapping will have no effect');
}
}
/**
* Normalizes a file path for consistent comparison
* - Converts backslashes to forward slashes
* - Removes trailing slashes
* - Normalizes redundant separators
*
* @param filePath - Path to normalize
* @returns Normalized path
*/
private static normalizePath(filePath: string): string {
// Convert backslashes to forward slashes
let normalized = filePath.replace(/\\/g, '/');
// Use path.normalize to handle redundant separators and ..
normalized = path.normalize(normalized);
// Convert backslashes again (path.normalize might add them on Windows)
normalized = normalized.replace(/\\/g, '/');
// Remove trailing slash (except for root '/')
if (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
}
}
+32 -2
View File
@@ -198,7 +198,37 @@ export class RankingAlgorithm {
const requestTitle = audiobook.title.toLowerCase();
const requestAuthor = audiobook.author.toLowerCase();
// Title matching (0-35 points)
// ========== STAGE 1: WORD COVERAGE FILTER (MANDATORY) ==========
// Extract significant words (filter out common stop words)
const stopWords = ['the', 'a', 'an', 'of', 'on', 'in', 'at', 'by', 'for'];
const extractWords = (text: string, stopList: string[]): string[] => {
return text
.toLowerCase()
.replace(/[^\w\s]/g, ' ') // Remove punctuation
.split(/\s+/)
.filter(word => word.length > 0 && !stopList.includes(word));
};
const requestWords = extractWords(requestTitle, stopWords);
const torrentWords = extractWords(torrentTitle, stopWords);
// Calculate word coverage: how many REQUEST words appear in TORRENT
if (requestWords.length === 0) {
// Edge case: title is only stop words, skip filter
// Fall through to normal scoring
} else {
const matchedWords = requestWords.filter(word => torrentWords.includes(word));
const coverage = matchedWords.length / requestWords.length;
// HARD REQUIREMENT: Must have 80%+ word coverage
if (coverage < 0.80) {
// Automatic rejection - doesn't contain enough of the requested words
return 0;
}
}
// ========== STAGE 2: TITLE MATCHING (0-35 points) ==========
let titleScore = 0;
if (torrentTitle.includes(requestTitle)) {
// Found the title, but is it the complete title or part of a longer one?
@@ -224,7 +254,7 @@ export class RankingAlgorithm {
titleScore = compareTwoStrings(requestTitle, torrentTitle) * 35;
}
// Author matching (0-15 points)
// ========== STAGE 3: AUTHOR MATCHING (0-15 points) ==========
// Parse requested authors (split on separators, filter out roles)
const requestAuthors = requestAuthor
.split(/,|&| and | - /)