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:
@@ -21,6 +21,7 @@ interface RecentRequest {
|
||||
createdAt: Date;
|
||||
completedAt: Date | null;
|
||||
errorMessage: string | null;
|
||||
torrentUrl?: string | null;
|
||||
}
|
||||
|
||||
interface RecentRequestsTableProps {
|
||||
@@ -273,6 +274,7 @@ export function RecentRequestsTable({ requests }: RecentRequestsTableProps) {
|
||||
title: request.title,
|
||||
author: request.author,
|
||||
status: request.status,
|
||||
torrentUrl: request.torrentUrl,
|
||||
}}
|
||||
onDelete={handleDeleteClick}
|
||||
onManualSearch={handleManualSearch}
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface RequestActionsDropdownProps {
|
||||
title: string;
|
||||
author: string;
|
||||
status: string;
|
||||
torrentUrl?: string | null;
|
||||
};
|
||||
onDelete: (requestId: string, title: string) => void;
|
||||
onManualSearch: (requestId: string) => Promise<void>;
|
||||
@@ -38,6 +39,7 @@ export function RequestActionsDropdown({
|
||||
const canSearch = ['pending', 'failed', 'awaiting_search'].includes(request.status);
|
||||
const canCancel = ['pending', 'searching', 'downloading'].includes(request.status);
|
||||
const canDelete = true; // Admins can always delete
|
||||
const canViewSource = !!request.torrentUrl && ['downloading', 'processing', 'downloaded', 'available'].includes(request.status);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
@@ -156,8 +158,35 @@ export function RequestActionsDropdown({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Divider if we have search actions and other actions */}
|
||||
{canSearch && (canCancel || canDelete) && (
|
||||
{/* View Source */}
|
||||
{canViewSource && (
|
||||
<a
|
||||
href={request.torrentUrl!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2 transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
View Source
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Divider if we have search/view actions and other actions */}
|
||||
{(canSearch || canViewSource) && (canCancel || canDelete) && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||
)}
|
||||
|
||||
|
||||
@@ -70,6 +70,9 @@ interface Settings {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
remotePathMappingEnabled: boolean;
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
};
|
||||
paths: {
|
||||
downloadDir: string;
|
||||
@@ -648,6 +651,9 @@ export default function AdminSettings() {
|
||||
url: settings.downloadClient.url,
|
||||
username: settings.downloadClient.username,
|
||||
password: settings.downloadClient.password,
|
||||
remotePathMappingEnabled: settings.downloadClient.remotePathMappingEnabled,
|
||||
remotePath: settings.downloadClient.remotePath,
|
||||
localPath: settings.downloadClient.localPath,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1196,7 +1202,7 @@ export default function AdminSettings() {
|
||||
<option value="">Select a library...</option>
|
||||
{absLibraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name} ({lib.itemCount} items)
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -1545,6 +1551,104 @@ export default function AdminSettings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remote Path Mapping */}
|
||||
<div className="mt-6 bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remote-path-mapping"
|
||||
checked={settings.downloadClient.remotePathMappingEnabled}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
...settings,
|
||||
downloadClient: {
|
||||
...settings.downloadClient,
|
||||
remotePathMappingEnabled: e.target.checked,
|
||||
},
|
||||
});
|
||||
setValidated({ ...validated, download: false });
|
||||
}}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="remote-path-mapping"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Enable Remote Path Mapping
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Use this when qBittorrent runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers)
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 font-mono">
|
||||
Example: Remote <span className="text-blue-600 dark:text-blue-400">/remote/mnt/d/done</span> → Local <span className="text-green-600 dark:text-green-400">/downloads</span>
|
||||
</p>
|
||||
|
||||
{/* Warning for existing downloads */}
|
||||
{settings.downloadClient.remotePathMappingEnabled && (
|
||||
<div className="mt-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
⚠️ <strong>Note:</strong> Path mapping only affects new downloads. In-progress downloads will continue using their original paths.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conditional Fields */}
|
||||
{settings.downloadClient.remotePathMappingEnabled && (
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Remote Path (from qBittorrent)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="/remote/mnt/d/done"
|
||||
value={settings.downloadClient.remotePath}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
...settings,
|
||||
downloadClient: {
|
||||
...settings.downloadClient,
|
||||
remotePath: e.target.value,
|
||||
},
|
||||
});
|
||||
setValidated({ ...validated, download: false });
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
The path prefix as reported by qBittorrent
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Local Path (for ReadMeABook)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="/downloads"
|
||||
value={settings.downloadClient.localPath}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
...settings,
|
||||
downloadClient: {
|
||||
...settings.downloadClient,
|
||||
localPath: e.target.value,
|
||||
},
|
||||
});
|
||||
setValidated({ ...validated, download: false });
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
The actual path where files are accessible
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<Button
|
||||
onClick={testDownloadClientConnection}
|
||||
|
||||
@@ -48,6 +48,8 @@ export async function GET(request: NextRequest) {
|
||||
downloadStatus: true,
|
||||
torrentName: true,
|
||||
torrentHash: true,
|
||||
startedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -75,7 +77,7 @@ export async function GET(request: NextRequest) {
|
||||
torrentName: download.downloadHistory[0]?.torrentName || null,
|
||||
downloadStatus: download.downloadHistory[0]?.downloadStatus || null,
|
||||
user: download.user.plexUsername,
|
||||
startedAt: download.updatedAt,
|
||||
startedAt: download.downloadHistory[0]?.startedAt || download.downloadHistory[0]?.createdAt || download.updatedAt,
|
||||
}));
|
||||
return NextResponse.json({ downloads: formatted });
|
||||
}
|
||||
@@ -112,7 +114,7 @@ export async function GET(request: NextRequest) {
|
||||
torrentName: download.downloadHistory[0]?.torrentName || null,
|
||||
downloadStatus: download.downloadHistory[0]?.downloadStatus || null,
|
||||
user: download.user.plexUsername,
|
||||
startedAt: download.updatedAt,
|
||||
startedAt: download.downloadHistory[0]?.startedAt || download.downloadHistory[0]?.createdAt || download.updatedAt,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -30,6 +30,15 @@ export async function GET(request: NextRequest) {
|
||||
plexUsername: true,
|
||||
},
|
||||
},
|
||||
downloadHistory: {
|
||||
where: {
|
||||
selected: true,
|
||||
},
|
||||
select: {
|
||||
torrentUrl: true,
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
@@ -47,6 +56,7 @@ export async function GET(request: NextRequest) {
|
||||
createdAt: request.createdAt,
|
||||
completedAt: request.completedAt,
|
||||
errorMessage: request.errorMessage,
|
||||
torrentUrl: request.downloadHistory[0]?.torrentUrl || null,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ requests: formatted });
|
||||
|
||||
@@ -6,12 +6,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { PathMapper } from '@/lib/utils/path-mapper';
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { type, url, username, password } = await request.json();
|
||||
const {
|
||||
type,
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
remotePathMappingEnabled,
|
||||
remotePath,
|
||||
localPath,
|
||||
} = await request.json();
|
||||
|
||||
if (!type || !url || !username || !password) {
|
||||
return NextResponse.json(
|
||||
@@ -28,6 +37,33 @@ export async function PUT(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Validate path mapping if enabled
|
||||
if (remotePathMappingEnabled) {
|
||||
if (!remotePath || !localPath) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Remote path and local path are required when path mapping is enabled' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
PathMapper.validate({
|
||||
enabled: true,
|
||||
remotePath,
|
||||
localPath,
|
||||
});
|
||||
} catch (validationError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: validationError instanceof Error
|
||||
? validationError.message
|
||||
: 'Invalid path mapping configuration',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_type' },
|
||||
@@ -56,6 +92,28 @@ export async function PUT(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// Save remote path mapping configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_remote_path_mapping_enabled' },
|
||||
update: { value: remotePathMappingEnabled ? 'true' : 'false' },
|
||||
create: {
|
||||
key: 'download_client_remote_path_mapping_enabled',
|
||||
value: remotePathMappingEnabled ? 'true' : 'false',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_remote_path' },
|
||||
update: { value: remotePath || '' },
|
||||
create: { key: 'download_client_remote_path', value: remotePath || '' },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_local_path' },
|
||||
update: { value: localPath || '' },
|
||||
create: { key: 'download_client_local_path', value: localPath || '' },
|
||||
});
|
||||
|
||||
console.log('[Admin] Download client settings updated');
|
||||
|
||||
// Invalidate qBittorrent service singleton to force reload of credentials and URL
|
||||
|
||||
@@ -72,6 +72,9 @@ export async function GET(request: NextRequest) {
|
||||
username: configMap.get('download_client_username') || '',
|
||||
password: maskValue('password', configMap.get('download_client_password')),
|
||||
seedingTimeMinutes: parseInt(configMap.get('seeding_time_minutes') || '0'),
|
||||
remotePathMappingEnabled: configMap.get('download_client_remote_path_mapping_enabled') === 'true',
|
||||
remotePath: configMap.get('download_client_remote_path') || '',
|
||||
localPath: configMap.get('download_client_local_path') || '',
|
||||
},
|
||||
paths: {
|
||||
downloadDir: configMap.get('download_dir') || '/downloads',
|
||||
|
||||
@@ -12,7 +12,15 @@ export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const { type, url, username, password } = await request.json();
|
||||
const {
|
||||
type,
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
remotePathMappingEnabled,
|
||||
remotePath,
|
||||
localPath,
|
||||
} = await request.json();
|
||||
|
||||
if (!type || !url || !username || !password) {
|
||||
return NextResponse.json(
|
||||
@@ -52,6 +60,33 @@ export async function POST(request: NextRequest) {
|
||||
actualPassword
|
||||
);
|
||||
|
||||
// If path mapping enabled, validate local path exists
|
||||
if (remotePathMappingEnabled) {
|
||||
if (!remotePath || !localPath) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Remote path and local path are required when path mapping is enabled',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if local path is accessible
|
||||
const fs = await import('fs/promises');
|
||||
try {
|
||||
await fs.access(localPath, fs.constants.R_OK);
|
||||
} catch (accessError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Local path "${localPath}" is not accessible. Please verify the path exists and has correct permissions.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
version,
|
||||
|
||||
@@ -79,10 +79,10 @@ export async function POST(request: NextRequest) {
|
||||
// Rank torrents using the ranking algorithm
|
||||
const rankedResults = rankTorrents(results, { title, author });
|
||||
|
||||
// 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);
|
||||
|
||||
console.log(`[AudiobookSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (30/100)`);
|
||||
console.log(`[AudiobookSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100)`);
|
||||
|
||||
// Log top 3 results with detailed score breakdown for debugging
|
||||
const top3 = filteredResults.slice(0, 3);
|
||||
|
||||
@@ -114,10 +114,10 @@ export async function POST(
|
||||
author: requestRecord.audiobook.author,
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
console.log(`[InteractiveSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (30/100)`);
|
||||
console.log(`[InteractiveSearch] Ranked ${rankedResults.length} results, ${filteredResults.length} above threshold (50/100)`);
|
||||
|
||||
// Log top 3 results with detailed score breakdown for debugging
|
||||
const top3 = filteredResults.slice(0, 3);
|
||||
|
||||
@@ -356,6 +356,28 @@ export async function POST(request: NextRequest) {
|
||||
create: { key: 'download_client_password', value: downloadClient.password },
|
||||
});
|
||||
|
||||
// Remote path mapping configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_remote_path_mapping_enabled' },
|
||||
update: { value: downloadClient.remotePathMappingEnabled ? 'true' : 'false' },
|
||||
create: {
|
||||
key: 'download_client_remote_path_mapping_enabled',
|
||||
value: downloadClient.remotePathMappingEnabled ? 'true' : 'false',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_remote_path' },
|
||||
update: { value: downloadClient.remotePath || '' },
|
||||
create: { key: 'download_client_remote_path', value: downloadClient.remotePath || '' },
|
||||
});
|
||||
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_client_local_path' },
|
||||
update: { value: downloadClient.localPath || '' },
|
||||
create: { key: 'download_client_local_path', value: downloadClient.localPath || '' },
|
||||
});
|
||||
|
||||
// Path configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: 'download_dir' },
|
||||
|
||||
@@ -77,6 +77,9 @@ interface SetupState {
|
||||
downloadClientUrl: string;
|
||||
downloadClientUsername: string;
|
||||
downloadClientPassword: string;
|
||||
remotePathMappingEnabled: boolean;
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
downloadDir: string;
|
||||
mediaDir: string;
|
||||
metadataTaggingEnabled: boolean;
|
||||
@@ -142,6 +145,9 @@ export default function SetupWizard() {
|
||||
downloadClientUrl: '',
|
||||
downloadClientUsername: 'admin',
|
||||
downloadClientPassword: '',
|
||||
remotePathMappingEnabled: false,
|
||||
remotePath: '',
|
||||
localPath: '',
|
||||
downloadDir: '/downloads',
|
||||
mediaDir: '/media/audiobooks',
|
||||
metadataTaggingEnabled: true,
|
||||
@@ -211,6 +217,9 @@ export default function SetupWizard() {
|
||||
url: state.downloadClientUrl,
|
||||
username: state.downloadClientUsername,
|
||||
password: state.downloadClientPassword,
|
||||
remotePathMappingEnabled: state.remotePathMappingEnabled,
|
||||
remotePath: state.remotePath,
|
||||
localPath: state.localPath,
|
||||
},
|
||||
paths: {
|
||||
download_dir: state.downloadDir,
|
||||
@@ -494,6 +503,9 @@ export default function SetupWizard() {
|
||||
downloadClientUrl={state.downloadClientUrl}
|
||||
downloadClientUsername={state.downloadClientUsername}
|
||||
downloadClientPassword={state.downloadClientPassword}
|
||||
remotePathMappingEnabled={state.remotePathMappingEnabled}
|
||||
remotePath={state.remotePath}
|
||||
localPath={state.localPath}
|
||||
onUpdate={updateField}
|
||||
onNext={() => goToStep(currentStepNumber + 1)}
|
||||
onBack={() => goToStep(currentStepNumber - 1)}
|
||||
|
||||
@@ -217,7 +217,7 @@ export function AudiobookshelfStep({
|
||||
<option value="">Select a library...</option>
|
||||
{libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>
|
||||
{lib.name} ({lib.itemCount} items)
|
||||
{lib.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -14,7 +14,10 @@ interface DownloadClientStepProps {
|
||||
downloadClientUrl: string;
|
||||
downloadClientUsername: string;
|
||||
downloadClientPassword: string;
|
||||
onUpdate: (field: string, value: string) => void;
|
||||
remotePathMappingEnabled: boolean;
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
onUpdate: (field: string, value: any) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
@@ -24,6 +27,9 @@ export function DownloadClientStep({
|
||||
downloadClientUrl,
|
||||
downloadClientUsername,
|
||||
downloadClientPassword,
|
||||
remotePathMappingEnabled,
|
||||
remotePath,
|
||||
localPath,
|
||||
onUpdate,
|
||||
onNext,
|
||||
onBack,
|
||||
@@ -48,6 +54,9 @@ export function DownloadClientStep({
|
||||
url: downloadClientUrl,
|
||||
username: downloadClientUsername,
|
||||
password: downloadClientPassword,
|
||||
remotePathMappingEnabled,
|
||||
remotePath,
|
||||
localPath,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -176,6 +185,68 @@ export function DownloadClientStep({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remote Path Mapping */}
|
||||
<div className="mt-4 bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="remote-path-mapping-setup"
|
||||
checked={remotePathMappingEnabled}
|
||||
onChange={(e) => onUpdate('remotePathMappingEnabled', e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<label
|
||||
htmlFor="remote-path-mapping-setup"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-gray-100 cursor-pointer"
|
||||
>
|
||||
Enable Remote Path Mapping
|
||||
</label>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Use this when qBittorrent runs on a different machine or uses different mount points (e.g., remote seedbox, Docker containers)
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 font-mono">
|
||||
Example: Remote <span className="text-blue-600 dark:text-blue-400">/remote/mnt/d/done</span> → Local <span className="text-green-600 dark:text-green-400">/downloads</span>
|
||||
</p>
|
||||
|
||||
{/* Conditional Fields */}
|
||||
{remotePathMappingEnabled && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Remote Path (from qBittorrent)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="/remote/mnt/d/done"
|
||||
value={remotePath}
|
||||
onChange={(e) => onUpdate('remotePath', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
The path prefix as reported by qBittorrent
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Local Path (for ReadMeABook)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="/downloads"
|
||||
value={localPath}
|
||||
onChange={(e) => onUpdate('localPath', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
The actual path where files are accessible
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={testConnection}
|
||||
loading={testing}
|
||||
|
||||
@@ -9,13 +9,32 @@ import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface ReviewStepProps {
|
||||
config: {
|
||||
backendMode: 'plex' | 'audiobookshelf';
|
||||
|
||||
// Plex config
|
||||
plexUrl: string;
|
||||
plexLibraryId: string;
|
||||
|
||||
// Audiobookshelf config
|
||||
absUrl: string;
|
||||
absLibraryId: string;
|
||||
|
||||
// Auth config (ABS mode)
|
||||
authMethod: 'oidc' | 'manual' | 'both';
|
||||
oidcProviderName: string;
|
||||
adminUsername: string;
|
||||
|
||||
// Common config
|
||||
prowlarrUrl: string;
|
||||
downloadClient: 'qbittorrent' | 'transmission';
|
||||
downloadClientUrl: string;
|
||||
downloadDir: string;
|
||||
mediaDir: string;
|
||||
|
||||
// BookDate
|
||||
bookdateConfigured: boolean;
|
||||
bookdateProvider: string;
|
||||
bookdateModel: string;
|
||||
};
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
@@ -58,26 +77,82 @@ export function ReviewStep({ config, loading, error, onComplete, onBack }: Revie
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Plex Configuration */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Plex Media Server
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Server URL:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{config.plexUrl}
|
||||
</dd>
|
||||
{/* Backend Configuration - Conditional based on mode */}
|
||||
{config.backendMode === 'plex' ? (
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Plex Media Server
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Server URL:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{config.plexUrl}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Library ID:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{config.plexLibraryId}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Audiobookshelf Configuration */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Audiobookshelf
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Server URL:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{config.absUrl}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Library ID:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{config.absLibraryId}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Library ID:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{config.plexLibraryId}
|
||||
</dd>
|
||||
|
||||
{/* Authentication Configuration */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
Authentication
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Auth Method:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100 capitalize">
|
||||
{config.authMethod === 'both' ? 'OIDC + Manual Registration' : config.authMethod === 'oidc' ? 'OIDC' : 'Manual Registration'}
|
||||
</dd>
|
||||
</div>
|
||||
{(config.authMethod === 'oidc' || config.authMethod === 'both') && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">OIDC Provider:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{config.oidcProviderName}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{(config.authMethod === 'manual' || config.authMethod === 'both') && (
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Admin Username:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{config.adminUsername}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Prowlarr Configuration */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
@@ -135,6 +210,29 @@ export function ReviewStep({ config, loading, error, onComplete, onBack }: Revie
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* BookDate Configuration (Optional) */}
|
||||
{config.bookdateConfigured && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">
|
||||
BookDate AI Recommendations
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Provider:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100 capitalize">
|
||||
{config.bookdateProvider}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-sm text-gray-600 dark:text-gray-400">Model:</dt>
|
||||
<dd className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{config.bookdateModel}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
|
||||
Reference in New Issue
Block a user