0.13.1-10 Misc fixes

This commit is contained in:
2026-04-17 08:04:52 +00:00
parent f25b6e1181
commit bebe9eac8c
19 changed files with 979 additions and 2155 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
"version": "0.13.1-9",
"version": "0.13.1-10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "0.13.1-9",
"version": "0.13.1-10",
"dependencies": {
"primeicons": "^7.0.0",
"primereact": "^10.9.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "0.13.1-9",
"version": "0.13.1-10",
"private": true,
"type": "module",
"scripts": {
+2
View File
@@ -823,6 +823,7 @@ export const api = {
async deleteJobEntry(jobId, target = 'none', options = {}) {
const includeRelated = Boolean(options?.includeRelated);
const resetDriveState = Boolean(options?.resetDriveState);
const preserveRawForImportJobs = Boolean(options?.preserveRawForImportJobs);
const selectedMoviePaths = Array.isArray(options?.selectedMoviePaths)
? options.selectedMoviePaths
.map((item) => String(item || '').trim())
@@ -838,6 +839,7 @@ export const api = {
target,
includeRelated,
resetDriveState,
preserveRawForImportJobs,
...(hasKeepDetectedDevice ? { keepDetectedDevice } : {}),
...(selectedMoviePaths ? { selectedMoviePaths } : {})
})
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Dialog } from 'primereact/dialog';
import { Button } from 'primereact/button';
import { DataTable } from 'primereact/datatable';
@@ -45,11 +45,13 @@ export default function MetadataSelectionDialog({
const [manualImdb, setManualImdb] = useState('');
const [manualDiscNumber, setManualDiscNumber] = useState('');
const [extraResults, setExtraResults] = useState([]);
const [hasSearchOverride, setHasSearchOverride] = useState(false);
const [searchBusy, setSearchBusy] = useState(false);
const [searchError, setSearchError] = useState('');
const [submitError, setSubmitError] = useState('');
const [seasonFilter, setSeasonFilter] = useState('');
const [discValidationError, setDiscValidationError] = useState('');
const activeSearchRequestRef = useRef(0);
const contextWorkflowKind = normalizeWorkflowKind(
context?.selectedMetadata?.workflowKind
|| context?.workflowKind
@@ -102,12 +104,14 @@ export default function MetadataSelectionDialog({
setManualImdb(defaultImdb);
setManualDiscNumber('');
setExtraResults([]);
setHasSearchOverride(false);
setSearchBusy(false);
setSearchError('');
setSubmitError('');
setSeasonFilter('');
setDiscValidationError('');
setSelectedMetadataProvider(contextMetadataProvider);
activeSearchRequestRef.current += 1;
}, [visible, context]);
useEffect(() => {
@@ -129,7 +133,8 @@ export default function MetadataSelectionDialog({
const base = Array.isArray(context?.metadataCandidates)
? context.metadataCandidates
: (context?.omdbCandidates || []);
const all = [...base, ...extraResults];
const sourceRows = hasSearchOverride ? extraResults : base;
const all = Array.isArray(sourceRows) ? sourceRows : [];
const map = new Map();
all.forEach((item) => {
@@ -149,7 +154,7 @@ export default function MetadataSelectionDialog({
});
return Array.from(map.values());
}, [context, extraResults]);
}, [context, extraResults, hasSearchOverride]);
const filteredRows = useMemo(() => {
if (metadataProvider !== 'tmdb') {
@@ -192,18 +197,28 @@ export default function MetadataSelectionDialog({
if (!trimmedQuery || typeof onSearch !== 'function') {
return;
}
const requestId = activeSearchRequestRef.current + 1;
activeSearchRequestRef.current = requestId;
setHasSearchOverride(true);
setSearchBusy(true);
setSearchError('');
try {
const results = await onSearch(trimmedQuery, {
metadataProvider
});
if (activeSearchRequestRef.current !== requestId) {
return;
}
setExtraResults(Array.isArray(results) ? results : []);
} catch (error) {
setExtraResults([]);
if (activeSearchRequestRef.current !== requestId) {
return;
}
setSearchError(error?.message || 'Suche fehlgeschlagen.');
} finally {
setSearchBusy(false);
if (activeSearchRequestRef.current === requestId) {
setSearchBusy(false);
}
}
};
@@ -262,10 +277,6 @@ export default function MetadataSelectionDialog({
};
const handleHideRequest = () => {
if (requiresDiscNumber && !hasValidDiscNumber) {
setDiscValidationError('Disk-Nummer ist Pflicht und muss eine ganze Zahl >= 1 sein.');
return;
}
onHide?.();
};
@@ -291,6 +302,7 @@ export default function MetadataSelectionDialog({
setSelectedMetadataProvider(nextProvider);
setSelected(null);
setExtraResults([]);
setHasSearchOverride(false);
setSearchError('');
setSubmitError('');
setDiscValidationError('');
@@ -331,7 +343,7 @@ export default function MetadataSelectionDialog({
/>
<small className="metadata-filter-subtitle">
{requiresDiscNumber
? 'Pflichtfeld fuer Serien-DVDs. Ohne Disk-Nummer koennen die Metadaten nicht verlassen werden.'
? 'Pflichtfeld fuer Serien-DVDs. Ohne Disk-Nummer koennen die Metadaten nicht uebernommen werden.'
: 'Optionale Disc-Nummer fuer internes Mapping und Output-Templates. Nur Werte groesser 0 werden gespeichert.'}
</small>
</div>
@@ -394,7 +406,6 @@ export default function MetadataSelectionDialog({
severity="secondary"
text
onClick={handleHideRequest}
disabled={requiresDiscNumber && !hasValidDiscNumber}
/>
<Button
label="Auswahl übernehmen"
+178 -29
View File
@@ -669,15 +669,16 @@ function formatTemplateTwoDigit(value) {
return String(value || '').trim() || String(numeric);
}
function resolveEpisodeAssignmentRange(assignment = null, title = null, fallbackEpisodeNumber = 1) {
function resolveEpisodeAssignmentRange(assignment = null, title = null, fallbackEpisodeNumber = 1, options = {}) {
const assignmentSource = assignment && typeof assignment === 'object' ? assignment : {};
const titleSource = title && typeof title === 'object' ? title : {};
const allowTitleEpisodeNumber = options?.allowTitleEpisodeNumber !== false;
const fallbackNumber = normalizePositiveInt(fallbackEpisodeNumber) || 1;
const episodeStart = normalizePositiveInt(
assignmentSource?.episodeNumberStart
?? assignmentSource?.episodeNoStart
?? assignmentSource?.episodeNumber
?? titleSource?.episodeNumber
?? (allowTitleEpisodeNumber ? titleSource?.episodeNumber : null)
?? fallbackNumber
) || fallbackNumber;
const explicitEpisodeEnd = normalizePositiveInt(
@@ -939,7 +940,135 @@ function resolveEffectiveEpisodeRangeForTitle(assignment = null, title = null, a
};
}
function buildDvdSeriesEpisodeOutputPathPreview(settings, metadata, title, assignment = null, fallbackJobId = null, allTitles = []) {
function resolveSeriesTitleOrderForPreview(allTitles = [], selectedTitleIds = [], targetTitleId = null) {
const explicitSelectedIds = normalizeTitleIdList(selectedTitleIds);
const fallbackSelectedIds = explicitSelectedIds.length > 0
? explicitSelectedIds
: normalizeTitleIdList(
(Array.isArray(allTitles) ? allTitles : [])
.filter((item) => Boolean(item?.selectedForEncode || item?.encodeInput))
.map((item) => item?.id)
);
let ordered = fallbackSelectedIds;
const normalizedTarget = normalizeTitleId(targetTitleId);
if (normalizedTarget && !ordered.some((id) => Number(id) === Number(normalizedTarget))) {
ordered = [...ordered, Number(normalizedTarget)];
}
if (ordered.length === 0 && normalizedTarget) {
ordered = [Number(normalizedTarget)];
}
return ordered;
}
function resolveEpisodeRangeForTitleFromAssignments({
assignmentByTitle = {},
allTitles = [],
selectedTitleIds = [],
targetTitle = null,
fallbackEpisodeNumber = 1
}) {
const titles = Array.isArray(allTitles) ? allTitles : [];
const titlesById = new Map();
for (const item of titles) {
const id = normalizeTitleId(item?.id);
if (!id) {
continue;
}
titlesById.set(Number(id), item);
}
const targetTitleId = normalizeTitleId(targetTitle?.id || null);
const orderedTitleIds = resolveSeriesTitleOrderForPreview(titles, selectedTitleIds, targetTitleId);
const fallbackStart = normalizePositiveInt(fallbackEpisodeNumber) || 1;
let cursor = fallbackStart;
let targetRange = null;
for (const rawId of orderedTitleIds) {
const currentId = normalizeTitleId(rawId);
if (!currentId) {
continue;
}
const assignment = assignmentByTitle?.[currentId] || assignmentByTitle?.[String(currentId)] || null;
const currentTitle = titlesById.get(Number(currentId)) || null;
const hasExplicitRange = hasExplicitEpisodeAssignmentRange(assignment);
const range = resolveEpisodeAssignmentRange(
assignment,
currentTitle,
cursor,
{ allowTitleEpisodeNumber: hasExplicitRange }
);
const normalizedEnd = normalizePositiveInt(range?.end);
if (normalizedEnd !== null) {
cursor = normalizedEnd + 1;
}
if (targetTitleId && Number(currentId) === Number(targetTitleId)) {
targetRange = range;
break;
}
}
if (!targetRange) {
const assignment = targetTitleId
? (assignmentByTitle?.[targetTitleId] || assignmentByTitle?.[String(targetTitleId)] || null)
: null;
const hasExplicitRange = hasExplicitEpisodeAssignmentRange(assignment);
targetRange = resolveEpisodeAssignmentRange(
assignment,
targetTitle,
cursor,
{ allowTitleEpisodeNumber: hasExplicitRange }
);
}
return targetRange;
}
function templateUsesLeadingZeroForToken(template, token) {
const source = String(template || '').trim();
const normalizedToken = String(token || '').trim();
if (!source || !normalizedToken) {
return false;
}
const pattern = new RegExp(`\\{\\s*0\\s*:\\s*${normalizedToken}\\s*\\}`, 'i');
return pattern.test(source);
}
function formatEpisodeNumberByTemplate(rawValue, template, token = 'episodeNr') {
const numeric = Number(rawValue);
if (!Number.isFinite(numeric) || numeric <= 0) {
return String(rawValue || '').trim() || '0';
}
if (!Number.isInteger(numeric)) {
return String(numeric);
}
const normalized = String(Math.trunc(numeric));
if (templateUsesLeadingZeroForToken(template, token)) {
return normalized.padStart(2, '0');
}
return normalized;
}
function buildFallbackEpisodeTitleForOutput(episodeRange = null, template = '') {
const start = normalizePositiveInt(episodeRange?.start) || 1;
const end = normalizePositiveInt(episodeRange?.end) || start;
if (end > start) {
const startToken = formatEpisodeNumberByTemplate(start, template, 'episodeNr');
const endToken = formatEpisodeNumberByTemplate(end, template, 'episodeNr');
return `Folge ${startToken}-${endToken}`;
}
const token = formatEpisodeNumberByTemplate(start, template, 'episodeNr');
return `Folge ${token}`;
}
function buildDvdSeriesEpisodeOutputPathPreview(
settings,
metadata,
title,
assignment = null,
fallbackJobId = null,
allTitles = [],
assignmentByTitle = {},
selectedTitleIds = []
) {
const seriesRootDir = String(
settings?.series_dir_dvd
|| settings?.movie_dir_dvd
@@ -953,15 +1082,28 @@ function buildDvdSeriesEpisodeOutputPathPreview(settings, metadata, title, assig
const seasonNumber = normalizePositiveInt(
assignment?.seasonNumber ?? metadata?.seasonNumber ?? title?.seasonNumber ?? 1
) || 1;
const normalizedSelectedIds = normalizeTitleIdList(selectedTitleIds);
const targetTitleId = normalizeTitleId(title?.id);
const fallbackEpisodeNumber = targetTitleId && normalizedSelectedIds.length > 0
? (normalizedSelectedIds.findIndex((id) => Number(id) === Number(targetTitleId)) + 1)
: 1;
const inferredRange = resolveEpisodeRangeForTitleFromAssignments({
assignmentByTitle,
allTitles,
selectedTitleIds: normalizedSelectedIds,
targetTitle: title,
fallbackEpisodeNumber: fallbackEpisodeNumber > 0 ? fallbackEpisodeNumber : 1
});
const episodeRange = resolveEffectiveEpisodeRangeForTitle(
assignment,
assignment || {},
title,
allTitles,
title?.id || 1
inferredRange?.start || (fallbackEpisodeNumber > 0 ? fallbackEpisodeNumber : 1)
);
const episodeNumber = episodeRange.start;
const episodeRangeToken = buildEpisodeRangeToken(episodeRange.start, episodeRange.end);
const partsToken = buildEpisodePartsToken(episodeRange);
const effectiveEpisodeRange = episodeRange;
const episodeNumber = effectiveEpisodeRange.start;
const episodeRangeToken = buildEpisodeRangeToken(effectiveEpisodeRange.start, effectiveEpisodeRange.end);
const partsToken = buildEpisodePartsToken(effectiveEpisodeRange);
const discNumber = normalizePositiveInt(metadata?.discNumber ?? assignment?.discNumber ?? null);
const seriesTitleRaw = String(
metadata?.title
@@ -969,16 +1111,6 @@ function buildDvdSeriesEpisodeOutputPathPreview(settings, metadata, title, assig
|| (fallbackJobId ? `job-${fallbackJobId}` : 'series')
).trim();
const seriesTitle = seriesTitleRaw || 'series';
const episodeTitleRaw = String(
assignment?.episodeTitle
|| title?.episodeTitle
|| title?.fileName
|| `Episode ${episodeNumber}`
).trim();
const episodeTitle = normalizeEpisodeTitleForOutput(
episodeTitleRaw || `Episode ${episodeRangeToken}`,
episodeRange
);
const year = String(metadata?.year || new Date().getFullYear()).trim();
const language = normalizeSeriesLanguage(settings?.dvd_series_language || metadata?.language || null);
@@ -992,21 +1124,28 @@ function buildDvdSeriesEpisodeOutputPathPreview(settings, metadata, title, assig
).trim();
const singleTemplate = singleTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{seasonNr}E{episodeNr} - {episodeTitle}';
const multiTemplate = multiTemplateRaw || '{seriesTitle}/Staffel {seasonNr}/S{0:seasonNr}E{episodeRange} - {episodeTitle} (Teil {parts})';
const template = episodeRange.isMulti ? multiTemplate : singleTemplate;
const template = effectiveEpisodeRange.isMulti ? multiTemplate : singleTemplate;
const fallbackEpisodeTitle = buildFallbackEpisodeTitleForOutput(effectiveEpisodeRange, template);
const episodeTitleRaw = String(
assignment?.episodeTitle
|| title?.episodeTitle
|| fallbackEpisodeTitle
).trim();
const rendered = renderTemplate(template, {
seriesTitle,
seasonNr: seasonNumber,
seasonNo: String(seasonNumber),
episodeNr: episodeNumber,
episodeNo: String(episodeNumber),
episodeNoStart: episodeRange.start,
episodeNoEnd: episodeRange.end,
episodeNumberStart: episodeRange.start,
episodeNumberEnd: episodeRange.end,
episodeNoStart: effectiveEpisodeRange.start,
episodeNoEnd: effectiveEpisodeRange.end,
episodeNumberStart: effectiveEpisodeRange.start,
episodeNumberEnd: effectiveEpisodeRange.end,
episodeRange: episodeRangeToken,
episodeSpan: episodeRange.span,
episodeSpan: effectiveEpisodeRange.span,
parts: partsToken,
episodeTitle,
episodeTitle: normalizeEpisodeTitleForOutput(
episodeTitleRaw || fallbackEpisodeTitle,
effectiveEpisodeRange
) || fallbackEpisodeTitle,
discNr: discNumber || '',
year,
language
@@ -2380,14 +2519,24 @@ export default function PipelineStatusCard({
title,
assignment,
retryJobId,
Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : []
Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [],
episodeAssignmentsByTitle,
selectedEncodeTitleIds
);
if (path) {
map[titleId] = path;
}
}
return map;
}, [isSeriesDvdReview, mediaInfoReview?.titles, episodeAssignmentsByTitle, settingsMap, selectedMetadata, retryJobId]);
}, [
isSeriesDvdReview,
mediaInfoReview?.titles,
episodeAssignmentsByTitle,
settingsMap,
selectedMetadata,
retryJobId,
selectedEncodeTitleIds
]);
const multiEpisodeReviewWarnings = useMemo(() => {
if (!isSeriesDvdReview) {
return [];
+18 -5
View File
@@ -1948,9 +1948,17 @@ export default function RipperPage({
return;
}
const job = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
const orphanImportSource = String(
job?.makemkvInfo?.source
|| job?.makemkvInfo?.importRecovery?.source
|| ''
).trim().toLowerCase();
const isOrphanRawImportJob = orphanImportSource === 'orphan_raw_import';
const ripSuccessful = Number(job?.rip_successful || 0) === 1;
const deleteIncompleteRaw = !ripSuccessful && isIncompleteRawPath(job?.raw_path);
const deleteIncompleteRaw = !isOrphanRawImportJob && !ripSuccessful && isIncompleteRawPath(job?.raw_path);
const deleteTarget = deleteIncompleteRaw ? 'raw' : 'none';
const resetDriveStateOnDelete = !isOrphanRawImportJob;
const keepDetectedDeviceOnDelete = isOrphanRawImportJob;
const title = String(job?.title || job?.detected_title || `Job #${normalizedJobId}`).trim();
const confirmed = await confirmModal({
header: 'Job löschen',
@@ -1959,9 +1967,13 @@ export default function RipperPage({
`${title}\n\n` +
(deleteIncompleteRaw
? 'Hinweis: Unvollständiges RAW wird gelöscht. Für einen neuen Versuch bitte das Laufwerk erneut analysieren.'
: 'Hinweis: Dateien bleiben erhalten. Vollständiges RAW ist anschließend über /database wiederherstellbar.') +
: (isOrphanRawImportJob
? 'Hinweis: Dieser /database-Job löscht beim Entfernen aus dem Ripper keine RAW-Dateien.'
: 'Hinweis: Dateien bleiben erhalten. Vollständiges RAW ist anschließend über /database wiederherstellbar.')) +
'\nZugehörige Einträge (z.B. Serien-Container oder Folgejobs) werden ebenfalls entfernt.' +
'\nDas zugeordnete Laufwerk wird freigegeben und auf Leerzustand zurückgesetzt.',
(isOrphanRawImportJob
? '\nOrphan-Import-Job: Das zugeordnete Laufwerk bleibt unverändert.'
: '\nDas zugeordnete Laufwerk wird freigegeben und auf Leerzustand zurückgesetzt.'),
acceptLabel: 'Löschen',
rejectLabel: 'Abbrechen',
danger: true
@@ -1987,8 +1999,9 @@ export default function RipperPage({
}
await api.deleteJobEntry(normalizedJobId, deleteTarget, {
includeRelated: true,
resetDriveState: true,
keepDetectedDevice: false
resetDriveState: resetDriveStateOnDelete,
keepDetectedDevice: keepDetectedDeviceOnDelete,
preserveRawForImportJobs: true
});
await refreshPipeline();
await loadRipperJobs();