Initial commit mit MkDocs-Dokumentation

This commit is contained in:
2026-03-04 14:18:33 +00:00
parent 6115090da1
commit 31d3e36597
97 changed files with 27518 additions and 1 deletions

View File

@@ -0,0 +1,598 @@
import { useEffect, useMemo, useState } from 'react';
import { Card } from 'primereact/card';
import { Tag } from 'primereact/tag';
import { ProgressBar } from 'primereact/progressbar';
import { Button } from 'primereact/button';
import MediaInfoReviewPanel from './MediaInfoReviewPanel';
import { api } from '../api/client';
const severityMap = {
IDLE: 'success',
DISC_DETECTED: 'info',
ANALYZING: 'warning',
METADATA_SELECTION: 'warning',
WAITING_FOR_USER_DECISION: 'warning',
READY_TO_START: 'info',
MEDIAINFO_CHECK: 'warning',
READY_TO_ENCODE: 'info',
RIPPING: 'warning',
ENCODING: 'warning',
FINISHED: 'success',
ERROR: 'danger'
};
function normalizeTitleId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizePlaylistId(value) {
const raw = String(value || '').trim().toLowerCase();
if (!raw) {
return null;
}
const match = raw.match(/(\d{1,5})(?:\.mpls)?$/i);
return match ? String(match[1]).padStart(5, '0') : null;
}
function normalizeTrackId(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.trunc(parsed);
}
function normalizeTrackIdList(values) {
const list = Array.isArray(values) ? values : [];
const seen = new Set();
const output = [];
for (const value of list) {
const normalized = normalizeTrackId(value);
if (normalized === null) {
continue;
}
const key = String(normalized);
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(normalized);
}
return output;
}
function buildDefaultTrackSelection(review) {
const titles = Array.isArray(review?.titles) ? review.titles : [];
const selection = {};
for (const title of titles) {
const titleId = normalizeTitleId(title?.id);
if (!titleId) {
continue;
}
selection[titleId] = {
audioTrackIds: normalizeTrackIdList(
(Array.isArray(title?.audioTracks) ? title.audioTracks : [])
.filter((track) => Boolean(track?.selectedByRule))
.map((track) => track?.id)
),
subtitleTrackIds: normalizeTrackIdList(
(Array.isArray(title?.subtitleTracks) ? title.subtitleTracks : [])
.filter((track) => Boolean(track?.selectedByRule))
.map((track) => track?.id)
)
};
}
return selection;
}
function defaultTrackSelectionForTitle(review, titleId) {
const defaults = buildDefaultTrackSelection(review);
return defaults[titleId] || defaults[String(titleId)] || { audioTrackIds: [], subtitleTrackIds: [] };
}
function buildSettingsMap(categories) {
const map = {};
const list = Array.isArray(categories) ? categories : [];
for (const category of list) {
for (const setting of (Array.isArray(category?.settings) ? category.settings : [])) {
map[setting.key] = setting.value;
}
}
return map;
}
function sanitizeFileName(input) {
return String(input || 'untitled')
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 180);
}
function renderTemplate(template, values) {
return String(template || '${title} (${year})').replace(/\$\{([^}]+)\}/g, (_, key) => {
const value = values[key.trim()];
if (value === undefined || value === null || value === '') {
return 'unknown';
}
return String(value);
});
}
function buildOutputPathPreview(settings, metadata, fallbackJobId = null) {
const movieDir = String(settings?.movie_dir || '').trim();
if (!movieDir) {
return null;
}
const title = metadata?.title || (fallbackJobId ? `job-${fallbackJobId}` : 'job');
const year = metadata?.year || new Date().getFullYear();
const imdbId = metadata?.imdbId || (fallbackJobId ? `job-${fallbackJobId}` : 'noimdb');
const template = settings?.filename_template || '${title} (${year})';
const folderName = sanitizeFileName(renderTemplate('${title} (${year})', { title, year, imdbId }));
const baseName = sanitizeFileName(renderTemplate(template, { title, year, imdbId }));
const ext = String(settings?.output_extension || 'mkv').trim() || 'mkv';
const root = movieDir.replace(/\/+$/g, '');
return `${root}/${folderName}/${baseName}.${ext}`;
}
export default function PipelineStatusCard({
pipeline,
onAnalyze,
onReanalyze,
onStart,
onRestartEncode,
onConfirmReview,
onSelectPlaylist,
onCancel,
onRetry,
busy,
liveJobLog = ''
}) {
const state = pipeline?.state || 'IDLE';
const progress = Number(pipeline?.progress || 0);
const running = state === 'ANALYZING' || state === 'RIPPING' || state === 'ENCODING' || state === 'MEDIAINFO_CHECK';
const retryJobId = pipeline?.context?.jobId;
const selectedMetadata = pipeline?.context?.selectedMetadata || null;
const mediaInfoReview = pipeline?.context?.mediaInfoReview || null;
const playlistAnalysis = pipeline?.context?.playlistAnalysis || null;
const encodeInputPath = pipeline?.context?.inputPath || mediaInfoReview?.encodeInputPath || null;
const reviewConfirmed = Boolean(pipeline?.context?.reviewConfirmed || mediaInfoReview?.reviewConfirmed);
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null);
const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
const [settingsMap, setSettingsMap] = useState({});
useEffect(() => {
let cancelled = false;
const loadSettings = async () => {
try {
const response = await api.getSettings();
if (!cancelled) {
setSettingsMap(buildSettingsMap(response?.categories || []));
}
} catch (_error) {
if (!cancelled) {
setSettingsMap({});
}
}
};
loadSettings();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const fromReview = normalizeTitleId(mediaInfoReview?.encodeInputTitleId);
setSelectedEncodeTitleId(fromReview);
setTrackSelectionByTitle(buildDefaultTrackSelection(mediaInfoReview));
}, [mediaInfoReview?.encodeInputTitleId, mediaInfoReview?.generatedAt, retryJobId]);
useEffect(() => {
const currentTitleId = normalizeTitleId(selectedEncodeTitleId);
if (!currentTitleId) {
return;
}
setTrackSelectionByTitle((prev) => {
if (prev?.[currentTitleId] || prev?.[String(currentTitleId)]) {
return prev;
}
const defaults = buildDefaultTrackSelection(mediaInfoReview);
const fallback = defaults[currentTitleId] || { audioTrackIds: [], subtitleTrackIds: [] };
return {
...prev,
[currentTitleId]: fallback
};
});
}, [selectedEncodeTitleId, mediaInfoReview?.generatedAt]);
const reviewPlaylistDecisionRequired = Boolean(mediaInfoReview?.playlistDecisionRequired);
const hasSelectedEncodeTitle = Boolean(normalizeTitleId(selectedEncodeTitleId));
const canConfirmReview = !reviewPlaylistDecisionRequired || hasSelectedEncodeTitle;
const canStartReadyJob = isPreRipReview
? Boolean(retryJobId)
: Boolean(retryJobId && encodeInputPath);
const canRestartEncodeFromLastSettings = Boolean(
state === 'ERROR'
&& retryJobId
&& pipeline?.context?.canRestartEncodeFromLastSettings
);
const waitingPlaylistRows = useMemo(() => {
const evaluated = Array.isArray(playlistAnalysis?.evaluatedCandidates)
? playlistAnalysis.evaluatedCandidates
: [];
const rows = evaluated.length > 0
? evaluated
: (Array.isArray(pipeline?.context?.playlistCandidates) ? pipeline.context.playlistCandidates : []);
const normalized = rows
.map((item) => {
const playlistId = normalizePlaylistId(item?.playlistId || item?.playlistFile || item);
if (!playlistId) {
return null;
}
const playlistFile = `${playlistId}.mpls`;
const score = Number(item?.score);
const sequenceCoherence = Number(
item?.structuralMetrics?.sequenceCoherence ?? item?.sequenceCoherence
);
const handBrakeTitleId = Number(item?.handBrakeTitleId);
return {
playlistId,
playlistFile,
titleId: Number.isFinite(Number(item?.titleId)) ? Number(item.titleId) : null,
score: Number.isFinite(score) ? score : null,
evaluationLabel: item?.evaluationLabel || null,
segmentCommand: item?.segmentCommand
|| `strings BDMV/PLAYLIST/${playlistId}.mpls | grep m2ts`,
segmentFiles: Array.isArray(item?.segmentFiles) ? item.segmentFiles : [],
sequenceCoherence: Number.isFinite(sequenceCoherence) ? sequenceCoherence : null,
recommended: Boolean(item?.recommended),
handBrakeTitleId: Number.isFinite(handBrakeTitleId) && handBrakeTitleId > 0
? Math.trunc(handBrakeTitleId)
: null,
audioSummary: item?.audioSummary || null,
audioTrackPreview: Array.isArray(item?.audioTrackPreview) ? item.audioTrackPreview : []
};
})
.filter(Boolean);
const dedup = [];
const seen = new Set();
for (const row of normalized) {
if (seen.has(row.playlistId)) {
continue;
}
seen.add(row.playlistId);
dedup.push(row);
}
return dedup;
}, [playlistAnalysis, pipeline?.context?.playlistCandidates]);
useEffect(() => {
if (state !== 'WAITING_FOR_USER_DECISION') {
setSelectedPlaylistId(null);
return;
}
const current = normalizePlaylistId(pipeline?.context?.selectedPlaylist);
if (current) {
setSelectedPlaylistId(current);
return;
}
const recommendedFromRows = waitingPlaylistRows.find((item) => item.recommended)?.playlistId || null;
const recommendedFromAnalysis = normalizePlaylistId(playlistAnalysis?.recommendation?.playlistId);
const fallback = waitingPlaylistRows[0]?.playlistId || null;
setSelectedPlaylistId(recommendedFromRows || recommendedFromAnalysis || fallback);
}, [
state,
retryJobId,
waitingPlaylistRows,
playlistAnalysis?.recommendation?.playlistId,
pipeline?.context?.selectedPlaylist
]);
const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION';
const commandOutputPath = useMemo(
() => buildOutputPathPreview(settingsMap, selectedMetadata, retryJobId),
[settingsMap, selectedMetadata, retryJobId]
);
return (
<Card title="Pipeline Status" subTitle="Live Zustand und Fortschritt">
<div className="status-row">
<Tag value={state} severity={severityMap[state] || 'secondary'} />
<span>{pipeline?.statusText || 'Bereit'}</span>
</div>
{running && (
<div className="progress-wrap">
<ProgressBar value={progress} showValue />
<small>{pipeline?.eta ? `ETA ${pipeline.eta}` : 'ETA unbekannt'}</small>
</div>
)}
{state === 'FINISHED' && (
<div className="progress-wrap">
<ProgressBar value={100} showValue />
</div>
)}
<div className="actions-row">
{(state === 'DISC_DETECTED' || state === 'IDLE') && (
<Button
label="Analyse starten"
icon="pi pi-search"
onClick={onAnalyze}
loading={busy}
/>
)}
{state === 'READY_TO_START' && retryJobId && (
<Button
label="Job starten"
icon="pi pi-play"
severity="success"
onClick={() => onStart(retryJobId)}
loading={busy}
/>
)}
{state === 'READY_TO_ENCODE' && retryJobId && (
<Button
label="Auswahl bestätigen"
icon="pi pi-check"
severity="warning"
outlined
onClick={() => {
const encodeTitleId = normalizeTitleId(selectedEncodeTitleId);
const selectionEntry = encodeTitleId
? (trackSelectionByTitle?.[encodeTitleId] || trackSelectionByTitle?.[String(encodeTitleId)] || null)
: null;
const fallbackSelection = encodeTitleId
? defaultTrackSelectionForTitle(mediaInfoReview, encodeTitleId)
: { audioTrackIds: [], subtitleTrackIds: [] };
const effectiveSelection = selectionEntry || fallbackSelection;
const selectedTrackSelection = encodeTitleId
? {
[encodeTitleId]: {
audioTrackIds: normalizeTrackIdList(effectiveSelection?.audioTrackIds || []),
subtitleTrackIds: normalizeTrackIdList(effectiveSelection?.subtitleTrackIds || [])
}
}
: null;
onConfirmReview(retryJobId, encodeTitleId, selectedTrackSelection);
}}
loading={busy}
disabled={reviewConfirmed || !canConfirmReview}
/>
)}
{playlistDecisionRequiredBeforeStart && retryJobId && (
<Button
label="Playlist übernehmen"
icon="pi pi-check"
severity="warning"
outlined
onClick={() => onSelectPlaylist?.(retryJobId, selectedPlaylistId)}
loading={busy}
disabled={!normalizePlaylistId(selectedPlaylistId)}
/>
)}
{state === 'READY_TO_ENCODE' && retryJobId && (
<Button
label={isPreRipReview ? 'Backup + Encode starten' : 'Encode starten'}
icon="pi pi-play"
severity="success"
onClick={() => onStart(retryJobId)}
loading={busy}
disabled={!canStartReadyJob || !reviewConfirmed}
/>
)}
{running && (
<Button
label="Abbrechen"
icon="pi pi-stop"
severity="danger"
onClick={onCancel}
loading={busy}
/>
)}
{canRestartEncodeFromLastSettings ? (
<Button
label="Encode neu starten"
icon="pi pi-play"
severity="success"
onClick={() => onRestartEncode?.(retryJobId)}
loading={busy}
disabled={!retryJobId}
/>
) : null}
{state === 'ERROR' && retryJobId && (
<Button
label="Retry Rippen"
icon="pi pi-refresh"
severity="warning"
onClick={() => onRetry(retryJobId)}
loading={busy}
/>
)}
{state === 'ERROR' ? (
<Button
label="Disk-Analyse neu starten"
icon="pi pi-search"
severity="secondary"
onClick={onReanalyze || onAnalyze}
loading={busy}
/>
) : null}
</div>
{running ? (
<div className="live-log-block">
<h4>Aktueller Job-Log</h4>
<pre className="log-box">{liveJobLog || 'Noch keine Log-Ausgabe vorhanden.'}</pre>
</div>
) : null}
{playlistDecisionRequiredBeforeStart ? (
<div className="playlist-decision-block">
<h3>Playlist-Auswahl erforderlich</h3>
<small>
Metadaten sind abgeschlossen. Vor Start muss ein Titel/Playlist manuell per Checkbox gewählt werden.
</small>
{waitingPlaylistRows.length > 0 ? (
<div className="playlist-decision-list">
{waitingPlaylistRows.map((row) => (
<div key={row.playlistId} className="playlist-decision-item">
<label className="readonly-check-row">
<input
type="checkbox"
checked={normalizePlaylistId(selectedPlaylistId) === row.playlistId}
onChange={() => {
const next = normalizePlaylistId(selectedPlaylistId) === row.playlistId ? null : row.playlistId;
setSelectedPlaylistId(next);
}}
/>
<span>
{row.playlistFile}
{row.titleId !== null ? ` | Titel #${row.titleId}` : ''}
{row.score !== null ? ` | Score ${row.score}` : ''}
{row.recommended ? ' | empfohlen' : ''}
</span>
</label>
{row.evaluationLabel ? <small className="track-action-note">{row.evaluationLabel}</small> : null}
{row.sequenceCoherence !== null ? (
<small className="track-action-note">Sequenz-Kohärenz: {row.sequenceCoherence.toFixed(3)}</small>
) : null}
{row.handBrakeTitleId !== null ? (
<small className="track-action-note">HandBrake Titel: -t {row.handBrakeTitleId}</small>
) : null}
{row.audioSummary ? (
<small className="track-action-note">Audio: {row.audioSummary}</small>
) : null}
{row.segmentCommand ? <small className="track-action-note">Info: {row.segmentCommand}</small> : null}
{Array.isArray(row.audioTrackPreview) && row.audioTrackPreview.length > 0 ? (
<details className="playlist-segment-toggle">
<summary>Audio-Spuren anzeigen ({row.audioTrackPreview.length})</summary>
<pre className="playlist-segment-output">{row.audioTrackPreview.join('\n')}</pre>
</details>
) : null}
{Array.isArray(row.segmentFiles) && row.segmentFiles.length > 0 ? (
<details className="playlist-segment-toggle">
<summary>Segment-Dateien anzeigen ({row.segmentFiles.length})</summary>
<pre className="playlist-segment-output">{row.segmentFiles.join('\n')}</pre>
</details>
) : (
<small className="track-action-note">Keine Segmentliste aus TINFO:26 verfügbar.</small>
)}
</div>
))}
</div>
) : (
<small>Keine Kandidaten gefunden. Bitte Analyse erneut ausführen.</small>
)}
</div>
) : null}
{selectedMetadata ? (
<div className="pipeline-meta-inline">
{selectedMetadata.poster ? (
<img
src={selectedMetadata.poster}
alt={selectedMetadata.title || 'Poster'}
className="poster-large"
/>
) : (
<div className="poster-large poster-fallback">Kein Poster</div>
)}
<div className="device-meta">
<div>
<strong>Titel:</strong> {selectedMetadata.title || '-'}
</div>
<div>
<strong>Jahr:</strong> {selectedMetadata.year || '-'}
</div>
<div>
<strong>IMDb:</strong> {selectedMetadata.imdbId || '-'}
</div>
<div>
<strong>Status:</strong> {state}
</div>
</div>
</div>
) : null}
{(state === 'READY_TO_ENCODE' || state === 'MEDIAINFO_CHECK' || mediaInfoReview) ? (
<div className="mediainfo-review-block">
<h3>Titel-/Spurprüfung</h3>
{state === 'READY_TO_ENCODE' && !reviewConfirmed ? (
<small>
{isPreRipReview
? 'Backup/Rip + Encode ist gesperrt, bis die Spurauswahl bestätigt wurde.'
: 'Encode ist gesperrt, bis die Titel-/Spurauswahl bestätigt wurde.'}
{reviewPlaylistDecisionRequired ? ' Bitte den korrekten Titel per Checkbox auswählen.' : ''}
</small>
) : null}
<MediaInfoReviewPanel
review={mediaInfoReview}
commandOutputPath={commandOutputPath}
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
allowTitleSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed}
onSelectEncodeTitle={(titleId) => setSelectedEncodeTitleId(normalizeTitleId(titleId))}
allowTrackSelection={state === 'READY_TO_ENCODE' && !reviewConfirmed}
trackSelectionByTitle={trackSelectionByTitle}
onTrackSelectionChange={(titleId, trackType, trackId, checked) => {
const normalizedTitleId = normalizeTitleId(titleId);
const normalizedTrackId = normalizeTrackId(trackId);
if (!normalizedTitleId || normalizedTrackId === null) {
return;
}
setTrackSelectionByTitle((prev) => {
const current = prev?.[normalizedTitleId] || prev?.[String(normalizedTitleId)] || {
audioTrackIds: [],
subtitleTrackIds: []
};
const key = trackType === 'subtitle' ? 'subtitleTrackIds' : 'audioTrackIds';
const existing = normalizeTrackIdList(current?.[key] || []);
const next = checked
? normalizeTrackIdList([...existing, normalizedTrackId])
: existing.filter((id) => id !== normalizedTrackId);
return {
...prev,
[normalizedTitleId]: {
...current,
[key]: next
}
};
});
}}
/>
</div>
) : null}
</Card>
);
}