0.13.0 DVD Series
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user