0.13.0 DVD Series

This commit is contained in:
2026-04-10 18:44:41 +00:00
parent 43702b0138
commit 974b7bfad3
36 changed files with 9666 additions and 739 deletions
+25
View File
@@ -363,6 +363,12 @@ export const api = {
getDetectedDrives() {
return request('/settings/drives');
},
forceUnlockDrive(payload = {}) {
return request('/settings/drives/force-unlock', {
method: 'POST',
body: JSON.stringify(payload || {})
});
},
async getHandBrakePresets(options = {}) {
const result = await requestCachedGet('/settings/handbrake-presets', {
ttlMs: 10 * 60 * 1000,
@@ -547,6 +553,14 @@ export const api = {
searchOmdb(q) {
return request(`/pipeline/omdb/search?q=${encodeURIComponent(q)}`);
},
searchTmdbSeries(q, seasonNumber = null) {
const params = new URLSearchParams();
params.set('q', String(q || ''));
if (seasonNumber !== undefined && seasonNumber !== null && String(seasonNumber).trim() !== '') {
params.set('season', String(seasonNumber).trim());
}
return request(`/pipeline/tmdb/series/search?${params.toString()}`);
},
searchMusicBrainz(q) {
return request(`/pipeline/cd/musicbrainz/search?q=${encodeURIComponent(q)}`);
},
@@ -675,6 +689,7 @@ export const api = {
const body = {};
if (options.keepBoth) body.keepBoth = true;
if (Array.isArray(options.deleteFolders) && options.deleteFolders.length > 0) body.deleteFolders = options.deleteFolders;
if (options.restartMode) body.restartMode = options.restartMode;
const result = await request(`/pipeline/restart-encode/${jobId}`, {
method: 'POST',
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined
@@ -743,6 +758,9 @@ export const api = {
if (params.lite) {
query.set('lite', '1');
}
if (params.includeChildren) {
query.set('includeChildren', '1');
}
const suffix = query.toString() ? `?${query.toString()}` : '';
return request(`/history${suffix}`);
},
@@ -773,6 +791,13 @@ export const api = {
afterMutationInvalidate(['/history']);
return result;
},
async acknowledgeJobError(jobId) {
const result = await request(`/history/${jobId}/error/ack`, {
method: 'POST'
});
afterMutationInvalidate(['/history']);
return result;
},
async deleteJobFiles(jobId, target = 'both') {
const result = await request(`/history/${jobId}/delete-files`, {
method: 'POST',
+102 -15
View File
@@ -293,9 +293,10 @@ function ExternalStoragePathsEditor({ value, onChange, settingKey }) {
);
}
function DetectedDrivesInfo() {
function DetectedDrivesInfo({ expertModeEnabled = false, onNotify = null }) {
const [drives, setDrives] = useState(null);
const [loading, setLoading] = useState(false);
const [unlocking, setUnlocking] = useState(false);
const load = () => {
setLoading(true);
@@ -307,21 +308,70 @@ function DetectedDrivesInfo() {
useEffect(() => { load(); }, []);
const notify = (severity, summary, detail) => {
if (typeof onNotify === 'function') {
onNotify({ severity, summary, detail });
}
};
const handleUnlockOne = async (devicePath) => {
setUnlocking(true);
try {
await api.forceUnlockDrive({ devicePath });
notify('success', 'Laufwerk freigegeben', `${devicePath} wurde entsperrt.`);
load();
} catch (error) {
notify('error', 'Freigabe fehlgeschlagen', error?.message || 'Unbekannter Fehler');
} finally {
setUnlocking(false);
}
};
const handleUnlockAll = async () => {
setUnlocking(true);
try {
await api.forceUnlockDrive({ all: true });
notify('success', 'Laufwerke freigegeben', 'Alle erkannten Laufwerke wurden entsperrt.');
load();
} catch (error) {
notify('error', 'Freigabe fehlgeschlagen', error?.message || 'Unbekannter Fehler');
} finally {
setUnlocking(false);
}
};
return (
<div className="detected-drives-info">
<div className="detected-drives-header">
<span className="detected-drives-label">Erkannte optische Laufwerke</span>
<Button
icon="pi pi-refresh"
text
rounded
size="small"
loading={loading}
onClick={load}
type="button"
aria-label="Laufwerke neu einlesen"
title="Laufwerke neu einlesen"
/>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.3rem' }}>
{expertModeEnabled ? (
<Button
icon="pi pi-unlock"
text
rounded
size="small"
loading={unlocking}
disabled={loading}
onClick={handleUnlockAll}
type="button"
aria-label="Alle Laufwerke freigeben"
title="Alle Laufwerke freigeben"
/>
) : null}
<Button
icon="pi pi-refresh"
text
rounded
size="small"
loading={loading}
disabled={unlocking}
onClick={load}
type="button"
aria-label="Laufwerke neu einlesen"
title="Laufwerke neu einlesen"
/>
</div>
</div>
{drives === null || loading
? <span className="detected-drives-empty">Wird geladen</span>
@@ -334,6 +384,7 @@ function DetectedDrivesInfo() {
<th>Gerät</th>
<th>Modell</th>
<th>MakeMKV Index</th>
{expertModeEnabled ? <th>Freigabe</th> : null}
</tr>
</thead>
<tbody>
@@ -342,6 +393,19 @@ function DetectedDrivesInfo() {
<td><code>{d.path}</code></td>
<td>{d.model || '—'}</td>
<td><Tag value={d.discArg ?? '—'} severity="info" /></td>
{expertModeEnabled ? (
<td>
<Button
label="Freigeben"
icon="pi pi-unlock"
size="small"
text
onClick={() => handleUnlockOne(d.path)}
loading={unlocking}
disabled={loading}
/>
</td>
) : null}
</tr>
))}
</tbody>
@@ -438,7 +502,15 @@ function buildToolSections(settings) {
// Path keys per medium — _owner keys are rendered inline
const BLURAY_PATH_KEYS = ['raw_dir_bluray', 'movie_dir_bluray', 'output_template_bluray'];
const DVD_PATH_KEYS = ['raw_dir_dvd', 'movie_dir_dvd', 'output_template_dvd'];
const DVD_PATH_KEYS = [
'raw_dir_dvd',
'raw_dir_dvd_series',
'movie_dir_dvd',
'series_dir_dvd',
'output_template_dvd',
'output_template_dvd_series_episode',
'output_template_dvd_series_multi_episode'
];
const CD_PATH_KEYS = ['raw_dir_cd', 'movie_dir_cd', 'cd_output_template'];
const AUDIOBOOK_PATH_KEYS = ['raw_dir_audiobook', 'movie_dir_audiobook', 'output_template_audiobook', 'output_chapter_template_audiobook', 'audiobook_raw_template'];
const DOWNLOAD_PATH_KEYS = ['download_dir'];
@@ -732,6 +804,7 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
const defaultRaw = effectivePaths?.defaults?.raw || 'data/output/raw';
const defaultMovies = effectivePaths?.defaults?.movies || 'data/output/movies';
const defaultSeries = effectivePaths?.defaults?.series || 'data/output/series';
const defaultCd = effectivePaths?.defaults?.cd || 'data/output/cd';
const defaultAudiobookRaw = effectivePaths?.defaults?.audiobookRaw || 'data/output/audiobook-raw';
const defaultAudiobookMovies = effectivePaths?.defaults?.audiobookMovies || 'data/output/audiobooks';
@@ -745,7 +818,9 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
const blurayRaw = ep.bluray?.raw || defaultRaw;
const blurayMovies = ep.bluray?.movies || defaultMovies;
const dvdRaw = ep.dvd?.raw || defaultRaw;
const dvdSeriesRaw = ep.dvd?.seriesRaw || dvdRaw;
const dvdMovies = ep.dvd?.movies || defaultMovies;
const dvdSeries = ep.dvd?.series || defaultSeries;
const cdRaw = ep.cd?.raw || defaultCd;
const cdMovies = ep.cd?.movies || cdRaw;
const audiobookRaw = ep.audiobook?.raw || defaultAudiobookRaw;
@@ -796,6 +871,17 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
{isDefault(dvdMovies, defaultMovies) && <span className="path-default-badge">Standard</span>}
</td>
</tr>
<tr>
<td><strong>DVD Serie</strong></td>
<td>
<code>{dvdSeriesRaw}</code>
{isDefault(dvdSeriesRaw, dvdRaw) && <span className="path-default-badge">wie DVD RAW</span>}
</td>
<td>
<code>{dvdSeries}</code>
{isDefault(dvdSeries, defaultSeries) && <span className="path-default-badge">Standard</span>}
</td>
</tr>
<tr>
<td><strong>CD / Audio</strong></td>
<td>
@@ -915,7 +1001,8 @@ export default function DynamicSettingsForm({
errors,
dirtyKeys,
onChange,
effectivePaths
effectivePaths,
onNotify
}) {
const safeCategories = Array.isArray(categories) ? categories : [];
const expertModeEnabled = toBoolean(values?.[EXPERT_MODE_SETTING_KEY]);
@@ -1009,7 +1096,7 @@ export default function DynamicSettingsForm({
const isDriveCategory = normalizeText(category?.category) === 'laufwerk';
return (
<div className="settings-sections">
{isDriveCategory && <DetectedDrivesInfo />}
{isDriveCategory && <DetectedDrivesInfo expertModeEnabled={expertModeEnabled} onNotify={onNotify} />}
{sections.map((section) => (
<section
key={`${category?.category || 'category'}-${section.id}`}
+665 -52
View File
@@ -6,6 +6,7 @@ import blurayIndicatorIcon from '../assets/media-bluray.svg';
import discIndicatorIcon from '../assets/media-disc.svg';
import otherIndicatorIcon from '../assets/media-other.svg';
import { getProcessStatusLabel, getStatusLabel } from '../utils/statusPresentation';
import { isSeriesDvdJob } from '../utils/jobTaxonomy';
const CD_FORMAT_LABELS = {
flac: 'FLAC',
@@ -190,6 +191,98 @@ function buildExecutedHandBrakeCommand(handbrakeInfo) {
return `${cmd} ${args.map((arg) => shellQuote(arg)).join(' ')}`.trim();
}
function normalizeSeriesEpisodeStatus(status) {
const normalized = String(status || '').trim().toUpperCase();
if (['QUEUED', 'RUNNING', 'FINISHED', 'ERROR', 'CANCELLED'].includes(normalized)) {
return normalized;
}
return 'QUEUED';
}
function seriesEpisodeStatusMeta(status) {
const normalized = normalizeSeriesEpisodeStatus(status);
if (normalized === 'FINISHED') {
return { label: 'Fertig', severity: 'success' };
}
if (normalized === 'RUNNING') {
return { label: 'Läuft', severity: 'info' };
}
if (normalized === 'ERROR') {
return { label: 'Fehler', severity: 'danger' };
}
if (normalized === 'CANCELLED') {
return { label: 'Abgebrochen', severity: 'warning' };
}
return { label: 'Wartend', severity: 'secondary' };
}
function formatDateTimeOrDash(value) {
const text = String(value || '').trim();
if (!text) {
return '-';
}
const parsed = new Date(text);
if (Number.isNaN(parsed.getTime())) {
return text;
}
return parsed.toLocaleString();
}
function mergeSeriesBatchEpisodes(job) {
const planEpisodes = Array.isArray(job?.encodePlan?.seriesBatchEpisodes)
? job.encodePlan.seriesBatchEpisodes
: [];
const hbEpisodes = Array.isArray(job?.handbrakeInfo?.seriesBatch?.episodes)
? job.handbrakeInfo.seriesBatch.episodes
: [];
const map = new Map();
const append = (list, sourcePriority) => {
for (const item of list) {
if (!item || typeof item !== 'object') {
continue;
}
const episodeIndex = normalizePositiveInteger(item?.episodeIndex);
const titleId = normalizePositiveInteger(item?.titleId);
const key = `${episodeIndex || 0}:${titleId || 0}`;
const existing = map.get(key);
if (!existing) {
map.set(key, {
...item,
episodeIndex,
titleId,
sourcePriority
});
continue;
}
if (sourcePriority >= Number(existing.sourcePriority || 0)) {
map.set(key, {
...existing,
...item,
episodeIndex: episodeIndex || existing.episodeIndex || null,
titleId: titleId || existing.titleId || null,
sourcePriority
});
}
}
};
append(planEpisodes, 1);
append(hbEpisodes, 2);
return Array.from(map.values())
.map((entry, index) => ({
...entry,
episodeIndex: normalizePositiveInteger(entry?.episodeIndex) || (index + 1),
titleId: normalizePositiveInteger(entry?.titleId) || null,
progress: Number.isFinite(Number(entry?.progress))
? Math.max(0, Math.min(100, Number(entry.progress)))
: 0,
status: normalizeSeriesEpisodeStatus(entry?.status)
}))
.sort((left, right) => left.episodeIndex - right.episodeIndex);
}
function buildConfiguredScriptAndChainSelection(job) {
const plan = job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : {};
const handbrakeInfo = job?.handbrakeInfo && typeof job.handbrakeInfo === 'object' ? job.handbrakeInfo : {};
@@ -505,6 +598,147 @@ function omdbRottenTomatoesScore(omdbInfo) {
return omdbField(entry?.Value);
}
function normalizeMetadataProvider(value) {
return String(value || '').trim().toLowerCase() || 'omdb';
}
function resolveJobMetadataContext(job) {
const mkInfo = job?.makemkvInfo && typeof job.makemkvInfo === 'object'
? job.makemkvInfo
: {};
const analyzeContext = mkInfo?.analyzeContext && typeof mkInfo.analyzeContext === 'object'
? mkInfo.analyzeContext
: {};
const analyzeSelectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
? analyzeContext.selectedMetadata
: {};
const selectedMetadata = mkInfo?.selectedMetadata && typeof mkInfo.selectedMetadata === 'object'
? mkInfo.selectedMetadata
: {};
const encodeMetadata = job?.encodePlan?.metadata && typeof job.encodePlan.metadata === 'object'
? job.encodePlan.metadata
: {};
const mergedSelectedMetadata = {
...analyzeSelectedMetadata,
...selectedMetadata,
...encodeMetadata
};
const metadataProvider = normalizeMetadataProvider(
mergedSelectedMetadata?.metadataProvider
|| analyzeContext?.metadataProvider
|| 'omdb'
);
return {
analyzeContext,
selectedMetadata: mergedSelectedMetadata,
metadataProvider
};
}
function uniqueNameList(values, maxItems = 10) {
const source = Array.isArray(values) ? values : [];
const limit = Math.max(1, Number(maxItems || 10));
const output = [];
const seen = new Set();
for (const item of source) {
const name = String(item?.name || item || '').trim();
if (!name) {
continue;
}
const key = name.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(name);
if (output.length >= limit) {
break;
}
}
return output;
}
function formatRuntimeLabel(value) {
if (Array.isArray(value)) {
const values = value
.map((entry) => Number(entry))
.filter((entry) => Number.isFinite(entry) && entry > 0)
.map((entry) => Math.trunc(entry));
if (values.length === 0) {
return null;
}
const min = Math.min(...values);
const max = Math.max(...values);
return min === max ? `${min} min` : `${min}-${max} min`;
}
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric > 0) {
return `${Math.trunc(numeric)} min`;
}
const text = String(value || '').trim();
return text || null;
}
function resolveMetadataDetailsForDisplay(job, omdbInfo = {}) {
const metadataContext = resolveJobMetadataContext(job);
const provider = metadataContext.metadataProvider;
const selectedMetadata = metadataContext.selectedMetadata;
const tmdbDetails = selectedMetadata?.tmdbDetails && typeof selectedMetadata.tmdbDetails === 'object'
? selectedMetadata.tmdbDetails
: {};
const isTmdbProvider = provider === 'tmdb' || provider === 'themoviedb';
if (isTmdbProvider) {
const genre = uniqueNameList(tmdbDetails?.genres || tmdbDetails?.genre, 3).join(', ') || null;
const actors = uniqueNameList(tmdbDetails?.seasonCast || tmdbDetails?.actors, 6).join(', ') || null;
const director = uniqueNameList(tmdbDetails?.createdBy || tmdbDetails?.director, 3).join(', ') || null;
const runtime = tmdbDetails?.seasonRuntime || tmdbDetails?.runtime || formatRuntimeLabel(selectedMetadata?.episodeRunTime);
const ratingNumber = Number(tmdbDetails?.seasonVoteAverage ?? tmdbDetails?.voteAverage ?? selectedMetadata?.voteAverage);
const tmdbRating = Number.isFinite(ratingNumber) && ratingNumber > 0
? ratingNumber.toFixed(1)
: null;
const imdbId = String(selectedMetadata?.imdbId || tmdbDetails?.imdbId || '').trim() || null;
const hasMatch = Boolean(selectedMetadata?.tmdbId || tmdbDetails?.tmdbId);
return {
provider: 'tmdb',
title: 'TMDb Details',
matchLabel: 'TMDb Match',
hasMatch,
imdbId,
director: director || '-',
actors: actors || '-',
runtime: runtime || '-',
genre: genre || '-',
tmdbRating: tmdbRating || '-'
};
}
return {
provider: 'omdb',
title: 'OMDb Details',
matchLabel: 'OMDb Match',
hasMatch: Boolean(job?.selected_from_omdb),
imdbId: String(job?.imdb_id || '').trim() || null,
director: omdbField(omdbInfo?.Director),
actors: omdbField(omdbInfo?.Actors),
runtime: omdbField(omdbInfo?.Runtime),
genre: omdbField(omdbInfo?.Genre),
rottenTomatoes: omdbRottenTomatoesScore(omdbInfo),
imdbRating: omdbField(omdbInfo?.imdbRating)
};
}
function resolveSeriesDiscNumber(job) {
const metadataContext = resolveJobMetadataContext(job);
return normalizePositiveInteger(
metadataContext?.selectedMetadata?.discNumber
?? metadataContext?.analyzeContext?.seriesLookupHint?.discNumber
?? job?.encodePlan?.discNumber
?? null
);
}
function BoolState({ value }) {
const isTrue = Boolean(value);
return isTrue ? (
@@ -518,6 +752,33 @@ function BoolState({ value }) {
);
}
function TriState({ existing = 0, expected = 0, labels = {} }) {
const safeExpected = Number.isFinite(Number(expected)) ? Number(expected) : 0;
const safeExisting = Number.isFinite(Number(existing)) ? Number(existing) : 0;
if (safeExpected <= 0) {
return <BoolState value={safeExisting > 0} />;
}
if (safeExisting <= 0) {
return (
<span className="job-step-inline-no" title={labels.none || `Nein (${safeExisting}/${safeExpected})`}>
<i className="pi pi-times-circle" aria-hidden="true" />
</span>
);
}
if (safeExisting < safeExpected) {
return (
<span className="job-step-inline-warn" title={labels.partial || `Teilweise (${safeExisting}/${safeExpected})`}>
<i className="pi pi-exclamation-circle" aria-hidden="true" />
</span>
);
}
return (
<span className="job-step-inline-ok" title={labels.full || `Ja (${safeExisting}/${safeExpected})`}>
<i className="pi pi-check-circle" aria-hidden="true" />
</span>
);
}
function resolveSelectionStateMeta({ selected, outcome }) {
if (!selected) {
return {
@@ -616,6 +877,7 @@ export default function JobDetailDialog({
logLoadingMode = null,
onAssignOmdb,
onAssignCdMetadata,
onAcknowledgeError,
onResumeReady,
onRestartEncode,
onRestartReview,
@@ -631,6 +893,7 @@ export default function JobDetailDialog({
isQueued = false,
omdbAssignBusy = false,
cdMetadataAssignBusy = false,
acknowledgeErrorBusy = false,
actionBusy = false,
cancelBusy = false,
reencodeBusy = false,
@@ -639,16 +902,39 @@ export default function JobDetailDialog({
downloadFolderBusyPath = null
}) {
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
// RAW-Import aus Datenbank, der noch nie encodiert wurde → nur "Neu einlesen" erlaubt
const isUnencodedOrphanImport = Boolean(
job?.makemkvInfo?.source === 'orphan_raw_import' && !job?.handbrakeInfo
);
const statusUpper = String(job?.status || '').trim().toUpperCase();
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(statusUpper);
const softCancelable = ['READY_TO_START', 'READY_TO_ENCODE', 'METADATA_SELECTION', 'WAITING_FOR_USER_DECISION', 'CD_METADATA_SELECTION', 'CD_READY_TO_RIP'].includes(statusUpper);
const showCancelAction = (running || softCancelable) && typeof onCancel === 'function';
const showFinalLog = !running;
const mediaType = resolveMediaType(job);
const isDvdSeries = mediaType === 'dvd' && isSeriesDvdJob(job);
const seriesBatchEpisodes = isDvdSeries ? mergeSeriesBatchEpisodes(job) : [];
const seriesEpisodeAssignments = isDvdSeries && job?.encodePlan?.episodeAssignments && typeof job.encodePlan.episodeAssignments === 'object'
? job.encodePlan.episodeAssignments
: {};
const seriesBatchSummary = seriesBatchEpisodes.reduce((acc, episode) => {
const status = normalizeSeriesEpisodeStatus(episode?.status);
if (status === 'FINISHED') {
acc.finished += 1;
} else if (status === 'ERROR') {
acc.error += 1;
} else if (status === 'CANCELLED') {
acc.cancelled += 1;
} else if (status === 'RUNNING') {
acc.running += 1;
} else {
acc.queued += 1;
}
return acc;
}, {
total: seriesBatchEpisodes.length,
finished: 0,
error: 0,
cancelled: 0,
running: 0,
queued: 0
});
const isCd = mediaType === 'cd';
const isAudiobook = mediaType === 'audiobook';
const isConverter = mediaType === 'converter';
@@ -811,7 +1097,7 @@ export default function JobDetailDialog({
const mediaTypeLabel = mediaType === 'bluray'
? 'Blu-ray'
: mediaType === 'dvd'
? 'DVD'
? (isDvdSeries ? 'DVD Serie' : 'DVD')
: isCd
? 'Audio CD'
: (isAudiobook ? 'Audiobook' : (isConverter ? `Converter ${converterMediaTypeLabel}` : 'Sonstiges Medium'));
@@ -823,6 +1109,27 @@ export default function JobDetailDialog({
const mediaTypeAlt = mediaTypeLabel;
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
const metadataContext = resolveJobMetadataContext(job);
const metadataDetails = resolveMetadataDetailsForDisplay(job, omdbInfo);
const seriesDiscNumber = isDvdSeries ? resolveSeriesDiscNumber(job) : null;
const seriesDiscLabel = seriesDiscNumber ? `Disk ${seriesDiscNumber}` : 'Disk unbekannt';
const childJobs = Array.isArray(job?.children) ? job.children : [];
const isSeriesContainer = isDvdSeries && String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
const seriesChildSummary = job?.seriesChildSummary || null;
const seriesBackupSummary = isSeriesContainer ? seriesChildSummary?.backup : null;
const seriesEncodeSummary = isSeriesContainer ? seriesChildSummary?.encode : null;
const displayedImdbId = metadataDetails?.imdbId || job?.imdb_id || null;
const metadataJsonTitle = metadataDetails.provider === 'tmdb' ? 'TMDb Info' : 'OMDb Info';
const metadataJsonValue = metadataDetails.provider === 'tmdb'
? (metadataContext?.selectedMetadata?.tmdbDetails || metadataContext?.selectedMetadata || null)
: job?.omdbInfo;
const metadataAssignProvider = isDvdSeries
? 'tmdb'
: (metadataDetails.provider === 'tmdb' ? 'tmdb' : 'omdb');
const metadataAssignButtonLabel = metadataAssignProvider === 'tmdb' ? 'TMDb neu zuweisen' : 'OMDb neu zuweisen';
const metadataAssignActionDescription = metadataAssignProvider === 'tmdb'
? 'Öffnet die TMDb-Suche, um Serien-Metadaten (Titel, Staffel, Episoden, Poster) neu zuzuweisen.'
: 'Öffnet die OMDb-Suche, um Film-Metadaten (Titel, Jahr, Poster, IMDb-ID) neu zuzuweisen.';
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|| configuredSelection.postScriptIds.length > 0
@@ -832,8 +1139,13 @@ export default function JobDetailDialog({
? job.encodePlan.userPreset
: null;
const reviewSelectedEncodeTitleId = job?.encodePlan?.encodeInputTitleId ?? null;
const encodePlanTitles = Array.isArray(job?.encodePlan?.titles) ? job.encodePlan.titles : [];
const selectedEncodeTitles = encodePlanTitles.filter(
(title) => title.selectedForEncode || title.encodeInput || String(title.id) === String(reviewSelectedEncodeTitleId)
);
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
const canDownloadRaw = Boolean(job?.raw_path && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
const resolvedRawPath = job?.rawStatus?.path || job?.raw_path || null;
const canDownloadRaw = Boolean(resolvedRawPath && job?.rawStatus?.exists && typeof onDownloadArchive === 'function');
const canDownloadOutput = Boolean(job?.output_path && job?.outputStatus?.exists && typeof onDownloadArchive === 'function');
const outputFolders = Array.isArray(job?.outputFolders) ? job.outputFolders : [];
const hasAnyOutputFolder = outputFolders.length > 0 || Boolean(job?.outputStatus?.exists);
@@ -979,12 +1291,24 @@ export default function JobDetailDialog({
) : (
<>
<section className="job-meta-block job-meta-block-film">
<h4>{isAudiobook ? 'Audiobook-Infos' : 'Film-Infos'}</h4>
<h4>{isAudiobook ? 'Audiobook-Infos' : (isDvdSeries ? 'Serieninfos' : 'Film-Infos')}</h4>
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Titel:</strong>
<span>{job.title || job.detected_title || '-'}</span>
</div>
{isDvdSeries ? (
<div className="job-meta-item">
<strong>Staffel:</strong>
<span>{metadataContext?.selectedMetadata?.seasonNumber ?? '-'}</span>
</div>
) : null}
{isDvdSeries ? (
<div className="job-meta-item">
<strong>{isSeriesContainer ? 'Container' : 'Disk'}:</strong>
<span>{isSeriesContainer ? 'Ja' : (seriesDiscNumber ? `Disk ${seriesDiscNumber}` : '-')}</span>
</div>
) : null}
<div className="job-meta-item">
<strong>Jahr:</strong>
<span>{job.year || '-'}</span>
@@ -1024,11 +1348,17 @@ export default function JobDetailDialog({
<>
<div className="job-meta-item">
<strong>IMDb:</strong>
<span>{job.imdb_id || '-'}</span>
<span>{displayedImdbId || '-'}</span>
</div>
{isDvdSeries ? (
<div className="job-meta-item">
<strong>TMDb:</strong>
<span>{metadataContext?.selectedMetadata?.tmdbId || '-'}</span>
</div>
) : null}
<div className="job-meta-item">
<strong>OMDb Match:</strong>
<BoolState value={job.selected_from_omdb} />
<strong>{metadataDetails.matchLabel}:</strong>
<BoolState value={metadataDetails.hasMatch} />
</div>
</>
)}
@@ -1044,32 +1374,41 @@ export default function JobDetailDialog({
{!isAudiobook ? (
<section className="job-meta-block job-meta-block-film">
<h4>OMDb Details</h4>
<h4>{metadataDetails.title}</h4>
<div className="job-meta-list">
<div className="job-meta-item">
<strong>Regisseur:</strong>
<span>{omdbField(omdbInfo?.Director)}</span>
<span>{omdbField(metadataDetails.director)}</span>
</div>
<div className="job-meta-item">
<strong>Schauspieler:</strong>
<span>{omdbField(omdbInfo?.Actors)}</span>
<span>{omdbField(metadataDetails.actors)}</span>
</div>
<div className="job-meta-item">
<strong>Laufzeit:</strong>
<span>{omdbField(omdbInfo?.Runtime)}</span>
<span>{omdbField(metadataDetails.runtime)}</span>
</div>
<div className="job-meta-item">
<strong>Genre:</strong>
<span>{omdbField(omdbInfo?.Genre)}</span>
</div>
<div className="job-meta-item">
<strong>Rotten Tomatoes:</strong>
<span>{omdbRottenTomatoesScore(omdbInfo)}</span>
</div>
<div className="job-meta-item">
<strong>imdbRating:</strong>
<span>{omdbField(omdbInfo?.imdbRating)}</span>
<span>{omdbField(metadataDetails.genre)}</span>
</div>
{metadataDetails.provider === 'tmdb' ? (
<div className="job-meta-item">
<strong>TMDb Rating:</strong>
<span>{omdbField(metadataDetails.tmdbRating)}</span>
</div>
) : (
<>
<div className="job-meta-item">
<strong>Rotten Tomatoes:</strong>
<span>{omdbField(metadataDetails.rottenTomatoes)}</span>
</div>
<div className="job-meta-item">
<strong>imdbRating:</strong>
<span>{omdbField(metadataDetails.imdbRating)}</span>
</div>
</>
)}
</div>
</section>
) : null}
@@ -1101,9 +1440,17 @@ export default function JobDetailDialog({
</>
) : (
<>
<span><strong>{isAudiobook || isConverter ? 'Import:' : 'Backup:'}</strong> <BoolState value={job?.backupSuccess} /></span>
<span><strong>{isAudiobook || isConverter ? 'Import:' : 'Backup:'}</strong> {isSeriesContainer && seriesBackupSummary ? (
<TriState existing={seriesBackupSummary.existing} expected={seriesBackupSummary.expected} />
) : (
<BoolState value={job?.backupSuccess} />
)}</span>
<span className="job-infos-sep">|</span>
<span><strong>Encode:</strong> <BoolState value={job?.encodeSuccess} /></span>
<span><strong>Encode:</strong> {isSeriesContainer && seriesEncodeSummary ? (
<TriState existing={seriesEncodeSummary.existing} expected={seriesEncodeSummary.expected} />
) : (
<BoolState value={job?.encodeSuccess} />
)}</span>
</>
)}
</div>
@@ -1115,11 +1462,29 @@ export default function JobDetailDialog({
{/* Zeile 3+4: Pfade */}
<PathField
label={isCd ? 'WAV:' : (isConverter ? 'Input:' : 'RAW:')}
value={isCd ? (job.raw_path || job.output_path) : job.raw_path}
value={isSeriesContainer ? null : (isCd ? (job.raw_path || job.output_path) : resolvedRawPath)}
onDownload={canDownloadRaw ? () => onDownloadArchive?.(job, 'raw') : null}
downloadDisabled={!canDownloadRaw}
downloadLoading={downloadBusyTarget === 'raw'}
/>
{isSeriesContainer && childJobs.length > 0 ? (
childJobs.map((child) => {
const childDiscNumber = resolveSeriesDiscNumber(child);
const childLabel = childDiscNumber ? `Disk ${childDiscNumber} RAW:` : 'Disk RAW:';
const childRawPath = child?.raw_path || child?.output_path || null;
const canDownloadChildRaw = Boolean(childRawPath && child?.rawStatus?.exists && typeof onDownloadArchive === 'function');
return (
<PathField
key={`child-raw-${child.id}`}
label={childLabel}
value={childRawPath}
onDownload={canDownloadChildRaw ? () => onDownloadArchive?.(child, 'raw') : null}
downloadDisabled={!canDownloadChildRaw}
downloadLoading={downloadBusyTarget === 'raw'}
/>
);
})
) : null}
{outputFolders.length > 0 ? (
outputFolders.map((folder, idx) => {
const folderPath = String(folder?.output_path || '').trim();
@@ -1149,7 +1514,23 @@ export default function JobDetailDialog({
/>
)}
{job.error_message ? (
<div><strong>Fehler:</strong> {job.error_message}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
<span><strong>Fehler:</strong> {job.error_message}</span>
{typeof onAcknowledgeError === 'function' ? (
<Button
icon="pi pi-check-circle"
rounded
text
size="small"
severity="success"
title="Fehler quittieren"
aria-label="Fehler quittieren"
onClick={() => onAcknowledgeError(job)}
loading={acknowledgeErrorBusy}
disabled={acknowledgeErrorBusy}
/>
) : null}
</div>
) : null}
</div>
</section>
@@ -1219,6 +1600,119 @@ export default function JobDetailDialog({
</section>
) : null}
{isDvdSeries && seriesBatchEpisodes.length > 0 ? (
<section className="job-meta-block job-meta-block-full">
<h4>Serien-Episoden</h4>
<div className="series-batch-progress-wrap">
<div className="series-batch-head">
<div>
<strong>Gesamt:</strong>{' '}
{`${seriesBatchSummary.finished}/${seriesBatchSummary.total} fertig`}
{seriesBatchSummary.running > 0 ? ` | laufend: ${seriesBatchSummary.running}` : ''}
{seriesBatchSummary.queued > 0 ? ` | wartend: ${seriesBatchSummary.queued}` : ''}
{seriesBatchSummary.error > 0 ? ` | fehler: ${seriesBatchSummary.error}` : ''}
{seriesBatchSummary.cancelled > 0 ? ` | abgebrochen: ${seriesBatchSummary.cancelled}` : ''}
</div>
</div>
<div className="series-batch-episodes">
{seriesBatchEpisodes.map((episode) => {
const assignment = seriesEpisodeAssignments[String(episode?.titleId)]
|| seriesEpisodeAssignments[Number(episode?.titleId)]
|| null;
const statusMeta = seriesEpisodeStatusMeta(episode?.status);
const episodeLabel = String(
episode?.label
|| assignment?.episodeTitle
|| `Episode ${episode?.episodeIndex || '?'}`
).trim();
const episodeCode = (() => {
const seasonNo = normalizePositiveInteger(assignment?.seasonNumber ?? episode?.seasonNumber);
const episodeNoRaw = Number(assignment?.episodeNumber ?? episode?.episodeNumber);
const episodeNo = Number.isFinite(episodeNoRaw) && episodeNoRaw > 0
? episodeNoRaw
: null;
if (!seasonNo && !episodeNo) {
return null;
}
const seasonToken = seasonNo ? String(seasonNo).padStart(2, '0') : '--';
const episodeToken = episodeNo
? (
Number.isInteger(episodeNo)
? String(Math.trunc(episodeNo)).padStart(2, '0')
: String(episodeNo)
)
: '--';
return `S${seasonToken}E${episodeToken}`;
})();
const trackSelection = episode?.trackSelection && typeof episode.trackSelection === 'object'
? episode.trackSelection
: null;
const audioTrackIds = Array.isArray(trackSelection?.audioTrackIds) ? trackSelection.audioTrackIds : [];
const subtitleTrackIds = Array.isArray(trackSelection?.subtitleTrackIds) ? trackSelection.subtitleTrackIds : [];
const subtitleForcedIndexes = Array.isArray(trackSelection?.subtitleForcedTrackIndexes)
? trackSelection.subtitleForcedTrackIndexes
: [];
const episodeCommand = buildExecutedHandBrakeCommand(episode?.handbrakeInfo);
const boundedProgress = Number.isFinite(Number(episode?.progress))
? Math.max(0, Math.min(100, Number(episode.progress)))
: 0;
return (
<details key={`series-episode-${episode.episodeIndex}-${episode.titleId || 'na'}`} className="episode-track-accordion">
<summary className="series-batch-episode-head">
<span className="series-batch-episode-title">
{`#${episode?.episodeIndex || '?'} | ${episodeLabel}${episodeCode ? ` | ${episodeCode}` : ''}${seriesDiscNumber ? ` | Disk ${seriesDiscNumber}` : ''}`}
</span>
<Tag value={statusMeta.label} severity={statusMeta.severity} />
</summary>
<div className="track-group">
<div className="track-item">
<span><strong>Titel-ID:</strong> {episode?.titleId || '-'}</span>
</div>
<div className="track-item">
<span><strong>Fortschritt:</strong> {`${boundedProgress.toFixed(2)}%`}</span>
</div>
<div className="track-item">
<span><strong>Gestartet:</strong> {formatDateTimeOrDash(episode?.startedAt)}</span>
</div>
<div className="track-item">
<span><strong>Beendet:</strong> {formatDateTimeOrDash(episode?.finishedAt)}</span>
</div>
<div className="track-item">
<span><strong>Audio-Spuren:</strong> {audioTrackIds.length > 0 ? audioTrackIds.join(', ') : '-'}</span>
</div>
<div className="track-item">
<span><strong>Subtitle-Spuren:</strong> {subtitleTrackIds.length > 0 ? subtitleTrackIds.join(', ') : '-'}</span>
</div>
<div className="track-item">
<span><strong>Subtitle Forced-Index:</strong> {subtitleForcedIndexes.length > 0 ? subtitleForcedIndexes.join(', ') : '-'}</span>
</div>
{episode?.outputPath ? (
<div className="track-item">
<span><strong>Output:</strong> {episode.outputPath}</span>
</div>
) : null}
{episode?.error ? (
<div className="track-item">
<span><strong>Fehler:</strong> {episode.error}</span>
</div>
) : null}
{episodeCommand ? (
<div className="track-item">
<span><strong>Command:</strong> {episodeCommand}</span>
</div>
) : null}
<div className="track-item">
<small className="track-action-note">Hinweis: Alle Episoden-Logs liegen im Hauptjob-Log.</small>
</div>
</div>
</details>
);
})}
</div>
</div>
</section>
) : null}
{!isCd && !isAudiobook && !isConverter && (hasConfiguredSelection || encodePlanUserPreset || job.encodePlan?.minLengthMinutes != null || Array.isArray(job.encodePlan?.titles)) ? (
<section className="job-meta-block job-meta-block-full">
<h4>Encode-Konfiguration</h4>
@@ -1257,12 +1751,74 @@ export default function JobDetailDialog({
</div>
) : null}
{/* Spurauswahl read-only */}
{job.encodePlan && Array.isArray(job.encodePlan.titles) && job.encodePlan.titles.length > 0 ? (
<div className="spurauswahl-block">
<div className="spurauswahl-label">Spurauswahl</div>
{job.encodePlan.titles
.filter((t) => t.selectedForEncode || t.encodeInput || String(t.id) === String(reviewSelectedEncodeTitleId))
.map((title) => {
{selectedEncodeTitles.length > 0 ? (
isDvdSeries ? (
<details className="episode-track-accordion">
<summary className="series-batch-episode-head">
<span className="series-batch-episode-title">{seriesDiscLabel}</span>
</summary>
<div className="spurauswahl-block">
<div className="spurauswahl-label">Spurauswahl</div>
{selectedEncodeTitles.map((title) => {
const audioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : [];
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
return (
<div key={title.id} className="track-title-block">
<div className="track-title-row">
<span>#{title.id} | {title.fileName} | {title.durationMinutes != null ? `${Number(title.durationMinutes).toFixed(2)} min` : '-'}</span>
</div>
<div className="track-groups-row">
{audioTracks.length > 0 ? (
<div className="track-group">
<div className="track-group-label">Tonspuren (Titel #{title.id})</div>
{audioTracks.map((track) => {
const lang = trackLang(track.language || track.languageLabel);
const codec = trackCodec('audio', track.format, track.description || track.title);
const chLayout = trackChLayout(track.channels);
let displayText = `#${track.id} | ${lang} | ${codec}`;
if (chLayout) displayText += ` | ${chLayout}`;
const actionInfo = track.selectedForEncode
? (String(track.encodePreviewSummary || track.encodeActionSummary || '').trim() || 'Copy')
: 'Nicht übernommen';
return (
<div key={track.id} className="track-item">
<span>{displayText}</span>
<small className="track-action-note">Encode: {actionInfo}</small>
</div>
);
})}
</div>
) : null}
{subtitleTracks.length > 0 ? (
<div className="track-group">
<div className="track-group-label">Subtitles (Titel #{title.id})</div>
{subtitleTracks.map((track) => {
const lang = trackLang(track.language || track.languageLabel);
const codec = trackCodec('subtitle', track.format);
const displayText = `#${track.id} | ${lang} | ${codec}`;
const rawAction = String(track.subtitlePreviewSummary || track.subtitleActionSummary || '').trim();
const actionInfo = track.selectedForEncode
? (/^nicht übernommen$/i.test(rawAction) ? 'Übernehmen' : (rawAction || 'Übernehmen'))
: 'Nicht übernommen';
return (
<div key={track.id} className="track-item">
<span>{displayText}</span>
<small className="track-action-note">Encode: {actionInfo}</small>
</div>
);
})}
</div>
) : null}
</div>
</div>
);
})}
</div>
</details>
) : (
<div className="spurauswahl-block">
<div className="spurauswahl-label">Spurauswahl</div>
{selectedEncodeTitles.map((title) => {
const audioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : [];
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
return (
@@ -1315,9 +1871,9 @@ export default function JobDetailDialog({
</div>
</div>
);
})
}
</div>
})}
</div>
)
) : null}
</section>
) : null}
@@ -1421,15 +1977,55 @@ export default function JobDetailDialog({
<section className="job-meta-block job-meta-block-full">
<h4>Logs</h4>
<div className="job-json-grid">
{!isCd && !isAudiobook && !isConverter ? <JsonView title="OMDb Info" value={job.omdbInfo} /> : null}
{isCd ? <JsonView title="MusicBrainz" value={job.makemkvInfo?.selectedMetadata ?? null} /> : null}
{isAudiobook ? <JsonView title="Metadaten" value={job.handbrakeInfo?.metadata ?? job.makemkvInfo?.selectedMetadata ?? null} /> : null}
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : (isConverter ? 'Converter Analyze Info' : 'MakeMKV Info'))} value={job.makemkvInfo} />
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={job.encodePlan} />
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={job.handbrakeInfo} />
</div>
{isSeriesContainer && childJobs.length > 0 ? (
<>
<div className="job-json-grid">
{!isCd && !isAudiobook && !isConverter ? <JsonView title={metadataJsonTitle} value={metadataJsonValue} /> : null}
</div>
{childJobs.map((child) => {
const childDiscNumber = resolveSeriesDiscNumber(child);
const childDiscLabel = childDiscNumber ? `Disk ${childDiscNumber}` : 'Disk unbekannt';
return (
<details key={`child-log-${child.id}`} className="episode-track-accordion">
<summary className="series-batch-episode-head">
<span className="series-batch-episode-title">{childDiscLabel}</span>
</summary>
<div className="job-json-grid">
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : (isConverter ? 'Converter Analyze Info' : 'MakeMKV Info'))} value={child.makemkvInfo} />
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={child.mediainfoInfo} /> : null}
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={child.encodePlan} />
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={child.handbrakeInfo} />
</div>
</details>
);
})}
</>
) : isDvdSeries ? (
<details className="episode-track-accordion">
<summary className="series-batch-episode-head">
<span className="series-batch-episode-title">{seriesDiscLabel}</span>
</summary>
<div className="job-json-grid">
{!isCd && !isAudiobook && !isConverter ? <JsonView title={metadataJsonTitle} value={metadataJsonValue} /> : null}
{isCd ? <JsonView title="MusicBrainz" value={job.makemkvInfo?.selectedMetadata ?? null} /> : null}
{isAudiobook ? <JsonView title="Metadaten" value={job.handbrakeInfo?.metadata ?? job.makemkvInfo?.selectedMetadata ?? null} /> : null}
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : (isConverter ? 'Converter Analyze Info' : 'MakeMKV Info'))} value={job.makemkvInfo} />
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={job.encodePlan} />
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={job.handbrakeInfo} />
</div>
</details>
) : (
<div className="job-json-grid">
{!isCd && !isAudiobook && !isConverter ? <JsonView title={metadataJsonTitle} value={metadataJsonValue} /> : null}
{isCd ? <JsonView title="MusicBrainz" value={job.makemkvInfo?.selectedMetadata ?? null} /> : null}
{isAudiobook ? <JsonView title="Metadaten" value={job.handbrakeInfo?.metadata ?? job.makemkvInfo?.selectedMetadata ?? null} /> : null}
<JsonView title={isCd ? 'cdparanoia Info' : (isAudiobook ? 'Audiobook Info' : (isConverter ? 'Converter Analyze Info' : 'MakeMKV Info'))} value={job.makemkvInfo} />
{!isCd && !isAudiobook ? <JsonView title="Mediainfo Info" value={job.mediainfoInfo} /> : null}
<JsonView title={isCd ? 'Rip-Plan' : (isConverter ? 'Converter Plan' : 'Encode Plan')} value={job.encodePlan} />
<JsonView title={isCd ? 'Rip-Info' : (isAudiobook ? 'FFmpeg Info' : (isConverter ? 'Converter Encode Info' : 'HandBrake Info'))} value={job.handbrakeInfo} />
</div>
)}
</section>
@@ -1461,7 +2057,24 @@ export default function JobDetailDialog({
{logTruncated ? <small>(gekürzt auf letzte 800 Zeilen)</small> : null}
</div>
{logLoaded ? (
<pre className="log-box">{job.log || ''}</pre>
isSeriesContainer && childJobs.length > 0 ? (
<div className="log-box">
{childJobs.map((child) => {
const childDiscNumber = resolveSeriesDiscNumber(child);
const childDiscLabel = childDiscNumber ? `Disk ${childDiscNumber}` : 'Disk unbekannt';
return (
<details key={`child-log-text-${child.id}`} className="episode-track-accordion">
<summary className="series-batch-episode-head">
<span className="series-batch-episode-title">{childDiscLabel}</span>
</summary>
<pre>{child.log || ''}</pre>
</details>
);
})}
</div>
) : (
<pre className="log-box">{job.log || ''}</pre>
)
) : (
<p>Log nicht vorgeladen. Über die Buttons oben laden.</p>
)}
@@ -1595,7 +2208,7 @@ export default function JobDetailDialog({
size="small"
onClick={() => onRestartReview?.(job)}
loading={actionBusy}
disabled={!canRestartReview || isUnencodedOrphanImport}
disabled={!canRestartReview}
/>
<span className="action-desc">{isAudiobook
? 'Öffnet die Kapitel-Auswahl erneut, um Kapitel anzupassen bevor der Encode startet.'
@@ -1610,9 +2223,9 @@ export default function JobDetailDialog({
icon="pi pi-sync"
severity="info"
size="small"
onClick={() => isUnencodedOrphanImport ? onRestartReview?.(job) : onReencode?.(job)}
loading={isUnencodedOrphanImport ? actionBusy : reencodeBusy}
disabled={isUnencodedOrphanImport ? !canRestartReview : !canReencode}
onClick={() => onReencode?.(job)}
loading={reencodeBusy}
disabled={!canReencode}
/>
<span className="action-desc">{isAudiobook
? 'Liest die AAX-Datei neu ein und öffnet danach die Kapitel-Auswahl. Sinnvoll wenn sich die Quelldatei geändert hat.'
@@ -1629,7 +2242,7 @@ export default function JobDetailDialog({
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
<div className="action-item">
<Button
label="OMDB neu zuweisen"
label={metadataAssignButtonLabel}
icon="pi pi-search"
severity="secondary"
outlined
@@ -1638,7 +2251,7 @@ export default function JobDetailDialog({
loading={omdbAssignBusy}
disabled={running}
/>
<span className="action-desc">Öffnet die OMDB-Suche, um Film-Metadaten (Titel, Jahr, Poster, IMDb-ID) neu zuzuweisen.</span>
<span className="action-desc">{metadataAssignActionDescription}</span>
</div>
</div>
) : null}
+533 -62
View File
@@ -411,6 +411,46 @@ function shellQuote(value) {
return `'${raw.replace(/'/g, `'"'"'`)}'`;
}
function transliterateForFilename(input) {
return String(input || '')
.replace(/ä/g, 'ae').replace(/Ä/g, 'Ae')
.replace(/ö/g, 'oe').replace(/Ö/g, 'Oe')
.replace(/ü/g, 'ue').replace(/Ü/g, 'Ue')
.replace(/ß/g, 'ss')
.replace(/å/g, 'a').replace(/Å/g, 'A')
.replace(/æ/g, 'ae').replace(/Æ/g, 'Ae')
.replace(/ø/g, 'o').replace(/Ø/g, 'O')
.replace(/ð/g, 'd').replace(/Ð/g, 'D')
.replace(/þ/g, 'th').replace(/Þ/g, 'Th')
.normalize('NFD')
.replace(/\p{M}+/gu, '')
.replace(/[^\x00-\x7F]/g, '');
}
function sanitizePathSegment(value) {
return transliterateForFilename(String(value || ''))
.replace(/[^a-zA-Z0-9 ().[\]_-]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function sanitizeOutputPathForCommand(pathValue) {
const raw = String(pathValue || '').trim();
if (!raw) {
return null;
}
const normalizedPath = raw.replace(/\\/g, '/');
const hasLeadingSlash = normalizedPath.startsWith('/');
const segments = normalizedPath
.split('/')
.map((segment) => sanitizePathSegment(segment))
.filter(Boolean);
if (segments.length === 0) {
return hasLeadingSlash ? '/' : null;
}
return `${hasLeadingSlash ? '/' : ''}${segments.join('/')}`;
}
function buildHandBrakeCommandPreview({
review,
title,
@@ -433,7 +473,12 @@ function buildHandBrakeCommandPreview({
const extraArgs = presetOverride !== null
? String(presetOverride.extraArgs || '').trim()
: String(review?.selectors?.extraArgs || '').trim();
const rawMappedTitleId = Number(review?.handBrakeTitleId);
const rawMappedTitleId = Number(
title?.handBrakeTitleId
?? title?.titleIndex
?? title?.id
?? review?.handBrakeTitleId
);
const mappedTitleId = Number.isFinite(rawMappedTitleId) && rawMappedTitleId > 0
? Math.trunc(rawMappedTitleId)
: null;
@@ -463,11 +508,12 @@ function buildHandBrakeCommandPreview({
selectedSubtitleTracks.filter((track) => Boolean(track?.subtitlePreviewDefaultTrack || track?.defaultTrack)).map((track) => track?.id)
)[0] || null;
const sanitizedOutputPath = sanitizeOutputPathForCommand(commandOutputPath);
const baseArgs = [
'-i',
inputPath || '<encode-input>',
'-o',
String(commandOutputPath || '').trim() || '<encode-output>'
String(sanitizedOutputPath || '').trim() || '<encode-output>'
];
if (mappedTitleId !== null) {
@@ -1195,13 +1241,22 @@ export default function MediaInfoReviewPanel({
review,
presetDisplayValue = '',
commandOutputPath = null,
commandOutputPathByTitle = {},
selectedEncodeTitleId = null,
selectedEncodeTitleIds = [],
allowTitleSelection = false,
compactTitleSelection = false,
onSelectEncodeTitle = null,
onToggleEncodeTitle = null,
allowTrackSelection = false,
trackSelectionByTitle = {},
onTrackSelectionChange = null,
onSubtitleVariantSelectionChange = null,
selectedMetadataEpisodes = [],
selectedEpisodeFillStart = null,
onEpisodeFillStartChange = null,
episodeAssignmentsByTitle = {},
onEpisodeAssignmentChange = null,
availableScripts = [],
availableChains = [],
preEncodeItems = [],
@@ -1225,10 +1280,68 @@ export default function MediaInfoReviewPanel({
}
const titles = review.titles || [];
const currentSelectedId = normalizeTitleId(selectedEncodeTitleId) || normalizeTitleId(review.encodeInputTitleId);
const encodeInputTitle = titles.find((item) => item.id === currentSelectedId) || null;
const processedFiles = Number(review.processedFiles || titles.length || 0);
const totalFiles = Number(review.totalFiles || titles.length || 0);
const candidateTitles = compactTitleSelection && Array.isArray(review?.handBrakeTitleCandidates)
? review.handBrakeTitleCandidates
.map((candidate) => {
const id = normalizeTitleId(candidate?.handBrakeTitleId);
if (id === null) return null;
const durationSeconds = Number(candidate?.durationSeconds || 0);
const durationMinutes = Number.isFinite(durationSeconds) && durationSeconds > 0
? Number((durationSeconds / 60).toFixed(2))
: Number(candidate?.durationMinutes || 0);
return {
id,
fileName: `Titel #${id}`,
durationSeconds: Number.isFinite(durationSeconds) ? durationSeconds : 0,
durationMinutes: Number.isFinite(durationMinutes) ? durationMinutes : 0,
sizeBytes: Number(candidate?.sizeBytes || 0),
audioTrackCount: Number(candidate?.audioTrackCount || 0),
subtitleTrackCount: Number(candidate?.subtitleTrackCount || 0),
audioTracks: [],
subtitleTracks: [],
selectedForEncode: false
};
})
.filter(Boolean)
: null;
const displayTitles = candidateTitles && candidateTitles.length > 0 ? candidateTitles : titles;
const selectedTitleIds = normalizeTrackIdList([
...normalizeTrackIdList(selectedEncodeTitleIds),
...normalizeTrackIdList(review?.selectedTitleIds || []),
...displayTitles.filter((item) => Boolean(item?.selectedForEncode)).map((item) => item?.id)
]);
const currentSelectedId = normalizeTitleId(selectedEncodeTitleId)
|| normalizeTitleId(review.encodeInputTitleId)
|| selectedTitleIds[0]
|| null;
const selectedTitleIdSet = new Set(selectedTitleIds.map((id) => String(id)));
const encodeInputTitle = displayTitles.find((item) => item.id === currentSelectedId) || null;
const normalizedEpisodeRows = (Array.isArray(selectedMetadataEpisodes) ? selectedMetadataEpisodes : [])
.map((episode) => {
const episodeId = Number(episode?.episodeId ?? episode?.id ?? 0);
const episodeNumber = Number(episode?.episodeNumber ?? episode?.number ?? 0);
const seasonNumber = Number(episode?.seasonNumber ?? episode?.season ?? 0);
const episodeTitle = String(episode?.episodeTitle || episode?.name || episode?.title || '').trim();
return {
episodeId: Number.isFinite(episodeId) && episodeId > 0 ? Math.trunc(episodeId) : null,
episodeNumber: Number.isFinite(episodeNumber) && episodeNumber > 0 ? Math.trunc(episodeNumber) : null,
seasonNumber: Number.isFinite(seasonNumber) && seasonNumber > 0 ? Math.trunc(seasonNumber) : null,
episodeTitle: episodeTitle || null
};
})
.filter((episode) => episode.episodeId || episode.episodeNumber !== null);
const episodeFillOptions = normalizedEpisodeRows.map((episode) => {
const seasonLabel = episode.seasonNumber !== null ? `S${String(episode.seasonNumber).padStart(2, '0')}` : 'S??';
const episodeLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : 'E??';
const titleLabel = episode.episodeTitle || '(ohne Titel)';
const value = String(episode.episodeId || episode.episodeNumber || '');
return {
label: `${seasonLabel}${episodeLabel} - ${titleLabel}`,
value
};
}).filter((option) => option.value);
const processedFiles = Number(review.processedFiles || displayTitles.length || 0);
const totalFiles = Number(review.totalFiles || displayTitles.length || 0);
const playlistRecommendation = review.playlistRecommendation || null;
const rawPreset = String(review.selectors?.preset || '').trim();
const presetLabel = String(presetDisplayValue || rawPreset).trim() || '(kein Preset)';
@@ -1280,9 +1393,170 @@ export default function MediaInfoReviewPanel({
const handlePreDrop = makeHandleDrop(preEncodeItems, onReorderPreEncodeItem);
const handlePostDrop = makeHandleDrop(postEncodeItems, onReorderPostEncodeItem);
const resolveTitleSelectionState = (title) => {
const titleSelectionEntry = trackSelectionByTitle?.[title.id] || trackSelectionByTitle?.[String(title.id)] || {};
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
const selectableSubtitleTrackIds = subtitleTracks
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null);
const selectableSubtitleTrackIdSet = new Set(selectableSubtitleTrackIds.map((id) => String(id)));
const sortedSelectableSubtitleTracks = subtitleTracks
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.slice()
.sort((a, b) => (normalizeTrackId(a?.id) || Number.MAX_SAFE_INTEGER) - (normalizeTrackId(b?.id) || Number.MAX_SAFE_INTEGER));
const defaultAudioTrackIds = (Array.isArray(title.audioTracks) ? title.audioTracks : [])
.filter((track) => Boolean(track?.selectedByRule))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null);
const defaultSubtitleTrackIds = subtitleTracks
.filter((track) => Boolean(track?.selectedByRule) && !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.map((track) => normalizeTrackId(track?.id))
.filter((id) => id !== null);
const defaultSubtitleVariantSelection = {};
const defaultSubtitleLanguageOrder = [];
const subtitleLanguageOrderSeen = new Set();
for (const track of sortedSelectableSubtitleTracks) {
const language = normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und');
if (!subtitleLanguageOrderSeen.has(language)) {
subtitleLanguageOrderSeen.add(language);
defaultSubtitleLanguageOrder.push(language);
}
if (!Boolean(track?.selectedByRule)) {
continue;
}
if (!defaultSubtitleVariantSelection[language]) {
defaultSubtitleVariantSelection[language] = { full: false, forced: false };
}
const flags = resolveSubtitleTrackVariantFlags(track);
if (flags.isForcedOnly) {
defaultSubtitleVariantSelection[language].forced = true;
} else {
defaultSubtitleVariantSelection[language].full = true;
}
}
const selectedAudioTrackIds = normalizeTrackIdList(
Array.isArray(titleSelectionEntry?.audioTrackIds)
? titleSelectionEntry.audioTrackIds
: defaultAudioTrackIds
);
const selectedSubtitleTrackIds = normalizeTrackIdList(
Array.isArray(titleSelectionEntry?.subtitleTrackIds)
? titleSelectionEntry.subtitleTrackIds
: defaultSubtitleTrackIds
).filter((id) => selectableSubtitleTrackIdSet.has(String(id)));
const selectedSubtitleVariantSelection = normalizeSubtitleVariantSelectionMap(
titleSelectionEntry?.subtitleVariantSelection || defaultSubtitleVariantSelection
);
const selectedSubtitleLanguageOrder = normalizeSubtitleLanguageOrder(
Array.isArray(titleSelectionEntry?.subtitleLanguageOrder)
? titleSelectionEntry.subtitleLanguageOrder
: defaultSubtitleLanguageOrder
);
return {
titleSelectionEntry,
subtitleTracks,
selectedAudioTrackIds,
selectedSubtitleTrackIds,
selectedSubtitleVariantSelection,
selectedSubtitleLanguageOrder
};
};
const normalizeTrackSignatureText = (value) => String(value || '').trim().toLowerCase();
const buildAudioSignature = (track) => [
normalizeTrackSignatureText(track?.language || track?.languageLabel || 'und'),
normalizeTrackSignatureText(track?.format || track?.codecName || ''),
normalizeTrackSignatureText(track?.channels || ''),
normalizeTrackSignatureText(track?.description || track?.title || '')
].join('|');
const buildSubtitleSignature = (track) => {
const flags = resolveSubtitleTrackVariantFlags(track);
return [
normalizeTrackSignatureText(track?.language || track?.languageLabel || 'und'),
normalizeTrackSignatureText(track?.format || track?.codecName || ''),
flags.isForcedOnly ? 'forced' : 'full',
flags.fullHasForced ? 'with_forced_variant' : 'plain'
].join('|');
};
const selectedTitlesForGlobal = selectedTitleIds.length > 0
? titles.filter((title) => selectedTitleIdSet.has(String(normalizeTitleId(title?.id))))
: titles;
const masterGlobalTitle = selectedTitlesForGlobal[0] || titles[0] || null;
const globalMasterSelectionState = masterGlobalTitle ? resolveTitleSelectionState(masterGlobalTitle) : null;
const globalAudioTrackMaps = new Map();
const globalSubtitleLanguagesByTitle = new Map();
const globalTrackCompatibility = (() => {
if (!masterGlobalTitle || selectedTitlesForGlobal.length < 2) {
return { audio: false, subtitle: false };
}
const masterAudioTracks = Array.isArray(masterGlobalTitle.audioTracks) ? masterGlobalTitle.audioTracks : [];
const masterSubtitleTracks = (Array.isArray(masterGlobalTitle.subtitleTracks) ? masterGlobalTitle.subtitleTracks : [])
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track));
const masterAudioSignatures = masterAudioTracks.map((track) => buildAudioSignature(track)).sort();
const masterSubtitleSignatures = masterSubtitleTracks.map((track) => buildSubtitleSignature(track)).sort();
const masterSubtitleLanguages = Array.from(new Set(masterSubtitleTracks.map((track) =>
normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und')
))).sort();
let audioCompatible = true;
let subtitleCompatible = true;
for (const title of selectedTitlesForGlobal) {
const titleId = normalizeTitleId(title?.id);
if (!titleId) {
continue;
}
const audioMap = new Map();
const titleAudioTracks = Array.isArray(title.audioTracks) ? title.audioTracks : [];
const titleAudioSignatures = titleAudioTracks.map((track) => {
const signature = buildAudioSignature(track);
const trackId = normalizeTrackId(track?.id);
if (trackId !== null && !audioMap.has(signature)) {
audioMap.set(signature, trackId);
}
return signature;
}).sort();
globalAudioTrackMaps.set(titleId, audioMap);
if (titleAudioSignatures.join('||') !== masterAudioSignatures.join('||')) {
audioCompatible = false;
}
const subtitleTracks = (Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [])
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track));
const subtitleSignatures = subtitleTracks.map((track) => buildSubtitleSignature(track)).sort();
const subtitleLanguages = Array.from(new Set(subtitleTracks.map((track) =>
normalizeSubtitleLanguage(track?.language || track?.languageLabel || 'und')
))).sort();
globalSubtitleLanguagesByTitle.set(titleId, subtitleLanguages);
if (subtitleSignatures.join('||') !== masterSubtitleSignatures.join('||')) {
subtitleCompatible = false;
}
if (subtitleLanguages.join('||') !== masterSubtitleLanguages.join('||')) {
subtitleCompatible = false;
}
}
return { audio: audioCompatible, subtitle: subtitleCompatible };
})();
const showGlobalEpisodeTrackControls = Boolean(
allowTrackSelection
&& allowTitleSelection
&& masterGlobalTitle
&& selectedTitlesForGlobal.length > 1
&& globalTrackCompatibility.audio
&& globalTrackCompatibility.subtitle
);
const useEpisodeTrackAccordion = Boolean(
masterGlobalTitle
&& selectedTitlesForGlobal.length > 1
&& globalTrackCompatibility.audio
&& globalTrackCompatibility.subtitle
);
return (
<div className="media-review-wrap">
{allowUserPresetSelection && (
{!compactTitleSelection && allowUserPresetSelection && (
<div className="user-preset-selector" style={{ marginBottom: '0.75rem', padding: '0.75rem', border: '1px solid var(--surface-border, #e0e0e0)', borderRadius: '6px', background: 'var(--surface-ground, #f8f8f8)' }}>
<label style={{ display: 'block', marginBottom: '0.4rem', fontWeight: 600 }}>
Encode-Preset auswählen
@@ -1315,9 +1589,10 @@ export default function MediaInfoReviewPanel({
)}
</div>
)}
<div className="media-review-meta">
<div>
<strong>Preset:</strong>{' '}
{!compactTitleSelection ? (
<div className="media-review-meta">
<div>
<strong>Preset:</strong>{' '}
{effectivePresetOverride
? (effectivePresetOverride.handbrakePreset || '(kein Preset)')
: presetLabel}
@@ -1345,13 +1620,14 @@ export default function MediaInfoReviewPanel({
<div><strong>Audio Fallback:</strong> {effectiveAudioSelector?.fallbackEncoder || '-'}</div>
<div><strong>Subtitle Auswahl:</strong> {review.selectors?.subtitle?.mode || '-'}</div>
<div><strong>Subtitle Flags:</strong> {review.selectors?.subtitle?.burnBehavior === 'first' ? 'burned(first)' : '-'}</div>
</div>
</div>
) : null}
{review.partial ? (
{!compactTitleSelection && review.partial ? (
<small>Zwischenstand: {processedFiles}/{totalFiles} Datei(en) analysiert.</small>
) : null}
{playlistRecommendation ? (
{!compactTitleSelection && playlistRecommendation ? (
<div className="playlist-recommendation-box">
<small>
<strong>Empfehlung:</strong> {playlistRecommendation.playlistFile || '-'}
@@ -1361,7 +1637,7 @@ export default function MediaInfoReviewPanel({
</div>
) : null}
{Array.isArray(review.notes) && review.notes.length > 0 ? (
{!compactTitleSelection && Array.isArray(review.notes) && review.notes.length > 0 ? (
<div className="media-review-notes">
{review.notes.map((note, idx) => (
<small key={`${idx}-${note}`}>{note}</small>
@@ -1370,7 +1646,7 @@ export default function MediaInfoReviewPanel({
) : null}
{/* Pre-Encode Items (scripts + chains unified) */}
{(allowEncodeItemSelection || preEncodeItems.length > 0) ? (
{!compactTitleSelection && (allowEncodeItemSelection || preEncodeItems.length > 0) ? (
<div className="post-script-box">
<h4>Pre-Encode Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
@@ -1491,7 +1767,8 @@ export default function MediaInfoReviewPanel({
) : null}
{/* Post-Encode Items (scripts + chains unified) */}
<div className="post-script-box">
{!compactTitleSelection ? (
<div className="post-script-box">
<h4>Post-Encode Ausführungen (optional)</h4>
{scriptCatalog.length === 0 && chainCatalog.length === 0 ? (
<small>Keine Skripte oder Ketten konfiguriert. In den Settings anlegen.</small>
@@ -1606,22 +1883,140 @@ export default function MediaInfoReviewPanel({
) : null}
</div>
) : null}
<small>Ausführung nach erfolgreichem Encode, strikt nacheinander (Drag-and-Drop möglich).</small>
</div>
<small>Ausführung nach erfolgreichem Encode, strikt nacheinander (Drag-and-Drop möglich).</small>
</div>
) : null}
<h4>Titel</h4>
{episodeFillOptions.length > 0 ? (
<div className="episode-fill-box">
<div className="episode-fill-field">
<label>Episodentitel befüllen ab Episode</label>
<Dropdown
value={String(selectedEpisodeFillStart || '').trim() || null}
options={episodeFillOptions}
optionLabel="label"
optionValue="value"
onChange={(event) => onEpisodeFillStartChange?.(event?.value || null)}
placeholder="Episode auswählen …"
disabled={!allowTitleSelection}
style={{ width: '100%' }}
filter
showClear
/>
<small>
Startet bei der gewählten Episode und befüllt alle ausgewählten DVD-Titel der Reihe nach.
</small>
</div>
</div>
) : null}
{showGlobalEpisodeTrackControls ? (
<div className="episode-global-selection-box">
<h4>Spurauswahl für alle Episoden</h4>
<small>Gilt für alle aktuell gewählten Episoden. Einzelne Episoden können danach weiterhin manuell überschrieben werden.</small>
<div className="media-track-grid">
{globalTrackCompatibility.audio && globalMasterSelectionState ? (
<TrackList
title="Tonspuren (alle Episoden)"
tracks={masterGlobalTitle?.audioTracks || []}
type="audio"
allowSelection={allowTrackSelection}
selectedTrackIds={globalMasterSelectionState.selectedAudioTrackIds}
audioSelector={effectiveAudioSelector}
onToggleTrack={(masterTrackId, checked) => {
if (!allowTrackSelection || typeof onTrackSelectionChange !== 'function') {
return;
}
const masterTrack = (Array.isArray(masterGlobalTitle?.audioTracks) ? masterGlobalTitle.audioTracks : [])
.find((track) => normalizeTrackId(track?.id) === normalizeTrackId(masterTrackId));
if (!masterTrack) {
return;
}
const signature = buildAudioSignature(masterTrack);
for (const title of selectedTitlesForGlobal) {
const titleId = normalizeTitleId(title?.id);
if (!titleId) {
continue;
}
const titleTrackMap = globalAudioTrackMaps.get(titleId);
const mappedTrackId = titleTrackMap?.get(signature) ?? null;
if (!mappedTrackId) {
continue;
}
onTrackSelectionChange(titleId, 'audio', mappedTrackId, checked);
}
}}
/>
) : (
<div>
<h4>Tonspuren (alle Episoden)</h4>
<small>Globale Auswahl nicht verfügbar, da die Tonspur-Layouts zwischen Episoden abweichen.</small>
</div>
)}
{globalTrackCompatibility.subtitle && globalMasterSelectionState ? (
<TrackList
title="Subtitles (alle Episoden)"
tracks={(Array.isArray(masterGlobalTitle?.subtitleTracks) ? masterGlobalTitle.subtitleTracks : [])
.filter((track) => !isBurnedSubtitleTrack(track))}
type="subtitle"
allowSelection={allowTrackSelection}
selectedTrackIds={globalMasterSelectionState.selectedSubtitleTrackIds}
selectedSubtitleVariantSelection={globalMasterSelectionState.selectedSubtitleVariantSelection}
selectedSubtitleLanguageOrder={globalMasterSelectionState.selectedSubtitleLanguageOrder}
onToggleSubtitleVariant={(language, variant, checked) => {
if (!allowTrackSelection || typeof onSubtitleVariantSelectionChange !== 'function') {
return;
}
const normalizedLanguage = normalizeSubtitleLanguage(language);
for (const title of selectedTitlesForGlobal) {
const titleId = normalizeTitleId(title?.id);
if (!titleId) {
continue;
}
const subtitleLanguages = globalSubtitleLanguagesByTitle.get(titleId) || [];
if (!subtitleLanguages.includes(normalizedLanguage)) {
continue;
}
onSubtitleVariantSelectionChange(titleId, normalizedLanguage, variant, checked);
}
}}
/>
) : (
<div>
<h4>Subtitles (alle Episoden)</h4>
<small>Globale Auswahl nicht verfügbar, da die Untertitel-Layouts zwischen Episoden abweichen.</small>
</div>
)}
</div>
</div>
) : null}
<div className="media-title-list">
{titles.length === 0 ? (
{displayTitles.length === 0 ? (
<p>Keine Titel analysiert.</p>
) : titles.map((title) => {
const titleEligible = title?.eligibleForEncode !== false;
) : displayTitles.map((title) => {
const normalizedTitleId = normalizeTitleId(title.id);
const titleChecked = allowTitleSelection
? (currentSelectedId !== null
? currentSelectedId === normalizeTitleId(title.id)
? (selectedTitleIdSet.size > 0
? selectedTitleIdSet.has(String(normalizedTitleId))
: Boolean(title.selectedForEncode))
: Boolean(title.selectedForEncode);
const titleIsPrimary = currentSelectedId !== null && currentSelectedId === normalizedTitleId;
const titleEpisodeAssignment = episodeAssignmentsByTitle?.[normalizedTitleId]
|| episodeAssignmentsByTitle?.[String(normalizedTitleId)]
|| null;
const episodeInputValue = String(
titleEpisodeAssignment?.episodeTitle
|| title?.episodeTitle
|| ''
);
const titleSelectionEntry = trackSelectionByTitle?.[title.id] || trackSelectionByTitle?.[String(title.id)] || {};
const subtitleTracks = Array.isArray(title.subtitleTracks) ? title.subtitleTracks : [];
const audioCount = Number.isFinite(Number(title?.audioTrackCount))
? Number(title.audioTrackCount)
: (Array.isArray(title.audioTracks) ? title.audioTracks.length : 0);
const subtitleCount = Number.isFinite(Number(title?.subtitleTrackCount))
? Number(title.subtitleTrackCount)
: subtitleTracks.length;
const selectableSubtitleTrackIds = subtitleTracks
.filter((track) => !isBurnedSubtitleTrack(track) && !isDuplicateSubtitleTrack(track))
.map((track) => normalizeTrackId(track?.id))
@@ -1691,21 +2086,52 @@ export default function MediaInfoReviewPanel({
<input
type="checkbox"
checked={titleChecked}
onChange={() => {
if (!allowTitleSelection || typeof onSelectEncodeTitle !== 'function') {
onChange={(event) => {
if (!allowTitleSelection) {
return;
}
onSelectEncodeTitle(normalizeTitleId(title.id));
const checked = Boolean(event?.target?.checked);
if (typeof onToggleEncodeTitle === 'function') {
onToggleEncodeTitle(normalizedTitleId, checked);
}
if (checked && typeof onSelectEncodeTitle === 'function') {
onSelectEncodeTitle(normalizedTitleId);
}
}}
readOnly={!allowTitleSelection}
disabled={!allowTitleSelection}
/>
<span>
#{title.id} | {title.fileName} | {formatDuration(title.durationMinutes)} | {formatBytes(title.sizeBytes)}
#{title.id} | {title.fileName} | {formatDuration(title.durationMinutes)}
{audioCount > 0 ? ` | Audio: ${audioCount}` : ''}
{subtitleCount > 0 ? ` | Untertitel: ${subtitleCount}` : ''}
{title.encodeInput ? ' | Encode-Input' : ''}
{titleIsPrimary ? ' | Primär' : ''}
</span>
</label>
{titleChecked ? (
<div className="episode-title-input-row">
<label htmlFor={`episode-title-${title.id}`}>Episodentitel</label>
<input
id={`episode-title-${title.id}`}
className="p-inputtext p-component"
type="text"
value={episodeInputValue}
onChange={(event) => {
if (!allowTitleSelection || typeof onEpisodeAssignmentChange !== 'function') {
return;
}
onEpisodeAssignmentChange(normalizedTitleId, {
episodeTitle: event?.target?.value || ''
});
}}
placeholder="Episodentitel"
disabled={!allowTitleSelection}
/>
</div>
) : null}
{title.playlistFile || title.playlistEvaluationLabel || title.playlistSegmentCommand ? (
<div className="playlist-info-box">
<small>
@@ -1729,40 +2155,85 @@ export default function MediaInfoReviewPanel({
</div>
) : null}
<div className="media-track-grid">
<TrackList
title={`Tonspuren (Titel #${title.id})`}
tracks={title.audioTracks || []}
type="audio"
allowSelection={allowTrackSelectionForTitle}
selectedTrackIds={selectedAudioTrackIds}
audioSelector={effectiveAudioSelector}
onToggleTrack={(trackId, checked) => {
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
return;
}
onTrackSelectionChange(title.id, 'audio', trackId, checked);
}}
/>
<TrackList
title={`Subtitles (Titel #${title.id})`}
tracks={allowTrackSelectionForTitle
? subtitleTracks.filter((track) => !isBurnedSubtitleTrack(track))
: subtitleTracks}
type="subtitle"
allowSelection={allowTrackSelectionForTitle}
selectedTrackIds={selectedSubtitleTrackIds}
selectedSubtitleVariantSelection={selectedSubtitleVariantSelection}
selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder}
onToggleSubtitleVariant={(language, variant, checked) => {
if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') {
return;
}
onSubtitleVariantSelectionChange(title.id, language, variant, checked);
}}
/>
</div>
{titleChecked ? (() => {
{!compactTitleSelection && useEpisodeTrackAccordion ? (
<details className="episode-track-accordion">
<summary>Tonspuren und Untertitel</summary>
<div className="media-track-grid">
<TrackList
title={`Tonspuren (Titel #${title.id})`}
tracks={title.audioTracks || []}
type="audio"
allowSelection={allowTrackSelectionForTitle}
selectedTrackIds={selectedAudioTrackIds}
audioSelector={effectiveAudioSelector}
onToggleTrack={(trackId, checked) => {
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
return;
}
onTrackSelectionChange(title.id, 'audio', trackId, checked);
}}
/>
<TrackList
title={`Subtitles (Titel #${title.id})`}
tracks={allowTrackSelectionForTitle
? subtitleTracks.filter((track) => !isBurnedSubtitleTrack(track))
: subtitleTracks}
type="subtitle"
allowSelection={allowTrackSelectionForTitle}
selectedTrackIds={selectedSubtitleTrackIds}
selectedSubtitleVariantSelection={selectedSubtitleVariantSelection}
selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder}
onToggleSubtitleVariant={(language, variant, checked) => {
if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') {
return;
}
onSubtitleVariantSelectionChange(title.id, language, variant, checked);
}}
/>
</div>
</details>
) : (!compactTitleSelection ? (
<div className="media-track-grid">
<TrackList
title={`Tonspuren (Titel #${title.id})`}
tracks={title.audioTracks || []}
type="audio"
allowSelection={allowTrackSelectionForTitle}
selectedTrackIds={selectedAudioTrackIds}
audioSelector={effectiveAudioSelector}
onToggleTrack={(trackId, checked) => {
if (!allowTrackSelectionForTitle || typeof onTrackSelectionChange !== 'function') {
return;
}
onTrackSelectionChange(title.id, 'audio', trackId, checked);
}}
/>
<TrackList
title={`Subtitles (Titel #${title.id})`}
tracks={allowTrackSelectionForTitle
? subtitleTracks.filter((track) => !isBurnedSubtitleTrack(track))
: subtitleTracks}
type="subtitle"
allowSelection={allowTrackSelectionForTitle}
selectedTrackIds={selectedSubtitleTrackIds}
selectedSubtitleVariantSelection={selectedSubtitleVariantSelection}
selectedSubtitleLanguageOrder={selectedSubtitleLanguageOrder}
onToggleSubtitleVariant={(language, variant, checked) => {
if (!allowTrackSelectionForTitle || typeof onSubtitleVariantSelectionChange !== 'function') {
return;
}
onSubtitleVariantSelectionChange(title.id, language, variant, checked);
}}
/>
</div>
) : null)}
{!compactTitleSelection && titleChecked ? (() => {
const resolvedCommandOutputPath = String(
commandOutputPathByTitle?.[normalizeTitleId(title?.id)]
|| commandOutputPathByTitle?.[String(normalizeTitleId(title?.id))]
|| commandOutputPath
|| ''
).trim() || null;
const commandPreview = buildHandBrakeCommandPreview({
review,
title,
@@ -1770,7 +2241,7 @@ export default function MediaInfoReviewPanel({
selectedSubtitleTrackIds,
selectedSubtitleVariantSelection,
selectedSubtitleLanguageOrder,
commandOutputPath,
commandOutputPath: resolvedCommandOutputPath,
presetOverride: effectivePresetOverride
});
return (
@@ -18,8 +18,14 @@ export default function MetadataSelectionDialog({
const [manualTitle, setManualTitle] = useState('');
const [manualYear, setManualYear] = useState('');
const [manualImdb, setManualImdb] = useState('');
const [manualDiscNumber, setManualDiscNumber] = useState('');
const [extraResults, setExtraResults] = useState([]);
const [searchBusy, setSearchBusy] = useState(false);
const [searchError, setSearchError] = useState('');
const [seasonFilter, setSeasonFilter] = useState('');
const metadataProvider = String(context?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
const providerLabel = metadataProvider === 'tmdb' ? 'TMDb' : 'OMDb';
const supportsExternalIdInput = metadataProvider === 'omdb';
useEffect(() => {
if (!visible) {
@@ -30,30 +36,64 @@ export default function MetadataSelectionDialog({
const defaultTitle = selectedMetadata.title || context?.detectedTitle || '';
const defaultYear = selectedMetadata.year ? String(selectedMetadata.year) : '';
const defaultImdb = selectedMetadata.imdbId || '';
const hintedDiscNumber = selectedMetadata.discNumber || context?.seriesLookupHint?.discNumber || null;
const defaultDiscNumber = Number(hintedDiscNumber || 0) > 0
? String(Math.trunc(Number(hintedDiscNumber || 0)))
: '';
setSelected(null);
setQuery(defaultTitle);
setManualTitle(defaultTitle);
setManualYear(defaultYear);
setManualImdb(defaultImdb);
setManualDiscNumber(defaultDiscNumber);
setExtraResults([]);
setSearchBusy(false);
setSearchError('');
setSeasonFilter('');
}, [visible, context]);
const rows = useMemo(() => {
const base = context?.omdbCandidates || [];
const base = Array.isArray(context?.metadataCandidates)
? context.metadataCandidates
: (context?.omdbCandidates || []);
const all = [...base, ...extraResults];
const map = new Map();
all.forEach((item) => {
if (item?.imdbId) {
map.set(item.imdbId, item);
const rowKey = String(
item?.providerId
|| item?.imdbId
|| item?.tmdbId
|| `${item?.title || '-'}::${item?.year || '-'}::${item?.seasonNumber || '-'}`
).trim();
if (!rowKey) {
return;
}
map.set(rowKey, {
...item,
_metadataKey: rowKey
});
});
return Array.from(map.values());
}, [context, extraResults]);
const filteredRows = useMemo(() => {
if (metadataProvider !== 'tmdb') {
return rows;
}
const normalizedFilter = String(seasonFilter || '').trim().toLowerCase();
if (!normalizedFilter) {
return rows;
}
return rows.filter((row) => {
const seasonNo = String(row?.seasonNumber ?? '').trim().toLowerCase();
const seasonName = String(row?.seasonName || '').trim().toLowerCase();
return seasonNo.includes(normalizedFilter) || seasonName.includes(normalizedFilter);
});
}, [metadataProvider, rows, seasonFilter]);
const titleWithPosterBody = (row) => (
<div className="omdb-row">
{row.poster && row.poster !== 'N/A' ? (
@@ -63,7 +103,14 @@ export default function MetadataSelectionDialog({
)}
<div>
<div><strong>{row.title}</strong></div>
<small>{row.year} | {row.imdbId}</small>
<small>
{row.year || '-'}
{row.seasonNumber ? ` | Staffel ${row.seasonNumber}` : ''}
{row.seasonName ? ` | ${row.seasonName}` : ''}
{row.episodeCount ? ` | ${row.episodeCount} Episoden` : ''}
{row.imdbId ? ` | ${row.imdbId}` : ''}
{!row.imdbId && row.tmdbId ? ` | TMDb ${row.tmdbId}` : ''}
</small>
</div>
</div>
);
@@ -74,15 +121,26 @@ export default function MetadataSelectionDialog({
return;
}
setSearchBusy(true);
setSearchError('');
try {
const results = await onSearch(trimmedQuery);
const results = await onSearch(trimmedQuery, {
metadataProvider
});
setExtraResults(Array.isArray(results) ? results : []);
} catch (error) {
setExtraResults([]);
setSearchError(error?.message || 'Suche fehlgeschlagen.');
} finally {
setSearchBusy(false);
}
};
const handleSubmit = async () => {
const parsedDiscNumber = Number(manualDiscNumber);
const effectiveDiscNumber = Number.isFinite(parsedDiscNumber) && parsedDiscNumber > 0
? Math.trunc(parsedDiscNumber)
: null;
const payload = selected
? {
jobId: context.jobId,
@@ -90,15 +148,27 @@ export default function MetadataSelectionDialog({
year: selected.year,
imdbId: selected.imdbId,
poster: selected.poster && selected.poster !== 'N/A' ? selected.poster : null,
fromOmdb: true
fromOmdb: metadataProvider === 'omdb',
metadataProvider,
providerId: selected.providerId || null,
tmdbId: selected.tmdbId || null,
metadataKind: selected.metadataKind || null,
seasonNumber: selected.seasonNumber || null,
seasonName: selected.seasonName || null,
episodeCount: selected.episodeCount || 0,
episodes: Array.isArray(selected.episodes) ? selected.episodes : [],
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
}
: {
jobId: context.jobId,
title: manualTitle,
year: manualYear,
imdbId: manualImdb,
imdbId: supportsExternalIdInput ? manualImdb : null,
poster: null,
fromOmdb: false
fromOmdb: false,
metadataProvider,
seasonNumber: null,
...(effectiveDiscNumber ? { discNumber: effectiveDiscNumber } : {})
};
await onSubmit(payload);
@@ -126,16 +196,39 @@ export default function MetadataSelectionDialog({
}}
placeholder="Titel suchen"
/>
<Button label="OMDb Suche" icon="pi pi-search" onClick={handleSearch} loading={searchBusy} disabled={searchBusy || busy} />
<Button label={`${providerLabel} Suche`} icon="pi pi-search" onClick={handleSearch} loading={searchBusy} disabled={searchBusy || busy} />
</div>
<div className={`metadata-filter-grid${metadataProvider !== 'tmdb' ? ' metadata-filter-grid-single' : ''}`}>
{metadataProvider === 'tmdb' ? (
<InputText
value={seasonFilter}
onChange={(event) => setSeasonFilter(event.target.value)}
placeholder="Staffel-Filter (optional, z.B. 1 oder 5.2)"
disabled={searchBusy || busy}
/>
) : null}
<div className="metadata-filter-disc">
<InputText
value={manualDiscNumber}
onChange={(event) => setManualDiscNumber(event.target.value)}
placeholder="Disc-Nr (optional)"
inputMode="numeric"
disabled={searchBusy || busy}
/>
<small className="metadata-filter-subtitle">
Optionale Disc-Nummer fuer internes Mapping und Output-Templates. Nur Werte groesser 0 werden gespeichert.
</small>
</div>
</div>
{searchError ? <small className="error-text">{searchError}</small> : null}
<div className="table-scroll-wrap table-scroll-medium">
<DataTable
value={rows}
value={filteredRows}
selectionMode="single"
selection={selected}
onSelectionChange={(event) => setSelected(event.value)}
dataKey="imdbId"
dataKey="_metadataKey"
size="small"
scrollable
scrollHeight="22rem"
@@ -145,7 +238,13 @@ export default function MetadataSelectionDialog({
>
<Column header="Titel" body={titleWithPosterBody} />
<Column field="year" header="Jahr" style={{ width: '8rem' }} />
<Column field="imdbId" header="IMDb" style={{ width: '10rem' }} />
<Column
header={metadataProvider === 'tmdb' ? 'Staffel' : 'IMDb'}
body={(row) => (metadataProvider === 'tmdb'
? (row.seasonNumber ? `S${row.seasonNumber}` : '-')
: (row.imdbId || '-'))}
style={{ width: '10rem' }}
/>
</DataTable>
</div>
@@ -166,8 +265,8 @@ export default function MetadataSelectionDialog({
<InputText
value={manualImdb}
onChange={(event) => setManualImdb(event.target.value)}
placeholder="IMDb-ID"
disabled={!!selected}
placeholder={supportsExternalIdInput ? 'IMDb-ID' : 'Externe ID'}
disabled={!!selected || !supportsExternalIdInput}
/>
</div>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -112,7 +112,7 @@ export default function DatabasePage() {
<Card
title="Gefundene RAW-Einträge"
subTitle="Es werden nur RAW-Ordner ohne zugehörigen Historienjob aus den konfigurierten RAW-Pfaden angezeigt."
subTitle="Es werden RAW-Ordner ohne zugehörigen Historienjob aus Settings- und Default-RAW-Pfaden angezeigt."
>
<div className="table-filters">
<Button
+200 -46
View File
@@ -23,11 +23,13 @@ import {
STATUS_FILTER_OPTIONS
} from '../utils/statusPresentation';
import { confirmModal } from '../utils/confirmModal';
import { isSeriesDvdJob } from '../utils/jobTaxonomy';
const MEDIA_FILTER_OPTIONS = [
{ label: 'Alle Medien', value: '' },
{ label: 'Blu-ray', value: 'bluray' },
{ label: 'DVD', value: 'dvd' },
{ label: 'DVD Serie', value: 'dvd_series' },
{ label: 'Audio CD', value: 'cd' },
{ label: 'Audiobook', value: 'audiobook' },
{ label: 'Sonstiges', value: 'other' }
@@ -123,6 +125,7 @@ function resolveMediaType(row) {
function resolveMediaTypeMeta(row) {
const mediaType = resolveMediaType(row);
const isSeriesDvd = mediaType === 'dvd' && isSeriesDvdJob(row);
if (mediaType === 'bluray') {
return {
mediaType,
@@ -135,8 +138,8 @@ function resolveMediaTypeMeta(row) {
return {
mediaType,
icon: discIndicatorIcon,
label: 'DVD',
alt: 'DVD'
label: isSeriesDvd ? 'DVD Serie' : 'DVD',
alt: isSeriesDvd ? 'DVD Serie' : 'DVD'
};
}
if (mediaType === 'cd') {
@@ -171,6 +174,14 @@ function resolveMediaTypeMeta(row) {
};
}
function isSeriesBatchChildHistoryRow(row) {
const encodePlan = row?.encodePlan && typeof row.encodePlan === 'object' ? row.encodePlan : null;
if (!encodePlan) {
return false;
}
return Boolean(encodePlan?.seriesBatchChild || encodePlan?.seriesBatchVirtualEpisode);
}
function formatDurationSeconds(totalSeconds) {
const parsed = Number(totalSeconds);
if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -438,10 +449,10 @@ export default function HistoryPage({ refreshToken = 0 }) {
const [metadataDialogVisible, setMetadataDialogVisible] = useState(false);
const [metadataDialogContext, setMetadataDialogContext] = useState(null);
const [omdbAssignBusy, setOmdbAssignBusy] = useState(false);
const [pendingRestartRow, setPendingRestartRow] = useState(null);
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
const [acknowledgeErrorBusy, setAcknowledgeErrorBusy] = useState(false);
const [loading, setLoading] = useState(false);
const [queuedJobIds, setQueuedJobIds] = useState([]);
const [conflictModalVisible, setConflictModalVisible] = useState(false);
@@ -464,19 +475,26 @@ export default function HistoryPage({ refreshToken = 0 }) {
}, [queuedJobIds]);
const preparedJobs = useMemo(
() => jobs.map((job) => ({
...job,
sortTitle: normalizeSortText(job?.title || job?.detected_title || ''),
sortMediaType: resolveMediaType(job),
sortStartTime: resolveSortTimestamp(job?.start_time, job?.updated_at, job?.created_at, job?.end_time),
sortEndTime: resolveSortTimestamp(job?.end_time, job?.updated_at, job?.created_at, job?.start_time)
})),
() => jobs
.filter((job) => !isSeriesBatchChildHistoryRow(job))
.map((job) => ({
...job,
sortTitle: normalizeSortText(job?.title || job?.detected_title || ''),
sortMediaType: resolveMediaType(job),
sortStartTime: resolveSortTimestamp(job?.start_time, job?.updated_at, job?.created_at, job?.end_time),
sortEndTime: resolveSortTimestamp(job?.end_time, job?.updated_at, job?.created_at, job?.start_time)
})),
[jobs]
);
const visibleJobs = useMemo(
() => (mediumFilter
? preparedJobs.filter((job) => job.sortMediaType === mediumFilter)
? preparedJobs.filter((job) => {
if (mediumFilter === 'dvd_series') {
return job.sortMediaType === 'dvd' && isSeriesDvdJob(job);
}
return job.sortMediaType === mediumFilter;
})
: preparedJobs),
[preparedJobs, mediumFilter]
);
@@ -1003,12 +1021,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
};
const handleReencode = async (row) => {
const mediaType = resolveMediaType(row);
if (mediaType === 'bluray' || mediaType === 'dvd') {
setPendingRestartRow(row);
handleAssignOmdb(row);
return;
}
if (await maybeOpenOutputConflictModal(row, 'reencode')) {
return;
}
@@ -1086,50 +1098,93 @@ export default function HistoryPage({ refreshToken = 0 }) {
const handleAssignOmdb = (row) => {
const jobId = Number(row?.id || 0);
if (!jobId) return;
const makemkvInfo = row?.makemkvInfo && typeof row.makemkvInfo === 'object' ? row.makemkvInfo : {};
const analyzeContext = makemkvInfo?.analyzeContext && typeof makemkvInfo.analyzeContext === 'object'
? makemkvInfo.analyzeContext
: {};
const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
? analyzeContext.selectedMetadata
: (makemkvInfo?.selectedMetadata && typeof makemkvInfo.selectedMetadata === 'object'
? makemkvInfo.selectedMetadata
: {});
const isSeriesDvd = resolveMediaType(row) === 'dvd' && isSeriesDvdJob(row);
const metadataProvider = isSeriesDvd ? 'tmdb' : 'omdb';
const seasonNumber = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
const discNumber = selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null;
const context = {
jobId,
detectedTitle: row?.detected_title || row?.title || '',
selectedMetadata: {
title: row?.title || row?.detected_title || '',
year: row?.year || null,
imdbId: row?.imdb_id || null,
poster: row?.poster_url || null
...(selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {}),
title: row?.title || selectedMetadata?.title || row?.detected_title || '',
year: row?.year || selectedMetadata?.year || null,
imdbId: row?.imdb_id || selectedMetadata?.imdbId || null,
poster: row?.poster_url || selectedMetadata?.poster || null,
metadataProvider,
tmdbId: selectedMetadata?.tmdbId || null,
providerId: selectedMetadata?.providerId || null,
metadataKind: selectedMetadata?.metadataKind || (isSeriesDvd ? 'season' : null),
seasonNumber,
seasonName: selectedMetadata?.seasonName || null,
episodeCount: selectedMetadata?.episodeCount || 0,
episodes: Array.isArray(selectedMetadata?.episodes) ? selectedMetadata.episodes : [],
...(discNumber ? { discNumber } : {})
},
metadataProvider,
metadataCandidates: Array.isArray(analyzeContext?.metadataCandidates) ? analyzeContext.metadataCandidates : [],
seriesAnalysis: analyzeContext?.seriesAnalysis || null,
seriesLookupHint: analyzeContext?.seriesLookupHint || null,
omdbCandidates: []
};
setMetadataDialogContext(context);
setMetadataDialogVisible(true);
};
const handleOmdbSearch = async (query) => {
const handleOmdbSearch = async (query, options = {}) => {
const provider = String(
options?.metadataProvider
|| metadataDialogContext?.metadataProvider
|| 'omdb'
).trim().toLowerCase() || 'omdb';
try {
const response = await api.searchOmdb(query);
const tmdbSeasonHint = metadataDialogContext?.selectedMetadata?.seasonNumber
?? metadataDialogContext?.seriesLookupHint?.seasonNumber
?? null;
const response = provider === 'tmdb'
? await api.searchTmdbSeries(query, tmdbSeasonHint)
: await api.searchOmdb(query);
return response.results || [];
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'OMDB-Suche fehlgeschlagen', detail: error.message });
toastRef.current?.show({
severity: 'error',
summary: provider === 'tmdb' ? 'TMDb-Suche fehlgeschlagen' : 'OMDb-Suche fehlgeschlagen',
detail: error.message
});
return [];
}
};
const handleOmdbSubmit = async (payload) => {
const provider = String(payload?.metadataProvider || metadataDialogContext?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
setOmdbAssignBusy(true);
try {
await api.assignJobOmdb(payload.jobId, payload);
toastRef.current?.show({ severity: 'success', summary: 'Metadaten zugewiesen', detail: 'OMDB-Metadaten wurden aktualisiert.', life: 3000 });
toastRef.current?.show({
severity: 'success',
summary: 'Metadaten zugewiesen',
detail: provider === 'tmdb' ? 'TMDb-Metadaten wurden aktualisiert.' : 'OMDb-Metadaten wurden aktualisiert.',
life: 3000
});
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
await load();
await refreshDetailIfOpen(payload.jobId);
const rowToRestart = pendingRestartRow;
if (rowToRestart && Number(rowToRestart.id) === Number(payload.jobId)) {
setPendingRestartRow(null);
if (!(await maybeOpenOutputConflictModal(rowToRestart, 'reencode'))) {
await executeHistoryAction(rowToRestart, 'reencode');
}
}
} catch (error) {
toastRef.current?.show({ severity: 'error', summary: 'OMDB-Zuweisung fehlgeschlagen', detail: error.message });
toastRef.current?.show({
severity: 'error',
summary: provider === 'tmdb' ? 'TMDb-Zuweisung fehlgeschlagen' : 'OMDb-Zuweisung fehlgeschlagen',
detail: error.message
});
} finally {
setOmdbAssignBusy(false);
}
@@ -1189,6 +1244,37 @@ export default function HistoryPage({ refreshToken = 0 }) {
}
};
const handleAcknowledgeError = async (job) => {
const jobId = normalizeJobId(job?.id || selectedJob?.id);
if (!jobId) {
return;
}
setAcknowledgeErrorBusy(true);
try {
const response = await api.acknowledgeJobError(jobId);
const updated = response?.job || null;
if (updated) {
setSelectedJob(updated);
}
await load();
toastRef.current?.show({
severity: 'success',
summary: 'Fehler quittiert',
detail: `Job #${jobId} wurde quittiert.`,
life: 2500
});
} catch (error) {
toastRef.current?.show({
severity: 'error',
summary: 'Quittierung fehlgeschlagen',
detail: error.message,
life: 4500
});
} finally {
setAcknowledgeErrorBusy(false);
}
};
const closeDeleteEntryDialog = () => {
if (deleteEntryTargetBusy) {
return;
@@ -1373,12 +1459,56 @@ export default function HistoryPage({ refreshToken = 0 }) {
return <div className="history-dv-poster-fallback">{isAudioSquare ? 'Kein Cover' : 'Kein Poster'}</div>;
};
const renderPresenceChip = (label, available) => (
<span className={`history-dv-chip ${available ? 'tone-ok' : 'tone-no'}`}>
<i className={`pi ${available ? 'pi-check-circle' : 'pi-times-circle'}`} aria-hidden="true" />
<span>{label}: {available ? 'Ja' : 'Nein'}</span>
</span>
);
const renderPresenceChip = (label, available, tone = null, suffix = '') => {
const resolvedTone = tone || (available ? 'tone-ok' : 'tone-no');
const iconClass = available ? 'pi-check-circle' : 'pi-times-circle';
return (
<span className={`history-dv-chip ${resolvedTone}`}>
<i className={`pi ${iconClass}`} aria-hidden="true" />
<span>{label}: {available ? 'Ja' : 'Nein'}{suffix}</span>
</span>
);
};
const renderSeriesOutputChip = (row) => {
const summary = row?.seriesOutputSummary || null;
if (!summary) {
return renderPresenceChip('Episoden', Boolean(row?.outputStatus?.exists));
}
const expected = Number(summary.expected || 0);
const existing = Number(summary.existing || 0);
const hasExpected = Number.isFinite(expected) && expected > 0;
const safeExpected = hasExpected ? expected : 0;
const safeExisting = Number.isFinite(existing) && existing >= 0 ? existing : 0;
const suffix = hasExpected ? ` (${safeExisting}/${safeExpected})` : '';
if (safeExisting <= 0) {
return renderPresenceChip('Episoden', false, 'tone-no', suffix || ' (0/0)');
}
if (hasExpected && safeExisting < safeExpected) {
return renderPresenceChip('Episoden', true, 'tone-warn', suffix);
}
return renderPresenceChip('Episoden', true, 'tone-ok', suffix || ` (${safeExisting}/${safeExpected || safeExisting})`);
};
const renderSeriesRawChip = (row) => {
const summary = row?.seriesChildSummary?.raw || null;
if (!summary) {
return renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists));
}
const expected = Number(summary.expected || 0);
const existing = Number(summary.existing || 0);
const hasExpected = Number.isFinite(expected) && expected > 0;
const safeExpected = hasExpected ? expected : 0;
const safeExisting = Number.isFinite(existing) && existing >= 0 ? existing : 0;
const suffix = hasExpected ? ` (${safeExisting}/${safeExpected})` : '';
if (safeExisting <= 0) {
return renderPresenceChip('RAW', false, 'tone-no', suffix || ' (0/0)');
}
if (hasExpected && safeExisting < safeExpected) {
return renderPresenceChip('RAW', true, 'tone-warn', suffix);
}
return renderPresenceChip('RAW', true, 'tone-ok', suffix || ` (${safeExisting}/${safeExpected || safeExisting})`);
};
const renderSupplementalInfo = (row) => {
if (resolveMediaType(row) === 'cd') {
@@ -1464,6 +1594,16 @@ export default function HistoryPage({ refreshToken = 0 }) {
const isCdJob = mediaMeta.mediaType === 'cd';
const outputIsAudio = hasAudioOutput(row);
const cdDetails = isCdJob ? resolveCdDetails(row) : null;
const selectedMetadata = row?.makemkvInfo?.selectedMetadata && typeof row.makemkvInfo.selectedMetadata === 'object'
? row.makemkvInfo.selectedMetadata
: {};
const isSeriesDvd = resolveMediaType(row) === 'dvd' && isSeriesDvdJob(row);
const seasonLabel = isSeriesDvd && selectedMetadata?.seasonNumber
? `Staffel ${selectedMetadata.seasonNumber}`
: null;
const tmdbLabel = isSeriesDvd && selectedMetadata?.tmdbId
? `TMDb ${selectedMetadata.tmdbId}`
: null;
const subtitle = isCdJob
? [
`#${row?.id || '-'}`,
@@ -1471,7 +1611,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
row?.year || null,
cdDetails?.mbId ? 'MusicBrainz' : null
].filter(Boolean).join(' | ')
: `#${row?.id || '-'} | ${row?.year || '-'} | ${row?.imdb_id || '-'}`;
: [
`#${row?.id || '-'}`,
row?.year || '-',
isSeriesDvd ? seasonLabel : (row?.imdb_id || '-'),
tmdbLabel
].filter(Boolean).join(' | ');
return (
<div className="col-12" key={row.id}>
@@ -1507,8 +1652,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
</div>
<div className="history-dv-flags-row">
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
{renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
{isSeriesDvd
? renderSeriesRawChip(row)
: renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
{isSeriesDvd
? renderSeriesOutputChip(row)
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
</div>
@@ -1571,8 +1720,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
</div>
<div className="history-dv-flags-row">
{renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
{renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
{isSeriesDvd
? renderSeriesRawChip(row)
: renderPresenceChip('RAW', Boolean(row?.rawStatus?.exists))}
{isSeriesDvd
? renderSeriesOutputChip(row)
: renderPresenceChip(outputIsAudio ? 'Audio' : 'Movie', Boolean(row?.outputStatus?.exists))}
{renderPresenceChip('Encode', Boolean(row?.encodeSuccess))}
</div>
@@ -1686,6 +1839,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
onRetry={resolveMediaType(selectedJob) === 'converter' ? null : handleRetry}
onAssignOmdb={handleAssignOmdb}
onAssignCdMetadata={handleAssignCdMetadata}
onAcknowledgeError={handleAcknowledgeError}
onDeleteFiles={handleDeleteFiles}
onDeleteEntry={handleDeleteEntry}
onDownloadArchive={handleDownloadArchive}
@@ -1695,6 +1849,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
actionBusy={actionBusy}
omdbAssignBusy={omdbAssignBusy}
cdMetadataAssignBusy={cdMetadataAssignBusy}
acknowledgeErrorBusy={acknowledgeErrorBusy}
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
deleteEntryBusy={deleteEntryBusy}
downloadBusyTarget={downloadBusyTarget}
@@ -1855,7 +2010,6 @@ export default function HistoryPage({ refreshToken = 0 }) {
onHide={() => {
setMetadataDialogVisible(false);
setMetadataDialogContext(null);
setPendingRestartRow(null);
}}
onSubmit={handleOmdbSubmit}
onSearch={handleOmdbSearch}
+269 -51
View File
@@ -21,7 +21,7 @@ import otherIndicatorIcon from '../assets/media-other.svg';
import audiobookIndicatorIcon from '../assets/media-audiobook.svg';
import { getStatusLabel, getStatusSeverity, normalizeStatus } from '../utils/statusPresentation';
import { confirmModal } from '../utils/confirmModal';
import { isConverterJob, resolveMediaType as resolveCentralMediaType } from '../utils/jobTaxonomy';
import { isConverterJob, isSeriesDvdJob, resolveMediaType as resolveCentralMediaType } from '../utils/jobTaxonomy';
const processingStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_ANALYZING', 'CD_RIPPING', 'CD_ENCODING'];
const driveActiveStates = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'CD_ANALYZING', 'CD_RIPPING'];
@@ -543,11 +543,18 @@ function resolveMediaType(job) {
function mediaIndicatorMeta(job) {
const mediaType = resolveMediaType(job);
const isSeriesDvd = mediaType === 'dvd' && isSeriesDvdJob(job);
if (mediaType === 'bluray') {
return { mediaType, src: blurayIndicatorIcon, alt: 'Blu-ray', title: 'Blu-ray' };
}
if (mediaType === 'dvd') {
return { mediaType, src: discIndicatorIcon, alt: 'DVD', title: 'DVD' };
return {
mediaType,
src: discIndicatorIcon,
alt: isSeriesDvd ? 'DVD Serie' : 'DVD',
title: isSeriesDvd ? 'DVD Serie' : 'DVD',
isSeriesDvd
};
}
if (mediaType === 'cd') {
return { mediaType, src: otherIndicatorIcon, alt: 'Audio CD', title: 'Audio CD' };
@@ -748,7 +755,16 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
mbId: resolvedCdMbId,
coverUrl: resolvedCdCoverUrl,
imdbId: job?.imdb_id || null,
poster: job?.poster_url || resolvedCdCoverUrl || null
poster: job?.poster_url || resolvedCdCoverUrl || null,
metadataProvider: analyzeContext?.metadataProvider || 'omdb',
providerId: analyzeContext?.selectedMetadata?.providerId || null,
tmdbId: analyzeContext?.selectedMetadata?.tmdbId || null,
metadataKind: analyzeContext?.selectedMetadata?.metadataKind || null,
seasonNumber: analyzeContext?.selectedMetadata?.seasonNumber || analyzeContext?.seriesLookupHint?.seasonNumber || null,
seasonName: analyzeContext?.selectedMetadata?.seasonName || null,
episodeCount: analyzeContext?.selectedMetadata?.episodeCount || 0,
episodes: Array.isArray(analyzeContext?.selectedMetadata?.episodes) ? analyzeContext.selectedMetadata.episodes : [],
discNumber: analyzeContext?.selectedMetadata?.discNumber || analyzeContext?.seriesLookupHint?.discNumber || null
};
const mode = String(encodePlan?.mode || 'rip').trim().toLowerCase();
const isPreRip = mode === 'pre_rip' || Boolean(encodePlan?.preRip);
@@ -836,6 +852,10 @@ function buildPipelineFromJob(job, currentPipeline, currentPipelineJobId) {
: [],
selectedPlaylist: analyzeContext.selectedPlaylist || null,
selectedTitleId: analyzeContext.selectedTitleId ?? null,
metadataProvider: analyzeContext.metadataProvider || 'omdb',
metadataCandidates: Array.isArray(analyzeContext.metadataCandidates) ? analyzeContext.metadataCandidates : [],
seriesAnalysis: analyzeContext.seriesAnalysis || null,
seriesLookupHint: analyzeContext.seriesLookupHint || null,
omdbCandidates: [],
canRestartEncodeFromLastSettings,
canRestartReviewFromRaw
@@ -1048,7 +1068,8 @@ export default function RipperPage({
api.getJobs({
statuses: Array.from(ripperStatuses),
limit: 160,
lite: true
lite: true,
includeChildren: true
}),
api.getPipelineQueue()
]);
@@ -1062,6 +1083,9 @@ export default function RipperPage({
if (isConverterJob(job)) {
return false;
}
if (String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container') {
return false;
}
const normalizedStatus = String(job?.status || '').trim().toUpperCase();
if (!ripperStatuses.has(normalizedStatus)) {
return false;
@@ -1113,8 +1137,12 @@ export default function RipperPage({
seen.add(String(id));
deduped.push(job);
}
const filteredJobs = deduped.filter((job) => {
const isSeriesBatchChild = Boolean(job?.encodePlan?.seriesBatchChild);
return !isSeriesBatchChild;
});
setRipperJobs(deduped);
setRipperJobs(filteredJobs);
// Prüfen ob Audiobook-Jobs auf Activation Bytes warten
try {
@@ -1314,13 +1342,20 @@ export default function RipperPage({
title: job?.title || job?.detected_title || context?.detectedTitle || '',
year: job?.year || null,
imdbId: job?.imdb_id || null,
poster: job?.poster_url || null
poster: job?.poster_url || null,
metadataProvider: context?.metadataProvider || 'omdb',
seasonNumber: context?.seriesLookupHint?.seasonNumber || null,
discNumber: context?.seriesLookupHint?.discNumber || null
};
return {
...context,
jobId: normalizedJobId,
detectedTitle: context?.detectedTitle || job?.detected_title || selectedMetadata?.title || '',
selectedMetadata,
metadataProvider: context?.metadataProvider || selectedMetadata?.metadataProvider || 'omdb',
metadataCandidates: Array.isArray(context?.metadataCandidates) ? context.metadataCandidates : [],
seriesAnalysis: context?.seriesAnalysis || null,
seriesLookupHint: context?.seriesLookupHint || null,
omdbCandidates: Array.isArray(context?.omdbCandidates) ? context.omdbCandidates : []
};
};
@@ -1342,8 +1377,15 @@ export default function RipperPage({
title: currentContext?.detectedTitle || '',
year: null,
imdbId: null,
poster: null
poster: null,
metadataProvider: currentContext?.metadataProvider || 'omdb',
seasonNumber: currentContext?.seriesLookupHint?.seasonNumber || null,
discNumber: currentContext?.seriesLookupHint?.discNumber || null
},
metadataProvider: currentContext?.metadataProvider || currentContext?.selectedMetadata?.metadataProvider || 'omdb',
metadataCandidates: Array.isArray(currentContext?.metadataCandidates) ? currentContext.metadataCandidates : [],
seriesAnalysis: currentContext?.seriesAnalysis || null,
seriesLookupHint: currentContext?.seriesLookupHint || null,
omdbCandidates: Array.isArray(currentContext?.omdbCandidates) ? currentContext.omdbCandidates : []
};
}
@@ -1409,8 +1451,16 @@ export default function RipperPage({
title: response?.result?.detectedTitle || '',
year: null,
imdbId: null,
poster: null
poster: null,
metadataProvider: response?.result?.metadataProvider || 'omdb',
seasonNumber: response?.result?.seriesLookupHint?.seasonNumber || null
},
metadataProvider: response?.result?.metadataProvider || 'omdb',
metadataCandidates: Array.isArray(response?.result?.metadataCandidates)
? response.result.metadataCandidates
: [],
seriesAnalysis: response?.result?.seriesAnalysis || null,
seriesLookupHint: response?.result?.seriesLookupHint || null,
omdbCandidates: Array.isArray(response?.result?.omdbCandidates)
? response.result.omdbCandidates
: []
@@ -1443,8 +1493,16 @@ export default function RipperPage({
title: response?.result?.detectedTitle || '',
year: null,
imdbId: null,
poster: null
poster: null,
metadataProvider: response?.result?.metadataProvider || 'omdb',
seasonNumber: response?.result?.seriesLookupHint?.seasonNumber || null
},
metadataProvider: response?.result?.metadataProvider || 'omdb',
metadataCandidates: Array.isArray(response?.result?.metadataCandidates)
? response.result.metadataCandidates
: [],
seriesAnalysis: response?.result?.seriesAnalysis || null,
seriesLookupHint: response?.result?.seriesLookupHint || null,
omdbCandidates: Array.isArray(response?.result?.omdbCandidates)
? response.result.omdbCandidates
: []
@@ -1672,7 +1730,9 @@ export default function RipperPage({
if (startOptions.ensureConfirmed) {
const confirmPayload = {
selectedEncodeTitleId: startOptions.selectedEncodeTitleId ?? null,
selectedEncodeTitleIds: startOptions.selectedEncodeTitleIds ?? null,
selectedTrackSelection: startOptions.selectedTrackSelection ?? null,
episodeAssignments: startOptions.episodeAssignments ?? null,
skipPipelineStateUpdate: true
};
if (startOptions.selectedPostEncodeScriptIds !== undefined) {
@@ -1773,16 +1833,26 @@ export default function RipperPage({
selectedTrackSelection = null,
selectedPostEncodeScriptIds = undefined,
selectedUserPresetId = undefined,
selectedHandBrakePreset = undefined
selectedHandBrakePreset = undefined,
selectedEncodeTitleIds = undefined,
episodeAssignments = undefined
) => {
const normalizedJobId = normalizeJobId(jobId);
if (normalizedJobId) setJobBusy(normalizedJobId, true);
try {
const payload = {
selectedEncodeTitleId,
selectedTrackSelection,
selectedPostEncodeScriptIds
selectedTrackSelection
};
if (selectedEncodeTitleIds !== undefined) {
payload.selectedEncodeTitleIds = selectedEncodeTitleIds;
}
if (selectedPostEncodeScriptIds !== undefined) {
payload.selectedPostEncodeScriptIds = selectedPostEncodeScriptIds;
}
if (episodeAssignments !== undefined) {
payload.episodeAssignments = episodeAssignments;
}
if (selectedUserPresetId !== undefined) {
payload.selectedUserPresetId = selectedUserPresetId;
}
@@ -1818,13 +1888,19 @@ export default function RipperPage({
}
};
const handleSelectHandBrakeTitle = async (jobId, selectedHandBrakeTitleId = null) => {
const handleSelectHandBrakeTitle = async (jobId, selectedHandBrakeTitleIds = null) => {
const normalizedJobId = normalizeJobId(jobId);
if (normalizedJobId) setJobBusy(normalizedJobId, true);
try {
const normalizedIds = (Array.isArray(selectedHandBrakeTitleIds) ? selectedHandBrakeTitleIds : [selectedHandBrakeTitleIds])
.map((value) => Number(value))
.filter((value) => Number.isFinite(value) && value > 0)
.map((value) => Math.trunc(value));
const dedupedIds = Array.from(new Set(normalizedIds));
await api.selectMetadata({
jobId,
selectedHandBrakeTitleId: selectedHandBrakeTitleId || null
selectedHandBrakeTitleId: dedupedIds[0] || null,
selectedHandBrakeTitleIds: dedupedIds
});
await refreshPipeline();
await loadRipperJobs();
@@ -1914,9 +1990,12 @@ export default function RipperPage({
}
};
const handleRestartEncodeWithLastSettings = async (jobId) => {
const handleRestartEncodeWithLastSettings = async (jobId, restartOptions = {}) => {
const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizeJobId(jobId)) || null;
const title = job?.title || job?.detected_title || `Job #${jobId}`;
const restartMode = String(restartOptions?.restartMode || 'all').trim().toLowerCase() === 'from_abort'
? 'from_abort'
: 'all';
if (job?.encodeSuccess) {
const confirmed = await confirmModal({
header: 'Encode neu starten',
@@ -1934,7 +2013,7 @@ export default function RipperPage({
const normalizedJobId = normalizeJobId(jobId);
if (normalizedJobId) setJobBusy(normalizedJobId, true);
try {
const response = await api.restartEncodeWithLastSettings(jobId);
const response = await api.restartEncodeWithLastSettings(jobId, { restartMode });
const result = getQueueActionResult(response);
const replacementJobId = normalizeJobId(result?.jobId) || normalizedJobId;
await refreshPipeline();
@@ -2115,14 +2194,12 @@ export default function RipperPage({
}
};
const handleOmdbSearch = async (query) => {
try {
const response = await api.searchOmdb(query);
return response.results || [];
} catch (error) {
showError(error);
return [];
}
const handleMetadataSearch = async (query, options = {}) => {
const provider = String(options?.metadataProvider || effectiveMetadataDialogContext?.metadataProvider || 'omdb').trim().toLowerCase() || 'omdb';
const response = provider === 'tmdb'
? await api.searchTmdbSeries(query)
: await api.searchOmdb(query);
return response.results || [];
};
const doSelectMetadata = async (payload) => {
@@ -2772,6 +2849,42 @@ export default function RipperPage({
normalizedStatus === 'READY_TO_ENCODE'
|| (mediaIndicator.mediaType === 'audiobook' && normalizedStatus === 'READY_TO_START')
) && !isCurrentSession;
const analyzeContext = getAnalyzeContext(job);
const selectedMetadata = pipelineForJob?.context?.selectedMetadata && typeof pipelineForJob.context.selectedMetadata === 'object'
? pipelineForJob.context.selectedMetadata
: (analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
? analyzeContext.selectedMetadata
: (job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
? job.makemkvInfo.selectedMetadata
: {}));
const seriesSeasonNumber = mediaIndicator.isSeriesDvd
? (selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null)
: null;
const seriesTmdbId = mediaIndicator.isSeriesDvd
? (selectedMetadata?.tmdbId ?? analyzeContext?.tmdbId ?? null)
: null;
const seriesDiscNumber = mediaIndicator.isSeriesDvd
? (selectedMetadata?.discNumber ?? analyzeContext?.seriesLookupHint?.discNumber ?? null)
: null;
const isSeriesContainer = mediaIndicator.isSeriesDvd
&& String(job?.job_kind || '').trim().toLowerCase() === 'dvd_series_container';
const parentContainerId = normalizeJobId(job?.parent_job_id);
const seriesTmdbText = String(seriesTmdbId ?? '').trim();
const seriesSeasonLabel = mediaIndicator.isSeriesDvd && seriesSeasonNumber
? ` Staffel ${seriesSeasonNumber}`
: '';
const seriesDiscLabel = mediaIndicator.isSeriesDvd && seriesDiscNumber
? ` | Disk ${seriesDiscNumber}`
: '';
const seriesContainerLabel = isSeriesContainer
? ' | Container'
: (parentContainerId ? ` | Container #${parentContainerId}` : '');
const seriesTmdbLabel = mediaIndicator.isSeriesDvd && seriesTmdbText && seriesTmdbText !== '0'
? ` | TMDb ${seriesTmdbId}`
: '';
const seriesTitleSuffix = mediaIndicator.isSeriesDvd
? `${seriesSeasonLabel}${seriesDiscLabel}${seriesContainerLabel}`
: '';
const mediaProfile = String(pipelineForJob?.context?.mediaProfile || '').trim().toLowerCase();
const jobState = String(pipelineForJob?.state || normalizedStatus).trim().toUpperCase();
const pipelineStage = String(pipelineForJob?.context?.stage || '').trim().toUpperCase();
@@ -2784,12 +2897,45 @@ export default function RipperPage({
const isAudiobookJob = mediaProfile === 'audiobook'
|| mediaIndicator.mediaType === 'audiobook'
|| String(pipelineForJob?.context?.mode || '').trim().toLowerCase() === 'audiobook';
const rawProgress = Number(pipelineForJob?.progress ?? 0);
const seriesBatchContext = pipelineForJob?.context?.seriesBatch && typeof pipelineForJob.context.seriesBatch === 'object'
? pipelineForJob.context.seriesBatch
: null;
const seriesBatchChildren = Array.isArray(seriesBatchContext?.children) ? seriesBatchContext.children : [];
const hasSeriesBatchProgress = seriesBatchChildren.length > 0 || Number(seriesBatchContext?.totalCount || 0) > 0;
const seriesBatchTotalCountRaw = Number(seriesBatchContext?.totalCount || 0);
const seriesBatchTotalCount = Number.isFinite(seriesBatchTotalCountRaw) && seriesBatchTotalCountRaw > 0
? Math.trunc(seriesBatchTotalCountRaw)
: seriesBatchChildren.length;
const seriesBatchAggregateProgress = hasSeriesBatchProgress && seriesBatchTotalCount > 0
? (
seriesBatchChildren.reduce((sum, child) => {
const status = String(child?.status || '').trim().toUpperCase();
if (status === 'FINISHED') {
return sum + 100;
}
const childProgress = Number(child?.progress || 0);
return sum + (Number.isFinite(childProgress) ? Math.max(0, Math.min(100, childProgress)) : 0);
}, 0) / seriesBatchTotalCount
)
: 0;
const runningSeriesChild = hasSeriesBatchProgress
? (
seriesBatchChildren.find((child) => String(child?.status || '').trim().toUpperCase() === 'RUNNING')
|| null
)
: null;
const rawProgress = hasSeriesBatchProgress
? seriesBatchAggregateProgress
: Number(pipelineForJob?.progress ?? 0);
const clampedProgress = Number.isFinite(rawProgress)
? Math.max(0, Math.min(100, rawProgress))
: 0;
const progressLabel = `${Math.round(clampedProgress)}%`;
const etaLabel = String(pipelineForJob?.eta || '').trim();
const etaLabel = String(
hasSeriesBatchProgress
? (runningSeriesChild?.eta || pipelineForJob?.eta || '')
: (pipelineForJob?.eta || '')
).trim();
const audiobookMeta = pipelineForJob?.context?.selectedMetadata && typeof pipelineForJob.context.selectedMetadata === 'object'
? pipelineForJob.context.selectedMetadata
@@ -2814,8 +2960,12 @@ export default function RipperPage({
/>
<span>#{jobId} | {jobTitle}</span>
</strong>
{mediaIndicator.isSeriesDvd ? (
<small className="ripper-job-subtitle">{seriesTitleSuffix || '-'}</small>
) : null}
<div className="ripper-job-badges">
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
{mediaIndicator.isSeriesDvd ? <Tag value="DVD Serie" severity="secondary" /> : null}
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
{normalizedStatus === 'READY_TO_ENCODE'
@@ -2934,21 +3084,30 @@ export default function RipperPage({
<span>{jobTitle}</span>
</strong>
<small>
#{jobId}
{isAudiobookJob
? (
`${audiobookMeta?.author ? ` | ${audiobookMeta.author}` : ''}`
+ `${audiobookMeta?.narrator ? ` | ${audiobookMeta.narrator}` : ''}`
+ `${audiobookChapterCount > 0 ? ` | ${audiobookChapterCount} Kapitel` : ''}`
)
{mediaIndicator.isSeriesDvd
? (seriesTitleSuffix || '-')
: (
`${job?.year ? ` | ${job.year}` : ''}`
+ `${job?.imdb_id ? ` | ${job.imdb_id}` : ''}`
`#${jobId}`
+ (isAudiobookJob
? (
`${audiobookMeta?.author ? ` | ${audiobookMeta.author}` : ''}`
+ `${audiobookMeta?.narrator ? ` | ${audiobookMeta.narrator}` : ''}`
+ `${audiobookChapterCount > 0 ? ` | ${audiobookChapterCount} Kapitel` : ''}`
)
: (
`${job?.year ? ` | ${job.year}` : ''}`
+ (mediaIndicator.isSeriesDvd ? seriesSeasonLabel : '')
+ (mediaIndicator.isSeriesDvd ? seriesDiscLabel : '')
+ (mediaIndicator.isSeriesDvd ? seriesContainerLabel : '')
+ (mediaIndicator.isSeriesDvd ? seriesTmdbLabel : '')
+ (mediaIndicator.isSeriesDvd ? '' : `${job?.imdb_id ? ` | ${job.imdb_id}` : ''}`)
))
)}
</small>
</div>
<div className="ripper-job-badges">
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
{mediaIndicator.isSeriesDvd ? <Tag value="DVD Serie" severity="secondary" /> : null}
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
{isResumable ? <Tag value="Fortsetzbar" severity="success" /> : null}
{normalizedStatus === 'READY_TO_ENCODE'
@@ -2995,7 +3154,35 @@ export default function RipperPage({
const detailKey = buildRunningQueueScriptKey(item?.jobId);
const detailsExpanded = hasScriptSummary && expandedQueueScriptKeys.has(detailKey);
const jobProgressEntry = pipeline?.jobProgress?.[String(item.jobId)] || null;
const rawQueueProgress = Number(jobProgressEntry?.progress ?? 0);
const seriesBatchContext = jobProgressEntry?.context?.seriesBatch && typeof jobProgressEntry.context.seriesBatch === 'object'
? jobProgressEntry.context.seriesBatch
: null;
const seriesBatchChildren = Array.isArray(seriesBatchContext?.children) ? seriesBatchContext.children : [];
const seriesBatchTotalCountRaw = Number(seriesBatchContext?.totalCount || 0);
const seriesBatchTotalCount = Number.isFinite(seriesBatchTotalCountRaw) && seriesBatchTotalCountRaw > 0
? Math.trunc(seriesBatchTotalCountRaw)
: seriesBatchChildren.length;
const seriesBatchOverallProgress = seriesBatchTotalCount > 0
? (
seriesBatchChildren.reduce((sum, child) => {
const status = String(child?.status || '').trim().toUpperCase();
if (status === 'FINISHED') {
return sum + 100;
}
const childProgress = Number(child?.progress || 0);
return sum + (Number.isFinite(childProgress) ? Math.max(0, Math.min(100, childProgress)) : 0);
}, 0) / seriesBatchTotalCount
)
: null;
const runningSeriesChild = seriesBatchChildren.find(
(child) => String(child?.status || '').trim().toUpperCase() === 'RUNNING'
) || null;
const runningChildProgress = Number(runningSeriesChild?.progress ?? NaN);
const rawQueueProgress = Number(
Number.isFinite(runningChildProgress)
? runningChildProgress
: (jobProgressEntry?.progress ?? 0)
);
const clampedQueueProgress = Number.isFinite(rawQueueProgress) ? Math.max(0, Math.min(100, rawQueueProgress)) : 0;
return (
<div key={`running-${item.jobId}`} className="pipeline-queue-entry-wrap">
@@ -3006,6 +3193,7 @@ export default function RipperPage({
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
</strong>
{item.subtitle ? <small>{item.subtitle}</small> : null}
<small>{getStatusLabel(item.status)}</small>
{clampedQueueProgress > 0 && (
<ProgressBar value={clampedQueueProgress} style={{ height: '5px' }} showValue={false} />
@@ -3122,6 +3310,7 @@ export default function RipperPage({
{item.hasScripts ? <i className="pi pi-code queue-job-tag" title="Skripte hinterlegt" /> : null}
{item.hasChains ? <i className="pi pi-link queue-job-tag" title="Skriptketten hinterlegt" /> : null}
</strong>
{item.subtitle ? <small>{item.subtitle}</small> : null}
<small>{item.actionLabel || item.action || '-'} | {getStatusLabel(item.status)}</small>
</>
)}
@@ -3497,7 +3686,7 @@ export default function RipperPage({
setMetadataDialogReassignMode(false);
}}
onSubmit={handleMetadataSubmit}
onSearch={handleOmdbSearch}
onSearch={handleMetadataSearch}
busy={busy}
/>
@@ -3521,31 +3710,60 @@ export default function RipperPage({
style={{ width: '30rem', maxWidth: '96vw' }}
modal
>
{(() => {
const pendingPayload = duplicateJobDialog.pendingPayload || {};
const metadataProvider = String(pendingPayload?.metadataProvider || '').trim().toLowerCase();
const seasonNumber = Number(pendingPayload?.seasonNumber || 0) || 0;
const tmdbId = Number(pendingPayload?.tmdbId || 0) || 0;
const isSeriesContainerAssign = metadataProvider === 'tmdb' && seasonNumber > 0 && tmdbId > 0;
return (
<>
<p>
<strong>{duplicateJobDialog.existingJob?.title || duplicateJobDialog.pendingPayload?.title}</strong> ist bereits als Job #{duplicateJobDialog.existingJob?.id} in der Historie vorhanden.
</p>
<p>Neuen Job anlegen oder mit dem vorhandenen Eintrag weiterarbeiten?</p>
<p>
{isSeriesContainerAssign
? 'Dieser Job kann dem vorhandenen Serien-Container automatisch zugewiesen werden.'
: 'Neuen Job anlegen oder mit dem vorhandenen Eintrag weiterarbeiten?'}
</p>
<div className="dialog-actions">
<Button
label="Vorhandenen Job öffnen"
icon="pi pi-history"
onClick={() => {
label={isSeriesContainerAssign ? 'Vorhandenem Container zuweisen' : 'Vorhandenen Job öffnen'}
icon={isSeriesContainerAssign ? 'pi pi-sitemap' : 'pi pi-history'}
onClick={async () => {
const payload = duplicateJobDialog.pendingPayload;
const jobId = duplicateJobDialog.existingJob?.id;
setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null });
if (isSeriesContainerAssign) {
await doSelectMetadata(payload);
return;
}
navigate(`/history?open=${jobId}`);
}}
/>
<Button
label="Neuen Job anlegen"
severity="secondary"
outlined
onClick={async () => {
const payload = duplicateJobDialog.pendingPayload;
setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null });
await doSelectMetadata(payload);
}}
/>
{isSeriesContainerAssign ? (
<Button
label="Abbrechen"
severity="secondary"
outlined
onClick={() => setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null })}
/>
) : (
<Button
label="Neuen Job anlegen"
severity="secondary"
outlined
onClick={async () => {
const payload = duplicateJobDialog.pendingPayload;
setDuplicateJobDialog({ visible: false, existingJob: null, pendingPayload: null });
await doSelectMetadata(payload);
}}
/>
)}
</div>
</>
);
})()}
</Dialog>
<Dialog
+2
View File
@@ -1134,6 +1134,7 @@ export default function SettingsPage() {
dirtyKeys={dirtyKeys}
onChange={handleFieldChange}
effectivePaths={effectivePaths}
onNotify={(msg) => toastRef.current?.show(msg)}
/>
)}
</TabPanel>
@@ -1849,6 +1850,7 @@ export default function SettingsPage() {
</table>
</Card>
)}
</div>
);
}
+142
View File
@@ -1640,6 +1640,29 @@ body {
margin-bottom: 0.75rem;
}
.metadata-filter-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin-bottom: 0.75rem;
align-items: start;
}
.metadata-filter-grid-single {
grid-template-columns: 1fr;
}
.metadata-filter-disc {
display: grid;
gap: 0.2rem;
}
.metadata-filter-subtitle {
color: var(--text-color-secondary);
font-size: 0.78rem;
line-height: 1.25;
}
.metadata-grid {
margin-top: 0.75rem;
display: grid;
@@ -2864,6 +2887,12 @@ body {
background: #f8e1df;
}
.history-dv-chip.tone-warn {
color: #92400e;
border-color: #f2c286;
background: #fff1db;
}
.history-dv-rating-chip {
display: inline-flex;
align-items: center;
@@ -3773,6 +3802,115 @@ body {
gap: 0.6rem;
}
.episode-fill-box {
border: 1px dashed var(--rip-border);
border-radius: 0.45rem;
padding: 0.55rem 0.65rem;
background: var(--rip-panel-soft);
}
.episode-fill-field {
display: grid;
gap: 0.3rem;
}
.episode-fill-field label {
font-weight: 600;
}
.episode-fill-field small {
color: var(--text-color-secondary);
font-size: 0.78rem;
}
.episode-title-input-row {
display: grid;
gap: 0.25rem;
}
.episode-title-input-row label {
font-weight: 600;
font-size: 0.82rem;
}
.episode-global-selection-box {
border: 1px dashed var(--rip-border);
border-radius: 0.45rem;
padding: 0.55rem 0.65rem;
background: var(--rip-panel-soft);
display: grid;
gap: 0.45rem;
}
.episode-global-selection-box h4 {
margin: 0;
}
.episode-track-accordion {
border: 1px dashed var(--rip-border);
border-radius: 0.4rem;
padding: 0.35rem 0.5rem;
background: var(--rip-panel);
}
.episode-track-accordion > summary {
cursor: pointer;
user-select: none;
font-weight: 600;
color: var(--rip-muted);
margin: 0.1rem 0;
}
.episode-track-accordion[open] > summary {
margin-bottom: 0.45rem;
}
.series-batch-progress-wrap {
border: 1px dashed var(--rip-border);
border-radius: 0.45rem;
padding: 0.55rem 0.65rem;
background: var(--rip-panel-soft);
display: grid;
gap: 0.45rem;
margin-bottom: 0.65rem;
}
.series-batch-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.series-batch-episodes {
display: grid;
gap: 0.45rem;
}
.series-batch-episode-row {
border: 1px solid var(--rip-border);
border-radius: 0.38rem;
padding: 0.4rem 0.5rem;
background: var(--rip-panel);
display: grid;
gap: 0.22rem;
}
.series-batch-episode-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.45rem;
}
.series-batch-episode-title {
font-size: 0.86rem;
font-weight: 600;
min-width: 0;
overflow-wrap: anywhere;
}
.playlist-info-box {
border: 1px dashed var(--rip-border);
border-radius: 0.4rem;
@@ -4076,6 +4214,10 @@ body {
grid-template-columns: 1fr;
}
.metadata-filter-grid {
grid-template-columns: 1fr;
}
.actions-row .p-button {
width: 100%;
}
+50 -1
View File
@@ -204,6 +204,56 @@ export function resolveMediaType(job) {
return 'other';
}
export function isSeriesDvdJob(job) {
if (resolveMediaType(job) !== 'dvd') {
return false;
}
const analyzeContext = job?.makemkvInfo?.analyzeContext && typeof job.makemkvInfo.analyzeContext === 'object'
? job.makemkvInfo.analyzeContext
: {};
const selectedMetadata = analyzeContext?.selectedMetadata && typeof analyzeContext.selectedMetadata === 'object'
? analyzeContext.selectedMetadata
: (job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
? job.makemkvInfo.selectedMetadata
: {});
const metadataProvider = String(
analyzeContext?.metadataProvider
|| selectedMetadata?.metadataProvider
|| ''
).trim().toLowerCase();
const metadataKind = String(
selectedMetadata?.metadataKind
|| analyzeContext?.metadataKind
|| ''
).trim().toLowerCase();
const seasonNumberRaw = selectedMetadata?.seasonNumber ?? analyzeContext?.seriesLookupHint?.seasonNumber ?? null;
const seasonNumberText = String(seasonNumberRaw ?? '').trim();
const parsedSeasonNumber = Number(seasonNumberText.replace(',', '.'));
const hasSeasonNumber = seasonNumberText
? (Number.isFinite(parsedSeasonNumber) ? parsedSeasonNumber > 0 : true)
: false;
const seasonName = String(selectedMetadata?.seasonName || '').trim();
const episodeCount = Number(selectedMetadata?.episodeCount ?? 0);
const hasEpisodeList = Array.isArray(selectedMetadata?.episodes) && selectedMetadata.episodes.length > 0;
const isSeriesKind = ['series', 'season', 'tv', 'tv_series', 'tv-season', 'tv_season'].includes(metadataKind);
if (isSeriesKind || hasSeasonNumber || Boolean(seasonName) || hasEpisodeList || (Number.isFinite(episodeCount) && episodeCount > 0)) {
return true;
}
const tmdbIdRaw = selectedMetadata?.tmdbId
?? analyzeContext?.tmdbId
?? selectedMetadata?.providerId
?? analyzeContext?.providerId
?? null;
const tmdbIdText = String(tmdbIdRaw ?? '').trim();
const hasTmdbId = Boolean(tmdbIdText) && tmdbIdText !== '0';
return (metadataProvider === 'tmdb' || metadataProvider === 'themoviedb') && hasTmdbId;
}
export function isConverterJob(job) {
return resolveMediaType(job) === 'converter';
}
@@ -233,4 +283,3 @@ export function classifyJob(job) {
family
};
}