mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50: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:
@@ -18,6 +18,12 @@ interface PathsTabProps {
|
||||
onValidationChange: (isValid: boolean) => void;
|
||||
}
|
||||
|
||||
interface TemplatePreview {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
previewPaths?: string[];
|
||||
}
|
||||
|
||||
export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps) {
|
||||
const { testing, testResult, updatePath, testPaths } = usePathsSettings({
|
||||
paths,
|
||||
@@ -25,31 +31,52 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
||||
onValidationChange,
|
||||
});
|
||||
|
||||
// Live preview state (client-side validation)
|
||||
const [livePreview, setLivePreview] = useState<{
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
previewPaths?: string[];
|
||||
} | null>(null);
|
||||
// Live preview state for audiobook template
|
||||
const [audiobookPreview, setAudiobookPreview] = useState<TemplatePreview | null>(null);
|
||||
|
||||
// Update live preview whenever template changes
|
||||
// Live preview state for ebook template
|
||||
const [ebookPreview, setEbookPreview] = useState<TemplatePreview | null>(null);
|
||||
|
||||
// Update audiobook live preview whenever template changes
|
||||
useEffect(() => {
|
||||
const template = paths.audiobookPathTemplate || '{author}/{title} {asin}';
|
||||
const validation = validateTemplate(template);
|
||||
|
||||
if (validation.valid) {
|
||||
setLivePreview({
|
||||
setAudiobookPreview({
|
||||
isValid: true,
|
||||
previewPaths: generateMockPreviews(template),
|
||||
});
|
||||
} else {
|
||||
setLivePreview({
|
||||
setAudiobookPreview({
|
||||
isValid: false,
|
||||
error: validation.error,
|
||||
});
|
||||
}
|
||||
}, [paths.audiobookPathTemplate]);
|
||||
|
||||
// Update ebook live preview whenever template changes
|
||||
useEffect(() => {
|
||||
const template = paths.ebookPathTemplate || '{author}/{title} {asin}';
|
||||
const validation = validateTemplate(template);
|
||||
|
||||
if (validation.valid) {
|
||||
setEbookPreview({
|
||||
isValid: true,
|
||||
previewPaths: generateMockPreviews(template),
|
||||
});
|
||||
} else {
|
||||
setEbookPreview({
|
||||
isValid: false,
|
||||
error: validation.error,
|
||||
});
|
||||
}
|
||||
}, [paths.ebookPathTemplate]);
|
||||
|
||||
const audiobookTemplate = paths.audiobookPathTemplate || '{author}/{title} {asin}';
|
||||
const ebookTemplate = paths.ebookPathTemplate || '{author}/{title} {asin}';
|
||||
const ebookMatchesAudiobook = ebookTemplate === audiobookTemplate;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
@@ -74,7 +101,7 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Temporary location for torrent downloads (kept for seeding)
|
||||
Temporary location for downloads before they are organized into the media library
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -111,61 +138,24 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
||||
Customize how audiobooks are organized within the media directory
|
||||
</p>
|
||||
|
||||
{/* Variable Reference Panel */}
|
||||
<div className="mt-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-3">
|
||||
Available Variables
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{author}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book author</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{title}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book title</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{narrator}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Narrator name</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{year}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Release year</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{asin}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Audible ASIN</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{series}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book series name</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{seriesPart}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Series part/position</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Preview - Client-side validation */}
|
||||
{livePreview && !livePreview.isValid && (
|
||||
{/* Audiobook Validation Error */}
|
||||
{audiobookPreview && !audiobookPreview.isValid && (
|
||||
<div className="mt-3 p-3 rounded-lg text-sm flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200">
|
||||
<span className="flex-shrink-0 mt-0.5">✗</span>
|
||||
<div className="flex-1">
|
||||
<span>{livePreview.error || 'Invalid template format'}</span>
|
||||
<span>{audiobookPreview.error || 'Invalid template format'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live Preview Examples - Show while editing */}
|
||||
{livePreview && livePreview.isValid && livePreview.previewPaths && (
|
||||
{/* Audiobook Preview Examples */}
|
||||
{audiobookPreview && audiobookPreview.isValid && audiobookPreview.previewPaths && (
|
||||
<div className="mt-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Preview Examples
|
||||
</h4>
|
||||
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
{livePreview.previewPaths.map((preview, index) => (
|
||||
{audiobookPreview.previewPaths.map((preview, index) => (
|
||||
<div key={index} className="text-xs">
|
||||
{paths.mediaDir || '/media/audiobooks'}/{preview}
|
||||
</div>
|
||||
@@ -175,6 +165,96 @@ export function PathsTab({ paths, onChange, onValidationChange }: PathsTabProps)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ebook Organization Template */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Ebook Organization Template
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={paths.ebookPathTemplate || '{author}/{title} {asin}'}
|
||||
onChange={(e) => updatePath('ebookPathTemplate', e.target.value)}
|
||||
placeholder="{author}/{title} {asin}"
|
||||
className="font-mono flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => updatePath('ebookPathTemplate', paths.audiobookPathTemplate || '{author}/{title} {asin}')}
|
||||
disabled={ebookMatchesAudiobook}
|
||||
className="whitespace-nowrap text-sm"
|
||||
>
|
||||
Match Audiobook
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Customize how ebooks are organized within the media directory
|
||||
</p>
|
||||
|
||||
{/* Ebook Validation Error */}
|
||||
{ebookPreview && !ebookPreview.isValid && (
|
||||
<div className="mt-3 p-3 rounded-lg text-sm flex items-start gap-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200">
|
||||
<span className="flex-shrink-0 mt-0.5">✗</span>
|
||||
<div className="flex-1">
|
||||
<span>{ebookPreview.error || 'Invalid template format'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ebook Preview Examples */}
|
||||
{ebookPreview && ebookPreview.isValid && ebookPreview.previewPaths && (
|
||||
<div className="mt-3 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Preview Examples
|
||||
</h4>
|
||||
<div className="space-y-1.5 text-sm font-mono text-gray-700 dark:text-gray-300">
|
||||
{ebookPreview.previewPaths.map((preview, index) => (
|
||||
<div key={index} className="text-xs">
|
||||
{paths.mediaDir || '/media/audiobooks'}/{preview}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variable Reference Panel (shared for both templates) */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-3">
|
||||
Available Variables
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm">
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{author}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book author</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{title}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book title</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{narrator}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Narrator name</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{year}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Release year</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{asin}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Audible ASIN</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{series}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Book series name</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-blue-700 dark:text-blue-300 font-mono">{'{seriesPart}'}</code>
|
||||
<span className="text-gray-600 dark:text-gray-400 ml-2">- Series part/position</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata Tagging Toggle */}
|
||||
<div className="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">
|
||||
|
||||
Reference in New Issue
Block a user