mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
ca7cac0c88
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.
597 lines
18 KiB
TypeScript
597 lines
18 KiB
TypeScript
/**
|
|
* Component: Setup Wizard Page
|
|
* Documentation: documentation/setup-wizard.md
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { WizardLayout } from './components/WizardLayout';
|
|
import { WelcomeStep } from './steps/WelcomeStep';
|
|
import { BackendSelectionStep } from './steps/BackendSelectionStep';
|
|
import { AdminAccountStep } from './steps/AdminAccountStep';
|
|
import { PlexStep } from './steps/PlexStep';
|
|
import { AudiobookshelfStep } from './steps/AudiobookshelfStep';
|
|
import { AuthMethodStep } from './steps/AuthMethodStep';
|
|
import { OIDCConfigStep } from './steps/OIDCConfigStep';
|
|
import { RegistrationSettingsStep } from './steps/RegistrationSettingsStep';
|
|
import { ProwlarrStep } from './steps/ProwlarrStep';
|
|
import { DownloadClientStep } from './steps/DownloadClientStep';
|
|
import { PathsStep } from './steps/PathsStep';
|
|
import { BookDateStep } from './steps/BookDateStep';
|
|
import { ReviewStep } from './steps/ReviewStep';
|
|
import { FinalizeStep } from './steps/FinalizeStep';
|
|
|
|
interface SelectedIndexer {
|
|
id: number;
|
|
name: string;
|
|
priority: number;
|
|
}
|
|
|
|
interface SetupState {
|
|
currentStep: number;
|
|
|
|
// Backend selection
|
|
backendMode: 'plex' | 'audiobookshelf';
|
|
|
|
// Admin account (for Plex mode and ABS + Manual mode)
|
|
adminUsername: string;
|
|
adminPassword: string;
|
|
|
|
// Plex config (if mode=plex)
|
|
plexUrl: string;
|
|
plexToken: string;
|
|
plexLibraryId: string;
|
|
|
|
// Audiobookshelf config (if mode=audiobookshelf)
|
|
absUrl: string;
|
|
absApiToken: string;
|
|
absLibraryId: string;
|
|
|
|
// Auth config (if mode=audiobookshelf)
|
|
authMethod: 'oidc' | 'manual' | 'both';
|
|
|
|
// OIDC config
|
|
oidcProviderName: string;
|
|
oidcIssuerUrl: string;
|
|
oidcClientId: string;
|
|
oidcClientSecret: string;
|
|
oidcAccessControlMethod: string;
|
|
oidcAccessGroupClaim: string;
|
|
oidcAccessGroupValue: string;
|
|
oidcAllowedEmails: string;
|
|
oidcAllowedUsernames: string;
|
|
oidcAdminClaimEnabled: boolean;
|
|
oidcAdminClaimName: string;
|
|
oidcAdminClaimValue: string;
|
|
|
|
// Manual registration config
|
|
requireAdminApproval: boolean;
|
|
|
|
// Prowlarr, download client, paths, bookdate (common to both modes)
|
|
prowlarrUrl: string;
|
|
prowlarrApiKey: string;
|
|
prowlarrIndexers: SelectedIndexer[];
|
|
downloadClient: 'qbittorrent' | 'transmission';
|
|
downloadClientUrl: string;
|
|
downloadClientUsername: string;
|
|
downloadClientPassword: string;
|
|
remotePathMappingEnabled: boolean;
|
|
remotePath: string;
|
|
localPath: string;
|
|
downloadDir: string;
|
|
mediaDir: string;
|
|
metadataTaggingEnabled: boolean;
|
|
bookdateProvider: string;
|
|
bookdateApiKey: string;
|
|
bookdateModel: string;
|
|
bookdateConfigured: boolean;
|
|
validated: {
|
|
plex: boolean;
|
|
prowlarr: boolean;
|
|
downloadClient: boolean;
|
|
paths: boolean;
|
|
};
|
|
}
|
|
|
|
export default function SetupWizard() {
|
|
const router = useRouter();
|
|
const [state, setState] = useState<SetupState>({
|
|
currentStep: 1,
|
|
|
|
// Backend selection
|
|
backendMode: 'plex',
|
|
|
|
// Admin account
|
|
adminUsername: 'admin',
|
|
adminPassword: '',
|
|
|
|
// Plex config
|
|
plexUrl: '',
|
|
plexToken: '',
|
|
plexLibraryId: '',
|
|
|
|
// Audiobookshelf config
|
|
absUrl: '',
|
|
absApiToken: '',
|
|
absLibraryId: '',
|
|
|
|
// Auth config
|
|
authMethod: 'oidc',
|
|
|
|
// OIDC config
|
|
oidcProviderName: 'Authentik',
|
|
oidcIssuerUrl: '',
|
|
oidcClientId: '',
|
|
oidcClientSecret: '',
|
|
oidcAccessControlMethod: 'open',
|
|
oidcAccessGroupClaim: 'groups',
|
|
oidcAccessGroupValue: '',
|
|
oidcAllowedEmails: '',
|
|
oidcAllowedUsernames: '',
|
|
oidcAdminClaimEnabled: false,
|
|
oidcAdminClaimName: 'groups',
|
|
oidcAdminClaimValue: '',
|
|
|
|
// Manual registration config
|
|
requireAdminApproval: true,
|
|
|
|
// Common config
|
|
prowlarrUrl: '',
|
|
prowlarrApiKey: '',
|
|
prowlarrIndexers: [],
|
|
downloadClient: 'qbittorrent',
|
|
downloadClientUrl: '',
|
|
downloadClientUsername: 'admin',
|
|
downloadClientPassword: '',
|
|
remotePathMappingEnabled: false,
|
|
remotePath: '',
|
|
localPath: '',
|
|
downloadDir: '/downloads',
|
|
mediaDir: '/media/audiobooks',
|
|
metadataTaggingEnabled: true,
|
|
bookdateProvider: 'openai',
|
|
bookdateApiKey: '',
|
|
bookdateModel: '',
|
|
bookdateConfigured: false,
|
|
validated: {
|
|
plex: false,
|
|
prowlarr: false,
|
|
downloadClient: false,
|
|
paths: false,
|
|
},
|
|
});
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [setupHasAdminTokens, setSetupHasAdminTokens] = useState(false);
|
|
|
|
// Calculate total steps based on backend mode and auth method
|
|
const getTotalSteps = () => {
|
|
if (state.backendMode === 'plex') {
|
|
// Plex mode: Welcome, Backend, Admin, Plex, Prowlarr, Download, Paths, BookDate, Review, Finalize
|
|
return 10;
|
|
} else {
|
|
// ABS mode: base steps + conditional auth steps
|
|
let steps = 10; // Welcome, Backend, ABS, Auth Method, Prowlarr, Download, Paths, BookDate, Review, Finalize
|
|
if (state.authMethod === 'oidc' || state.authMethod === 'both') {
|
|
steps += 1; // OIDC Config
|
|
}
|
|
if (state.authMethod === 'manual' || state.authMethod === 'both') {
|
|
steps += 2; // Registration Settings + Admin Account
|
|
}
|
|
return steps;
|
|
}
|
|
};
|
|
|
|
const totalSteps = getTotalSteps();
|
|
|
|
const updateState = (updates: Partial<SetupState>) => {
|
|
setState((prev) => ({ ...prev, ...updates }));
|
|
};
|
|
|
|
const updateField = (field: string, value: any) => {
|
|
setState((prev) => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
const goToStep = (step: number) => {
|
|
setState((prev) => ({ ...prev, currentStep: step }));
|
|
setError(null);
|
|
};
|
|
|
|
const completeSetup = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const payload: any = {
|
|
backendMode: state.backendMode,
|
|
prowlarr: {
|
|
url: state.prowlarrUrl,
|
|
api_key: state.prowlarrApiKey,
|
|
indexers: state.prowlarrIndexers,
|
|
},
|
|
downloadClient: {
|
|
type: state.downloadClient,
|
|
url: state.downloadClientUrl,
|
|
username: state.downloadClientUsername,
|
|
password: state.downloadClientPassword,
|
|
remotePathMappingEnabled: state.remotePathMappingEnabled,
|
|
remotePath: state.remotePath,
|
|
localPath: state.localPath,
|
|
},
|
|
paths: {
|
|
download_dir: state.downloadDir,
|
|
media_dir: state.mediaDir,
|
|
metadata_tagging_enabled: state.metadataTaggingEnabled,
|
|
},
|
|
bookdate: state.bookdateConfigured ? {
|
|
provider: state.bookdateProvider,
|
|
apiKey: state.bookdateApiKey,
|
|
model: state.bookdateModel,
|
|
} : null,
|
|
};
|
|
|
|
if (state.backendMode === 'plex') {
|
|
// Plex mode configuration
|
|
payload.admin = {
|
|
username: state.adminUsername,
|
|
password: state.adminPassword,
|
|
};
|
|
payload.plex = {
|
|
url: state.plexUrl,
|
|
token: state.plexToken,
|
|
audiobook_library_id: state.plexLibraryId,
|
|
};
|
|
} else {
|
|
// Audiobookshelf mode configuration
|
|
payload.audiobookshelf = {
|
|
server_url: state.absUrl,
|
|
api_token: state.absApiToken,
|
|
library_id: state.absLibraryId,
|
|
};
|
|
|
|
payload.authMethod = state.authMethod;
|
|
|
|
// OIDC configuration
|
|
if (state.authMethod === 'oidc' || state.authMethod === 'both') {
|
|
// Helper function to parse comma-separated strings into JSON arrays
|
|
const parseCommaSeparatedToArray = (str: string): string => {
|
|
if (!str || str.trim() === '') return '[]';
|
|
const items = str.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
|
return JSON.stringify(items);
|
|
};
|
|
|
|
payload.oidc = {
|
|
provider_name: state.oidcProviderName,
|
|
issuer_url: state.oidcIssuerUrl,
|
|
client_id: state.oidcClientId,
|
|
client_secret: state.oidcClientSecret,
|
|
access_control_method: state.oidcAccessControlMethod,
|
|
access_group_claim: state.oidcAccessGroupClaim,
|
|
access_group_value: state.oidcAccessGroupValue,
|
|
allowed_emails: parseCommaSeparatedToArray(state.oidcAllowedEmails),
|
|
allowed_usernames: parseCommaSeparatedToArray(state.oidcAllowedUsernames),
|
|
admin_claim_enabled: state.oidcAdminClaimEnabled ? 'true' : 'false',
|
|
admin_claim_name: state.oidcAdminClaimName,
|
|
admin_claim_value: state.oidcAdminClaimValue,
|
|
};
|
|
}
|
|
|
|
// Manual registration configuration
|
|
if (state.authMethod === 'manual' || state.authMethod === 'both') {
|
|
payload.registration = {
|
|
enabled: true,
|
|
require_admin_approval: state.requireAdminApproval,
|
|
};
|
|
|
|
// Create admin account for manual auth
|
|
payload.admin = {
|
|
username: state.adminUsername,
|
|
password: state.adminPassword,
|
|
};
|
|
}
|
|
}
|
|
|
|
const response = await fetch('/api/setup/complete', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(data.message || 'Failed to complete setup');
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Store admin auth tokens (if provided)
|
|
if (data.accessToken && data.refreshToken) {
|
|
// Clear any old tokens first to avoid conflicts
|
|
localStorage.clear();
|
|
|
|
localStorage.setItem('accessToken', data.accessToken);
|
|
localStorage.setItem('refreshToken', data.refreshToken);
|
|
localStorage.setItem('user', JSON.stringify(data.user));
|
|
|
|
// Mark that we have admin tokens for FinalizeStep
|
|
setSetupHasAdminTokens(true);
|
|
|
|
// Go to finalize step to run initial jobs
|
|
goToStep(totalSteps);
|
|
} else {
|
|
// OIDC-only mode - clear localStorage to remove stale tokens
|
|
localStorage.clear();
|
|
|
|
// Mark that we don't have admin tokens
|
|
setSetupHasAdminTokens(false);
|
|
|
|
// Go to finalize step (will show OIDC-only UI)
|
|
goToStep(totalSteps);
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Setup failed');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const renderStep = () => {
|
|
let currentStepNumber = 1;
|
|
|
|
// Step 1: Welcome
|
|
if (state.currentStep === currentStepNumber) {
|
|
return <WelcomeStep onNext={() => goToStep(currentStepNumber + 1)} />;
|
|
}
|
|
currentStepNumber++;
|
|
|
|
// Step 2: Backend Selection
|
|
if (state.currentStep === currentStepNumber) {
|
|
return (
|
|
<BackendSelectionStep
|
|
value={state.backendMode}
|
|
onChange={(value) => updateField('backendMode', value)}
|
|
onNext={() => goToStep(currentStepNumber + 1)}
|
|
onBack={() => goToStep(currentStepNumber - 1)}
|
|
/>
|
|
);
|
|
}
|
|
currentStepNumber++;
|
|
|
|
// Conditional flow based on backend mode
|
|
if (state.backendMode === 'plex') {
|
|
// Plex Mode: Admin → Plex → Prowlarr → Download → Paths → BookDate → Review → Finalize
|
|
|
|
// Step 3: Admin Account
|
|
if (state.currentStep === currentStepNumber) {
|
|
return (
|
|
<AdminAccountStep
|
|
adminUsername={state.adminUsername}
|
|
adminPassword={state.adminPassword}
|
|
onUpdate={updateField}
|
|
onNext={() => goToStep(currentStepNumber + 1)}
|
|
onBack={() => goToStep(currentStepNumber - 1)}
|
|
/>
|
|
);
|
|
}
|
|
currentStepNumber++;
|
|
|
|
// Step 4: Plex
|
|
if (state.currentStep === currentStepNumber) {
|
|
return (
|
|
<PlexStep
|
|
plexUrl={state.plexUrl}
|
|
plexToken={state.plexToken}
|
|
plexLibraryId={state.plexLibraryId}
|
|
onUpdate={updateField}
|
|
onNext={() => goToStep(currentStepNumber + 1)}
|
|
onBack={() => goToStep(currentStepNumber - 1)}
|
|
/>
|
|
);
|
|
}
|
|
currentStepNumber++;
|
|
} else {
|
|
// Audiobookshelf Mode: ABS → Auth Method → [OIDC Config] → [Registration Settings] → [Admin Account] → Prowlarr → ...
|
|
|
|
// Step 3: Audiobookshelf
|
|
if (state.currentStep === currentStepNumber) {
|
|
return (
|
|
<AudiobookshelfStep
|
|
absUrl={state.absUrl}
|
|
absApiToken={state.absApiToken}
|
|
absLibraryId={state.absLibraryId}
|
|
onUpdate={updateField}
|
|
onNext={() => goToStep(currentStepNumber + 1)}
|
|
onBack={() => goToStep(currentStepNumber - 1)}
|
|
/>
|
|
);
|
|
}
|
|
currentStepNumber++;
|
|
|
|
// Step 4: Auth Method Selection
|
|
if (state.currentStep === currentStepNumber) {
|
|
return (
|
|
<AuthMethodStep
|
|
value={state.authMethod}
|
|
onChange={(value) => updateField('authMethod', value)}
|
|
onNext={() => goToStep(currentStepNumber + 1)}
|
|
onBack={() => goToStep(currentStepNumber - 1)}
|
|
/>
|
|
);
|
|
}
|
|
currentStepNumber++;
|
|
|
|
// Conditional: OIDC Config (if authMethod is 'oidc' or 'both')
|
|
if (state.authMethod === 'oidc' || state.authMethod === 'both') {
|
|
if (state.currentStep === currentStepNumber) {
|
|
return (
|
|
<OIDCConfigStep
|
|
oidcProviderName={state.oidcProviderName}
|
|
oidcIssuerUrl={state.oidcIssuerUrl}
|
|
oidcClientId={state.oidcClientId}
|
|
oidcClientSecret={state.oidcClientSecret}
|
|
oidcAccessControlMethod={state.oidcAccessControlMethod}
|
|
oidcAccessGroupClaim={state.oidcAccessGroupClaim}
|
|
oidcAccessGroupValue={state.oidcAccessGroupValue}
|
|
oidcAllowedEmails={state.oidcAllowedEmails}
|
|
oidcAllowedUsernames={state.oidcAllowedUsernames}
|
|
oidcAdminClaimEnabled={state.oidcAdminClaimEnabled}
|
|
oidcAdminClaimName={state.oidcAdminClaimName}
|
|
oidcAdminClaimValue={state.oidcAdminClaimValue}
|
|
onUpdate={updateField}
|
|
onNext={() => goToStep(currentStepNumber + 1)}
|
|
onBack={() => goToStep(currentStepNumber - 1)}
|
|
/>
|
|
);
|
|
}
|
|
currentStepNumber++;
|
|
}
|
|
|
|
// Conditional: Registration Settings (if authMethod is 'manual' or 'both')
|
|
if (state.authMethod === 'manual' || state.authMethod === 'both') {
|
|
if (state.currentStep === currentStepNumber) {
|
|
return (
|
|
<RegistrationSettingsStep
|
|
requireAdminApproval={state.requireAdminApproval}
|
|
onUpdate={updateField}
|
|
onNext={() => goToStep(currentStepNumber + 1)}
|
|
onBack={() => goToStep(currentStepNumber - 1)}
|
|
/>
|
|
);
|
|
}
|
|
currentStepNumber++;
|
|
|
|
// Step: Admin Account (for manual auth)
|
|
if (state.currentStep === currentStepNumber) {
|
|
return (
|
|
<AdminAccountStep
|
|
adminUsername={state.adminUsername}
|
|
adminPassword={state.adminPassword}
|
|
onUpdate={updateField}
|
|
onNext={() => goToStep(currentStepNumber + 1)}
|
|
onBack={() => goToStep(currentStepNumber - 1)}
|
|
/>
|
|
);
|
|
}
|
|
currentStepNumber++;
|
|
}
|
|
}
|
|
|
|
// Common steps for both modes: Prowlarr → Download → Paths → BookDate → Review → Finalize
|
|
|
|
// Step: Prowlarr
|
|
if (state.currentStep === currentStepNumber) {
|
|
return (
|
|
<ProwlarrStep
|
|
prowlarrUrl={state.prowlarrUrl}
|
|
prowlarrApiKey={state.prowlarrApiKey}
|
|
onUpdate={updateField}
|
|
onNext={() => goToStep(currentStepNumber + 1)}
|
|
onBack={() => goToStep(currentStepNumber - 1)}
|
|
/>
|
|
);
|
|
}
|
|
currentStepNumber++;
|
|
|
|
// Step: Download Client
|
|
if (state.currentStep === currentStepNumber) {
|
|
return (
|
|
<DownloadClientStep
|
|
downloadClient={state.downloadClient}
|
|
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)}
|
|
/>
|
|
);
|
|
}
|
|
currentStepNumber++;
|
|
|
|
// Step: Paths
|
|
if (state.currentStep === currentStepNumber) {
|
|
return (
|
|
<PathsStep
|
|
downloadDir={state.downloadDir}
|
|
mediaDir={state.mediaDir}
|
|
metadataTaggingEnabled={state.metadataTaggingEnabled}
|
|
onUpdate={updateField}
|
|
onNext={() => goToStep(currentStepNumber + 1)}
|
|
onBack={() => goToStep(currentStepNumber - 1)}
|
|
/>
|
|
);
|
|
}
|
|
currentStepNumber++;
|
|
|
|
// Step: BookDate
|
|
if (state.currentStep === currentStepNumber) {
|
|
return (
|
|
<BookDateStep
|
|
bookdateProvider={state.bookdateProvider}
|
|
bookdateApiKey={state.bookdateApiKey}
|
|
bookdateModel={state.bookdateModel}
|
|
bookdateConfigured={state.bookdateConfigured}
|
|
onUpdate={updateField}
|
|
onNext={() => goToStep(currentStepNumber + 1)}
|
|
onSkip={() => goToStep(currentStepNumber + 1)}
|
|
onBack={() => goToStep(currentStepNumber - 1)}
|
|
/>
|
|
);
|
|
}
|
|
currentStepNumber++;
|
|
|
|
// Step: Review
|
|
if (state.currentStep === currentStepNumber) {
|
|
return (
|
|
<ReviewStep
|
|
config={state}
|
|
loading={loading}
|
|
error={error}
|
|
onComplete={completeSetup}
|
|
onBack={() => goToStep(currentStepNumber - 1)}
|
|
/>
|
|
);
|
|
}
|
|
currentStepNumber++;
|
|
|
|
// Step: Finalize
|
|
if (state.currentStep === currentStepNumber) {
|
|
return (
|
|
<FinalizeStep
|
|
hasAdminTokens={setupHasAdminTokens}
|
|
onComplete={() => {
|
|
// OIDC-only mode: redirect to login
|
|
if (!setupHasAdminTokens) {
|
|
window.location.href = '/login';
|
|
return;
|
|
}
|
|
|
|
// Normal mode: Force full page reload to initialize auth context with new tokens
|
|
window.location.href = '/';
|
|
}}
|
|
onBack={() => goToStep(currentStepNumber - 1)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
return (
|
|
<WizardLayout
|
|
currentStep={state.currentStep}
|
|
totalSteps={totalSteps}
|
|
backendMode={state.backendMode}
|
|
authMethod={state.authMethod}
|
|
>
|
|
{renderStep()}
|
|
</WizardLayout>
|
|
);
|
|
}
|