0.12.0-15 Fix DVD Review

This commit is contained in:
2026-03-24 07:05:45 +00:00
parent 3e3f3231be
commit 76b020d5c0
30 changed files with 5148 additions and 44 deletions
@@ -0,0 +1,440 @@
import { useEffect, useState } from 'react';
import { Button } from 'primereact/button';
import { Dropdown } from 'primereact/dropdown';
import { InputNumber } from 'primereact/inputnumber';
import { SelectButton } from 'primereact/selectbutton';
import { ProgressBar } from 'primereact/progressbar';
import { Tag } from 'primereact/tag';
import { Message } from 'primereact/message';
import { api } from '../api/client';
const VIDEO_OUTPUT_FORMATS = [
{ label: 'MKV', value: 'mkv' },
{ label: 'MP4', value: 'mp4' },
{ label: 'M4V', value: 'm4v' }
];
const AUDIO_OUTPUT_FORMATS = [
{ label: 'FLAC', value: 'flac' },
{ label: 'MP3', value: 'mp3' },
{ label: 'AAC', value: 'aac' },
{ label: 'Opus', value: 'opus' },
{ label: 'OGG', value: 'ogg' },
{ label: 'WAV', value: 'wav' }
];
const MP3_MODES = [
{ label: 'CBR', value: 'cbr' },
{ label: 'VBR', value: 'vbr' }
];
function getStatusMeta(status) {
const s = String(status || '').toUpperCase();
const map = {
READY_TO_START: { label: 'Bereit', severity: 'warning', icon: 'pi-clock' },
ANALYZING: { label: 'Analysiere', severity: 'info', icon: 'pi-spin pi-spinner' },
RIPPING: { label: 'Rippen', severity: 'info', icon: 'pi-spin pi-spinner' },
ENCODING: { label: 'Encoding', severity: 'info', icon: 'pi-spin pi-spinner' },
MEDIAINFO_CHECK:{ label: 'MediaInfo', severity: 'info', icon: 'pi-spin pi-spinner' },
DONE: { label: 'Fertig', severity: 'success', icon: 'pi-check' },
ERROR: { label: 'Fehler', severity: 'danger', icon: 'pi-exclamation-triangle' },
CANCELLED: { label: 'Abgebrochen', severity: 'secondary', icon: 'pi-times' }
};
return map[s] || { label: s, severity: 'secondary', icon: 'pi-question' };
}
function mediaTypeBadge(type) {
const map = {
video: { label: 'Video', severity: 'info' },
audio: { label: 'Audio', severity: 'success' },
iso: { label: 'ISO', severity: 'warning' }
};
const m = map[type] || { label: type || '?', severity: 'secondary' };
return <Tag value={m.label} severity={m.severity} style={{ fontSize: 11 }} />;
}
function isActiveStatus(status) {
return ['ANALYZING', 'RIPPING', 'ENCODING', 'MEDIAINFO_CHECK'].includes(String(status || '').toUpperCase());
}
function isTerminal(status) {
return ['DONE', 'ERROR', 'CANCELLED'].includes(String(status || '').toUpperCase());
}
/**
* Inline-Konfigurationsbereich für READY_TO_START Jobs.
* Zeigt alle nötigen Optionen direkt in der Karte an.
*/
function InlineConfig({ job, plan, onStarted }) {
const converterMediaType = plan?.converterMediaType || 'video';
const isVideo = converterMediaType === 'video' || converterMediaType === 'iso';
const isAudio = converterMediaType === 'audio';
const [outputFormat, setOutputFormat] = useState(isAudio ? 'flac' : 'mkv');
const [userPresets, setUserPresets] = useState([]);
const [hbPresets, setHbPresets] = useState([]);
const [selectedUserPreset, setSelectedUserPreset] = useState(null);
const [selectedHbPreset, setSelectedHbPreset] = useState('');
const [audioFormatOptions, setAudioFormatOptions] = useState({
flacCompression: 5,
mp3Mode: 'cbr',
mp3Bitrate: 192,
mp3Quality: 4,
aacBitrate: 256,
opusBitrate: 160,
oggQuality: 6
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
// Presets laden
useEffect(() => {
if (!isVideo) return;
api.getUserPresets?.('video')
.then((d) => setUserPresets(Array.isArray(d?.presets) ? d.presets : []))
.catch(() => setUserPresets([]));
api.getHandBrakePresets?.()
.then((d) => setHbPresets(Array.isArray(d?.presets) ? d.presets : []))
.catch(() => setHbPresets([]));
}, [isVideo]);
const handleStart = async () => {
setBusy(true);
setError(null);
try {
let userPreset = null;
if (selectedUserPreset) {
const found = userPresets.find((p) => p.id === selectedUserPreset);
if (found) userPreset = { id: found.id, handbrakePreset: found.handbrake_preset || null, extraArgs: found.extra_args || '' };
} else if (selectedHbPreset) {
userPreset = { handbrakePreset: selectedHbPreset, extraArgs: '' };
}
await api.startConverterJob(job.id, {
converterMediaType,
outputFormat,
userPreset,
audioFormatOptions: isAudio ? audioFormatOptions : undefined
});
onStarted?.(job.id);
} catch (err) {
setError(err.message || 'Fehler beim Starten.');
setBusy(false);
}
};
const outputFormats = isVideo ? VIDEO_OUTPUT_FORMATS : AUDIO_OUTPUT_FORMATS;
return (
<div className="cjc-config">
{error && (
<Message severity="error" text={error} style={{ marginBottom: 10, width: '100%' }} />
)}
<div className="cjc-config-row">
{/* Medientyp (read-only) */}
<div className="cjc-config-field">
<label className="cjc-config-label">Medientyp</label>
<div className="cjc-config-value">
{mediaTypeBadge(converterMediaType)}
{converterMediaType === 'iso' && (
<small style={{ marginLeft: 6, color: 'var(--text-color-secondary)' }}>MakeMKV HandBrake</small>
)}
</div>
</div>
{/* Ausgabeformat */}
<div className="cjc-config-field">
<label className="cjc-config-label">Ausgabeformat</label>
<Dropdown
value={outputFormat}
options={outputFormats}
onChange={(e) => setOutputFormat(e.value)}
style={{ width: '100%' }}
/>
</div>
</div>
{/* Video: Presets */}
{isVideo && (
<div className="cjc-config-row">
<div className="cjc-config-field">
<label className="cjc-config-label">User-Preset</label>
<Dropdown
value={selectedUserPreset}
options={[
{ label: '— Kein User-Preset —', value: null },
...userPresets.map((p) => ({ label: p.name, value: p.id }))
]}
onChange={(e) => { setSelectedUserPreset(e.value); if (e.value) setSelectedHbPreset(''); }}
style={{ width: '100%' }}
/>
</div>
{!selectedUserPreset && (
<div className="cjc-config-field">
<label className="cjc-config-label">HandBrake-Preset</label>
<Dropdown
value={selectedHbPreset}
options={[
{ label: '— Kein Preset (Standard) —', value: '' },
...hbPresets.map((p) => ({ label: p.category ? `[${p.category}] ${p.name}` : p.name, value: p.name }))
]}
onChange={(e) => setSelectedHbPreset(e.value)}
filter
placeholder="Preset auswählen …"
style={{ width: '100%' }}
/>
</div>
)}
</div>
)}
{/* Audio: Format-Optionen */}
{isAudio && (
<div className="cjc-config-row">
{outputFormat === 'flac' && (
<div className="cjc-config-field">
<label className="cjc-config-label">Komprimierung (012)</label>
<InputNumber
value={audioFormatOptions.flacCompression}
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, flacCompression: e.value ?? 5 }))}
min={0} max={12} step={1}
style={{ width: '100%' }}
/>
</div>
)}
{outputFormat === 'mp3' && (
<>
<div className="cjc-config-field">
<label className="cjc-config-label">Modus</label>
<SelectButton
value={audioFormatOptions.mp3Mode}
options={MP3_MODES}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Mode: e.value || 'cbr' }))}
/>
</div>
<div className="cjc-config-field">
{audioFormatOptions.mp3Mode === 'cbr' ? (
<>
<label className="cjc-config-label">Bitrate (kbps)</label>
<Dropdown
value={audioFormatOptions.mp3Bitrate}
options={[128, 160, 192, 224, 256, 320].map((v) => ({ label: `${v} kbps`, value: v }))}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Bitrate: e.value }))}
style={{ width: '100%' }}
/>
</>
) : (
<>
<label className="cjc-config-label">VBR Qualität (0=beste, 9=schlechteste)</label>
<InputNumber
value={audioFormatOptions.mp3Quality}
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, mp3Quality: e.value ?? 4 }))}
min={0} max={9} step={1}
style={{ width: '100%' }}
/>
</>
)}
</div>
</>
)}
{outputFormat === 'aac' && (
<div className="cjc-config-field">
<label className="cjc-config-label">Bitrate (kbps)</label>
<Dropdown
value={audioFormatOptions.aacBitrate}
options={[128, 160, 192, 256, 320].map((v) => ({ label: `${v} kbps`, value: v }))}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, aacBitrate: e.value }))}
style={{ width: '100%' }}
/>
</div>
)}
{outputFormat === 'opus' && (
<div className="cjc-config-field">
<label className="cjc-config-label">Bitrate (kbps)</label>
<Dropdown
value={audioFormatOptions.opusBitrate}
options={[64, 96, 128, 160, 192, 256].map((v) => ({ label: `${v} kbps`, value: v }))}
onChange={(e) => setAudioFormatOptions((p) => ({ ...p, opusBitrate: e.value }))}
style={{ width: '100%' }}
/>
</div>
)}
{outputFormat === 'ogg' && (
<div className="cjc-config-field">
<label className="cjc-config-label">Qualität (-1 bis 10)</label>
<InputNumber
value={audioFormatOptions.oggQuality}
onValueChange={(e) => setAudioFormatOptions((p) => ({ ...p, oggQuality: e.value ?? 6 }))}
min={-1} max={10} step={1}
style={{ width: '100%' }}
/>
</div>
)}
</div>
)}
<div className="cjc-config-actions">
<Button
label="Encoding starten"
icon={busy ? 'pi pi-spin pi-spinner' : 'pi pi-play'}
disabled={busy}
onClick={handleStart}
/>
</div>
</div>
);
}
/**
* Status-Karte für einen einzelnen Converter-Job.
* Zeigt Konfiguration inline an wenn der Job READY_TO_START ist.
*/
export default function ConverterJobCard({
job,
jobProgress,
onStarted,
onDeleted,
onCancelled
}) {
const [cancelBusy, setCancelBusy] = useState(false);
const [deleteBusy, setDeleteBusy] = useState(false);
const plan = (() => {
try { return JSON.parse(job.encode_plan_json || '{}'); } catch (_e) { return {}; }
})();
const title = job.title || job.detected_title || 'Converter Job';
const status = job.status || 'UNKNOWN';
const statusMeta = getStatusMeta(status);
const converterMediaType = plan?.converterMediaType;
const active = isActiveStatus(status);
const terminal = isTerminal(status);
const isReady = String(status).toUpperCase() === 'READY_TO_START';
const liveProgress = jobProgress?.progress ?? null;
const liveEta = jobProgress?.eta || null;
const liveStatusText = jobProgress?.statusText || null;
const progressValue = liveProgress !== null ? Math.round(liveProgress) : null;
const handleCancel = async () => {
setCancelBusy(true);
try {
await api.cancelConverterJob(job.id);
onCancelled?.(job.id);
} catch (err) {
console.error('Cancel error:', err);
} finally {
setCancelBusy(false);
}
};
const handleDelete = async () => {
if (!window.confirm(`Job "${title}" wirklich löschen?`)) return;
setDeleteBusy(true);
try {
await api.deleteConverterJob(job.id);
onDeleted?.(job.id);
} catch (err) {
console.error('Delete error:', err);
} finally {
setDeleteBusy(false);
}
};
return (
<div className={`converter-job-card status-${status.toLowerCase()}${isReady ? ' is-ready' : ''}`}>
{/* Header */}
<div className="converter-job-card-header">
<div className="converter-job-card-title-row">
<span className="converter-job-card-title" title={title}>{title}</span>
{converterMediaType && mediaTypeBadge(converterMediaType)}
<Tag
value={statusMeta.label}
severity={statusMeta.severity}
icon={`pi ${statusMeta.icon}`}
/>
</div>
{!isReady && plan?.outputFormat && (
<div className="converter-job-card-meta">
<small>Format: <strong>{String(plan.outputFormat).toUpperCase()}</strong></small>
{plan?.inputPath && (
<small title={plan.inputPath} style={{ marginLeft: 8 }}>
{plan.inputPath.split('/').pop()}
</small>
)}
</div>
)}
{isReady && plan?.inputPath && (
<div className="converter-job-card-meta">
<small title={plan.inputPath}> {plan.inputPath.split('/').pop()}</small>
</div>
)}
</div>
{/* Inline-Konfiguration für READY_TO_START */}
{isReady && (
<InlineConfig job={job} plan={plan} onStarted={onStarted} />
)}
{/* Fortschrittsbalken */}
{active && (
<div className="converter-job-card-progress">
{progressValue !== null ? (
<ProgressBar value={progressValue} style={{ height: 6 }} displayValueTemplate={() => `${progressValue}%`} />
) : (
<ProgressBar mode="indeterminate" style={{ height: 6 }} />
)}
{(liveStatusText || liveEta) && (
<div className="converter-job-card-progress-meta">
{liveStatusText && <small>{liveStatusText}</small>}
{liveEta && <small>ETA: {liveEta}</small>}
</div>
)}
</div>
)}
{/* Fehler */}
{status === 'ERROR' && job.error_message && (
<div className="converter-job-card-error">
<small>{job.error_message}</small>
</div>
)}
{/* Ausgabe-Pfad */}
{terminal && job.output_path && (
<div className="converter-job-card-output">
<small title={job.output_path}> {job.output_path.split('/').slice(-2).join('/')}</small>
</div>
)}
{/* Aktions-Buttons */}
<div className="converter-job-card-actions">
{active && (
<Button
label="Abbrechen"
icon={cancelBusy ? 'pi pi-spin pi-spinner' : 'pi pi-times'}
size="small"
severity="warning"
outlined
disabled={cancelBusy}
onClick={handleCancel}
/>
)}
{terminal && (
<Button
label="Löschen"
icon={deleteBusy ? 'pi pi-spin pi-spinner' : 'pi pi-trash'}
size="small"
severity="danger"
outlined
disabled={deleteBusy}
onClick={handleDelete}
/>
)}
</div>
</div>
);
}