0.16.0 Pre-Release Checks
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.15.0-3",
|
||||
"version": "0.16.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.15.0-3",
|
||||
"version": "0.16.0",
|
||||
"dependencies": {
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.15.0-3",
|
||||
"version": "0.16.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -836,8 +836,21 @@ export const api = {
|
||||
afterMutationInvalidate(['/history']);
|
||||
return result;
|
||||
},
|
||||
async generateJobNfo(jobId) {
|
||||
const result = await request(`/history/${jobId}/nfo/generate`, {
|
||||
method: 'POST'
|
||||
});
|
||||
afterMutationInvalidate(['/history']);
|
||||
return result;
|
||||
},
|
||||
async deleteJobFiles(jobId, target = 'both', options = {}) {
|
||||
const includeRelated = Boolean(options?.includeRelated);
|
||||
const selectedJobIds = Array.isArray(options?.selectedJobIds)
|
||||
? options.selectedJobIds
|
||||
.map((item) => Number(item))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.map((value) => Math.trunc(value))
|
||||
: null;
|
||||
const selectedRawPaths = Array.isArray(options?.selectedRawPaths)
|
||||
? options.selectedRawPaths
|
||||
.map((item) => String(item || '').trim())
|
||||
@@ -853,6 +866,7 @@ export const api = {
|
||||
body: JSON.stringify({
|
||||
target,
|
||||
includeRelated,
|
||||
...(selectedJobIds ? { selectedJobIds } : {}),
|
||||
...(selectedRawPaths ? { selectedRawPaths } : {}),
|
||||
...(selectedMoviePaths ? { selectedMoviePaths } : {})
|
||||
})
|
||||
@@ -862,14 +876,29 @@ export const api = {
|
||||
},
|
||||
getJobDeletePreview(jobId, options = {}) {
|
||||
const includeRelated = options?.includeRelated !== false;
|
||||
const selectedJobIds = Array.isArray(options?.selectedJobIds)
|
||||
? options.selectedJobIds
|
||||
.map((item) => Number(item))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.map((value) => Math.trunc(value))
|
||||
: null;
|
||||
const query = new URLSearchParams();
|
||||
query.set('includeRelated', includeRelated ? '1' : '0');
|
||||
if (selectedJobIds) {
|
||||
query.set('selectedJobIds', selectedJobIds.join(','));
|
||||
}
|
||||
return request(`/history/${jobId}/delete-preview?${query.toString()}`);
|
||||
},
|
||||
async deleteJobEntry(jobId, target = 'none', options = {}) {
|
||||
const includeRelated = Boolean(options?.includeRelated);
|
||||
const resetDriveState = Boolean(options?.resetDriveState);
|
||||
const preserveRawForImportJobs = Boolean(options?.preserveRawForImportJobs);
|
||||
const selectedJobIds = Array.isArray(options?.selectedJobIds)
|
||||
? options.selectedJobIds
|
||||
.map((item) => Number(item))
|
||||
.filter((value) => Number.isFinite(value) && value > 0)
|
||||
.map((value) => Math.trunc(value))
|
||||
: null;
|
||||
const selectedRawPaths = Array.isArray(options?.selectedRawPaths)
|
||||
? options.selectedRawPaths
|
||||
.map((item) => String(item || '').trim())
|
||||
@@ -891,6 +920,7 @@ export const api = {
|
||||
includeRelated,
|
||||
resetDriveState,
|
||||
preserveRawForImportJobs,
|
||||
...(selectedJobIds ? { selectedJobIds } : {}),
|
||||
...(hasKeepDetectedDevice ? { keepDetectedDevice } : {}),
|
||||
...(selectedRawPaths ? { selectedRawPaths } : {}),
|
||||
...(selectedMoviePaths ? { selectedMoviePaths } : {})
|
||||
|
||||
@@ -28,6 +28,7 @@ const GENERAL_TOOL_KEYS = new Set([
|
||||
'mkvmerge_command',
|
||||
'ffprobe_command',
|
||||
'handbrake_restart_delete_incomplete_output',
|
||||
'handbrake_pre_metadata_scan_enabled',
|
||||
'script_test_timeout_ms'
|
||||
]);
|
||||
|
||||
|
||||
@@ -1060,6 +1060,7 @@ export default function JobDetailDialog({
|
||||
logLoadingMode = null,
|
||||
onAssignOmdb,
|
||||
onAssignCdMetadata,
|
||||
onGenerateNfo,
|
||||
onAcknowledgeError,
|
||||
onResumeReady,
|
||||
onRestartEncode,
|
||||
@@ -1078,6 +1079,7 @@ export default function JobDetailDialog({
|
||||
omdbAssignBusy = false,
|
||||
cdMetadataAssignBusy = false,
|
||||
acknowledgeErrorBusy = false,
|
||||
generateNfoBusy = false,
|
||||
actionBusy = false,
|
||||
cancelBusy = false,
|
||||
reencodeBusy = false,
|
||||
@@ -1088,6 +1090,8 @@ export default function JobDetailDialog({
|
||||
}) {
|
||||
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
||||
const statusUpper = String(job?.status || '').trim().toUpperCase();
|
||||
const lastStateUpper = String(job?.last_state || '').trim().toUpperCase();
|
||||
const errorMessageLower = String(job?.error_message || '').trim().toLowerCase();
|
||||
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';
|
||||
@@ -1123,6 +1127,22 @@ export default function JobDetailDialog({
|
||||
const isCd = mediaType === 'cd';
|
||||
const isAudiobook = mediaType === 'audiobook';
|
||||
const isConverter = mediaType === 'converter';
|
||||
const retryRipRecoveryReason = (
|
||||
errorMessageLower.includes('server-neustart')
|
||||
|| errorMessageLower.includes('rip ist unvollständig')
|
||||
|| errorMessageLower.includes('rip-validierung fehlgeschlagen')
|
||||
);
|
||||
const retryRipRequired = Boolean(
|
||||
!running
|
||||
&& !isAudiobook
|
||||
&& !isConverter
|
||||
&& ['ERROR', 'CANCELLED'].includes(statusUpper)
|
||||
&& (
|
||||
['RIPPING', 'CD_RIPPING'].includes(lastStateUpper)
|
||||
|| retryRipRecoveryReason
|
||||
)
|
||||
);
|
||||
const blockMetadataAndReviewUntilRetry = retryRipRequired;
|
||||
const converterMediaType = isConverter ? (resolveConverterMediaType(job) || 'video') : null;
|
||||
const converterMediaTypeLabel = converterMediaType === 'audio'
|
||||
? 'Audio'
|
||||
@@ -1215,6 +1235,7 @@ export default function JobDetailDialog({
|
||||
&& job?.rawStatus?.isEmpty !== true
|
||||
&& !running
|
||||
&& mediaType !== 'audiobook'
|
||||
&& !blockMetadataAndReviewUntilRetry
|
||||
&& typeof onRestartReview === 'function'
|
||||
);
|
||||
const converterPlan = isConverter && job?.encodePlan && typeof job.encodePlan === 'object'
|
||||
@@ -1271,6 +1292,14 @@ export default function JobDetailDialog({
|
||||
})()
|
||||
: null;
|
||||
const canDeleteEntry = !running && typeof onDeleteEntry === 'function';
|
||||
const nfoStatus = job?.nfoStatus && typeof job.nfoStatus === 'object'
|
||||
? job.nfoStatus
|
||||
: {};
|
||||
const canGenerateNfo = Boolean(
|
||||
!running
|
||||
&& typeof onGenerateNfo === 'function'
|
||||
&& nfoStatus?.canGenerateManual
|
||||
);
|
||||
const queueLocked = Boolean(isQueued && job?.id);
|
||||
const logCount = Number(job?.log_count || 0);
|
||||
const logMeta = job?.logMeta && typeof job.logMeta === 'object' ? job.logMeta : null;
|
||||
@@ -1278,7 +1307,7 @@ export default function JobDetailDialog({
|
||||
const logTruncated = Boolean(logMeta?.truncated);
|
||||
const cdDetails = isCd ? resolveCdDetails(job) : null;
|
||||
const audiobookDetails = isAudiobook ? resolveAudiobookDetails(job) : null;
|
||||
const canRetry = isCd && !running && typeof onRetry === 'function';
|
||||
const canRetry = !running && typeof onRetry === 'function' && (isCd || retryRipRequired);
|
||||
const mediaTypeLabel = mediaType === 'bluray'
|
||||
? (isDvdSeries ? 'Blu-ray Serie' : 'Blu-ray')
|
||||
: mediaType === 'dvd'
|
||||
@@ -1516,6 +1545,7 @@ export default function JobDetailDialog({
|
||||
const resolveChildActionState = (child) => {
|
||||
const childStatusUpper = String(child?.status || '').trim().toUpperCase();
|
||||
const childLastStateUpper = String(child?.last_state || '').trim().toUpperCase();
|
||||
const childErrorMessageLower = String(child?.error_message || '').trim().toLowerCase();
|
||||
const childRunning = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(childStatusUpper);
|
||||
const childCanResumeReady = Boolean(
|
||||
(childStatusUpper === 'READY_TO_ENCODE' || childLastStateUpper === 'READY_TO_ENCODE')
|
||||
@@ -1537,14 +1567,31 @@ export default function JobDetailDialog({
|
||||
const childHasRestartInput = Boolean(child?.encode_input_path || child?.raw_path || child?.encodePlan?.encodeInputPath);
|
||||
const childHasRaw = Boolean(child?.rawStatus?.exists || child?.raw_path || child?.encode_input_path);
|
||||
const childCanRestartEncode = Boolean(childHasConfirmedPlan && childHasRestartInput && !childRunning && childHasRaw);
|
||||
const childRetryRipRecoveryReason = (
|
||||
childErrorMessageLower.includes('server-neustart')
|
||||
|| childErrorMessageLower.includes('rip ist unvollständig')
|
||||
|| childErrorMessageLower.includes('rip-validierung fehlgeschlagen')
|
||||
);
|
||||
const childRetryRipRequired = Boolean(
|
||||
!childRunning
|
||||
&& childMediaType !== 'audiobook'
|
||||
&& childMediaType !== 'converter'
|
||||
&& ['ERROR', 'CANCELLED'].includes(childStatusUpper)
|
||||
&& (
|
||||
['RIPPING', 'CD_RIPPING'].includes(childLastStateUpper)
|
||||
|| childRetryRipRecoveryReason
|
||||
)
|
||||
);
|
||||
const childCanRestartReview = Boolean(
|
||||
child?.rawStatus?.exists
|
||||
&& child?.rawStatus?.isEmpty !== true
|
||||
&& !childRunning
|
||||
&& childMediaType !== 'audiobook'
|
||||
&& !childRetryRipRequired
|
||||
&& typeof onRestartReview === 'function'
|
||||
);
|
||||
const childCanAssignMetadata = !childRunning;
|
||||
const childCanAssignMetadata = !childRunning && !childRetryRipRequired;
|
||||
const childCanRetry = !childRunning && typeof onRetry === 'function' && (childMediaType === 'cd' || childRetryRipRequired);
|
||||
const childOutputFolders = Array.isArray(child?.outputFolders) ? child.outputFolders : [];
|
||||
const childSeriesOutputExisting = Number(child?.seriesOutputSummary?.existing || 0);
|
||||
const childHasAnyOutput = childOutputFolders.length > 0
|
||||
@@ -1557,6 +1604,8 @@ export default function JobDetailDialog({
|
||||
childCanRestartEncode,
|
||||
childCanRestartReview,
|
||||
childCanAssignMetadata,
|
||||
childCanRetry,
|
||||
childRetryRipRequired,
|
||||
childHasRaw,
|
||||
childHasAnyOutput
|
||||
};
|
||||
@@ -2544,7 +2593,24 @@ export default function JobDetailDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isCd && !isAudiobook && !isConverter && typeof onAssignOmdb === 'function' ? (
|
||||
{childActionState.childCanRetry ? (
|
||||
<div className="actions-group">
|
||||
<div className="actions-group-label"><i className="pi pi-refresh" /> Recovery</div>
|
||||
<div className="action-item">
|
||||
<Button
|
||||
label="Retry Rip"
|
||||
icon="pi pi-refresh"
|
||||
severity="warning"
|
||||
size="small"
|
||||
onClick={() => onRetry?.(child)}
|
||||
loading={actionBusy}
|
||||
/>
|
||||
<span className="action-desc">Löscht das alte RAW und startet den Rip erneut.</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isCd && !isAudiobook && !isConverter && !childActionState.childRetryRipRequired && typeof onAssignOmdb === 'function' ? (
|
||||
<div className="actions-group">
|
||||
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
|
||||
<div className="action-item">
|
||||
@@ -2938,7 +3004,28 @@ export default function JobDetailDialog({
|
||||
) : null
|
||||
)}
|
||||
|
||||
{!isCd && !isAudiobook && !isConverter && typeof onAssignOmdb === 'function' ? (
|
||||
{canRetry ? (
|
||||
<div className="actions-group">
|
||||
<div className="actions-group-label"><i className="pi pi-refresh" /> Recovery</div>
|
||||
<div className="action-item">
|
||||
<Button
|
||||
label={isCd ? 'CD-Rip neu starten' : 'Retry Rip'}
|
||||
icon="pi pi-refresh"
|
||||
severity="warning"
|
||||
size="small"
|
||||
onClick={() => onRetry?.(job)}
|
||||
loading={actionBusy}
|
||||
/>
|
||||
<span className="action-desc">
|
||||
{isCd
|
||||
? 'Startet den CD-Rip neu.'
|
||||
: 'Löscht das alte RAW und startet den Rip erneut.'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isCd && !isAudiobook && !isConverter && !blockMetadataAndReviewUntilRetry && typeof onAssignOmdb === 'function' ? (
|
||||
<div className="actions-group">
|
||||
<div className="actions-group-label"><i className="pi pi-search" /> Metadaten</div>
|
||||
<div className="action-item">
|
||||
@@ -2957,6 +3044,25 @@ export default function JobDetailDialog({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canGenerateNfo ? (
|
||||
<div className="actions-group">
|
||||
<div className="actions-group-label"><i className="pi pi-file" /> NFO</div>
|
||||
<div className="action-item">
|
||||
<Button
|
||||
label="NFO erzeugen"
|
||||
icon="pi pi-file-edit"
|
||||
severity="info"
|
||||
outlined
|
||||
size="small"
|
||||
onClick={() => onGenerateNfo?.(job)}
|
||||
loading={generateNfoBusy}
|
||||
disabled={actionBusy}
|
||||
/>
|
||||
<span className="action-desc">Erstellt eine .nfo-Datei neben der Output-Datei (manuell aus der Historie).</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{typeof onDeleteFiles === 'function' ? (
|
||||
<div className="actions-group">
|
||||
<div className="actions-group-label"><i className="pi pi-trash" /> Dateien löschen</div>
|
||||
|
||||
@@ -14,6 +14,84 @@ export default function MetadataSelectionDialog({
|
||||
onSearch,
|
||||
busy
|
||||
}) {
|
||||
const toSearchTitleCase = (value) => String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/\b([a-z\u00c0-\u024f])([a-z\u00c0-\u024f0-9']*)/g, (_match, first, rest) => (
|
||||
`${first.toUpperCase()}${rest}`
|
||||
));
|
||||
const stripTrailingDiscHint = (value) => {
|
||||
let working = String(value || '').trim();
|
||||
if (!working) {
|
||||
return '';
|
||||
}
|
||||
const patterns = [
|
||||
/\b(?:disc|disk|dvd|bd|br|cd|part|pt|vol|volume|season|staffel|episode|ep)\s*(?:\d{1,3}|[ivxlcdm]{1,8})\b$/i,
|
||||
/\b(?:[a-z]{1,3}\d{1,4}|\d{1,4}[a-z]{1,3})\b$/i
|
||||
];
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const before = working;
|
||||
for (const pattern of patterns) {
|
||||
working = working.replace(pattern, '').trim();
|
||||
}
|
||||
working = working.replace(/[-_.,;:]+$/g, '').trim();
|
||||
if (working === before || !working) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return working;
|
||||
};
|
||||
const buildSearchVariants = (rawValue) => {
|
||||
const raw = String(rawValue || '').trim();
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
if (/^tt\d{6,12}$/i.test(raw)) {
|
||||
return [raw.toLowerCase()];
|
||||
}
|
||||
const variants = [];
|
||||
const pushUnique = (candidate) => {
|
||||
const normalized = String(candidate || '').replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
if (!variants.some((entry) => entry.toLowerCase() === normalized.toLowerCase())) {
|
||||
variants.push(normalized);
|
||||
}
|
||||
};
|
||||
const separatorNormalized = raw
|
||||
.replace(/[_]+/g, ' ')
|
||||
.replace(/[.]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const punctuationReduced = separatorNormalized
|
||||
.replace(/[|/\\]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const stripped = stripTrailingDiscHint(punctuationReduced);
|
||||
|
||||
pushUnique(raw);
|
||||
pushUnique(separatorNormalized);
|
||||
pushUnique(punctuationReduced);
|
||||
pushUnique(toSearchTitleCase(separatorNormalized));
|
||||
pushUnique(stripped);
|
||||
pushUnique(toSearchTitleCase(stripped));
|
||||
|
||||
return variants.slice(0, 6);
|
||||
};
|
||||
const normalizeDetectedTitleForMetadataInput = (rawValue) => {
|
||||
const raw = String(rawValue || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
const separatorNormalized = raw
|
||||
.replace(/[_]+/g, ' ')
|
||||
.replace(/[.]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const stripped = stripTrailingDiscHint(separatorNormalized);
|
||||
const preferred = stripped || separatorNormalized || raw;
|
||||
return toSearchTitleCase(preferred);
|
||||
};
|
||||
const normalizeWorkflowKind = (value) => {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
@@ -56,6 +134,7 @@ export default function MetadataSelectionDialog({
|
||||
const [seasonFilter, setSeasonFilter] = useState('');
|
||||
const [discValidationError, setDiscValidationError] = useState('');
|
||||
const activeSearchRequestRef = useRef(0);
|
||||
const autoSearchDoneRef = useRef(false);
|
||||
const contextWorkflowKind = normalizeWorkflowKind(
|
||||
context?.selectedMetadata?.workflowKind
|
||||
|| context?.workflowKind
|
||||
@@ -97,7 +176,8 @@ export default function MetadataSelectionDialog({
|
||||
}
|
||||
|
||||
const selectedMetadata = context?.selectedMetadata || {};
|
||||
const defaultTitle = selectedMetadata.title || context?.detectedTitle || '';
|
||||
const rawDefaultTitle = selectedMetadata.title || context?.detectedTitle || '';
|
||||
const defaultTitle = normalizeDetectedTitleForMetadataInput(rawDefaultTitle) || rawDefaultTitle;
|
||||
const defaultYear = selectedMetadata.year ? String(selectedMetadata.year) : '';
|
||||
const defaultImdb = selectedMetadata.imdbId || '';
|
||||
|
||||
@@ -119,6 +199,7 @@ export default function MetadataSelectionDialog({
|
||||
setSeasonFilter('');
|
||||
setDiscValidationError('');
|
||||
setSelectedMetadataProvider(contextMetadataProvider);
|
||||
autoSearchDoneRef.current = false;
|
||||
activeSearchRequestRef.current += 1;
|
||||
}, [visible, context]);
|
||||
|
||||
@@ -205,19 +286,30 @@ export default function MetadataSelectionDialog({
|
||||
if (!trimmedQuery || typeof onSearch !== 'function') {
|
||||
return;
|
||||
}
|
||||
const queryVariants = buildSearchVariants(trimmedQuery);
|
||||
if (queryVariants.length === 0) {
|
||||
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;
|
||||
let effectiveResults = [];
|
||||
for (const candidateQuery of queryVariants) {
|
||||
const results = await onSearch(candidateQuery, {
|
||||
metadataProvider
|
||||
});
|
||||
if (activeSearchRequestRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(results) && results.length > 0) {
|
||||
effectiveResults = results;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setExtraResults(Array.isArray(results) ? results : []);
|
||||
setExtraResults(Array.isArray(effectiveResults) ? effectiveResults : []);
|
||||
} catch (error) {
|
||||
if (activeSearchRequestRef.current !== requestId) {
|
||||
return;
|
||||
@@ -230,6 +322,22 @@ export default function MetadataSelectionDialog({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || autoSearchDoneRef.current) {
|
||||
return;
|
||||
}
|
||||
if (typeof onSearch !== 'function') {
|
||||
autoSearchDoneRef.current = true;
|
||||
return;
|
||||
}
|
||||
const trimmedQuery = String(query || '').trim();
|
||||
if (!trimmedQuery) {
|
||||
return;
|
||||
}
|
||||
autoSearchDoneRef.current = true;
|
||||
void handleSearch();
|
||||
}, [visible, query, metadataProvider, onSearch]);
|
||||
|
||||
const handleSubmitError = (error, pendingPayload = null) => {
|
||||
const details = Array.isArray(error?.details) ? error.details : [];
|
||||
const duplicateDetail = details.find(
|
||||
|
||||
@@ -581,6 +581,88 @@ function hasSeriesMetadataShape(source = null) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDiscWorkflowKind(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (['series', 'tv', 'season', 'episode', 'tv_series', 'tv-season', 'tv_season'].includes(raw)) {
|
||||
return 'series';
|
||||
}
|
||||
if (['film', 'movie', 'feature'].includes(raw)) {
|
||||
return 'film';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDiscWorkflowKind({
|
||||
mediaProfile = null,
|
||||
selectedMetadata = null,
|
||||
analyzeContext = null,
|
||||
pipelineContext = null,
|
||||
fallbackIsSeries = false
|
||||
} = {}) {
|
||||
const normalizedMediaProfile = String(mediaProfile || '').trim().toLowerCase();
|
||||
if (normalizedMediaProfile !== 'dvd' && normalizedMediaProfile !== 'bluray') {
|
||||
return null;
|
||||
}
|
||||
const selected = selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {};
|
||||
const analyze = analyzeContext && typeof analyzeContext === 'object' ? analyzeContext : {};
|
||||
const pipelineCtx = pipelineContext && typeof pipelineContext === 'object' ? pipelineContext : {};
|
||||
const workflowCandidates = [
|
||||
selected?.workflowKind,
|
||||
selected?.kind,
|
||||
analyze?.workflowKind,
|
||||
pipelineCtx?.workflowKind
|
||||
];
|
||||
for (const candidate of workflowCandidates) {
|
||||
const normalized = normalizeDiscWorkflowKind(candidate);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
const metadataKindCandidates = [
|
||||
selected?.metadataKind,
|
||||
analyze?.metadataKind
|
||||
];
|
||||
for (const candidate of metadataKindCandidates) {
|
||||
const normalized = normalizeDiscWorkflowKind(candidate);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
const providerCandidates = [
|
||||
selected?.metadataProvider,
|
||||
analyze?.metadataProvider,
|
||||
pipelineCtx?.metadataProvider
|
||||
];
|
||||
for (const candidate of providerCandidates) {
|
||||
const provider = String(candidate || '').trim().toLowerCase();
|
||||
if (!provider) {
|
||||
continue;
|
||||
}
|
||||
if (provider === 'tmdb' || provider === 'themoviedb') {
|
||||
return 'series';
|
||||
}
|
||||
if (provider === 'omdb') {
|
||||
return 'film';
|
||||
}
|
||||
}
|
||||
return fallbackIsSeries ? 'series' : 'film';
|
||||
}
|
||||
|
||||
function getDiscWorkflowBadgeLabel(mediaProfile, workflowKind) {
|
||||
const normalizedMediaProfile = String(mediaProfile || '').trim().toLowerCase();
|
||||
if (normalizedMediaProfile !== 'dvd' && normalizedMediaProfile !== 'bluray') {
|
||||
return null;
|
||||
}
|
||||
const kind = normalizeDiscWorkflowKind(workflowKind) || 'film';
|
||||
if (normalizedMediaProfile === 'bluray') {
|
||||
return kind === 'series' ? 'Blu-ray-Serie' : 'Blu-ray-Film';
|
||||
}
|
||||
return kind === 'series' ? 'DVD-Serie' : 'DVD-Film';
|
||||
}
|
||||
|
||||
function hasSeriesEpisodeAssignmentHints(titles = []) {
|
||||
const reviewTitles = Array.isArray(titles) ? titles : [];
|
||||
return reviewTitles.some((title) => {
|
||||
@@ -1496,6 +1578,13 @@ export default function PipelineStatusCard({
|
||||
).trim().toLowerCase();
|
||||
const isOrphanImport = orphanSource === 'orphan_raw_import';
|
||||
const isOrphanCancelled = isOrphanImport && (state === 'ERROR' || state === 'CANCELLED');
|
||||
const jobStatusUpper = String(jobRow?.status || state || '').trim().toUpperCase();
|
||||
const jobLastStateUpper = String(jobRow?.last_state || '').trim().toUpperCase();
|
||||
const jobErrorMessageLower = String(
|
||||
jobRow?.error_message
|
||||
|| pipeline?.context?.errorMessage
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
const ripCompleted = Boolean(
|
||||
Number(jobRow?.rip_successful || jobRow?.ripSuccessful || pipeline?.context?.ripSuccessful || 0) === 1
|
||||
|| jobRow?.raw_path
|
||||
@@ -1504,12 +1593,31 @@ export default function PipelineStatusCard({
|
||||
|| String(jobRow?.makemkvInfo?.status || '').trim().toUpperCase() === 'SUCCESS'
|
||||
);
|
||||
const selectedMetadata = pipeline?.context?.selectedMetadata || null;
|
||||
const analyzeContext = jobRow?.makemkvInfo?.analyzeContext && typeof jobRow.makemkvInfo.analyzeContext === 'object'
|
||||
? jobRow.makemkvInfo.analyzeContext
|
||||
: null;
|
||||
const mediaInfoReview = pipeline?.context?.mediaInfoReview || null;
|
||||
const playlistAnalysis = pipeline?.context?.playlistAnalysis || null;
|
||||
const encodeInputPath = pipeline?.context?.inputPath || mediaInfoReview?.encodeInputPath || null;
|
||||
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
|
||||
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
|
||||
const jobMediaProfile = resolvePipelineMediaProfile(pipeline, mediaInfoReview);
|
||||
const retryRipRecoveryRequired = Boolean(
|
||||
!running
|
||||
&& retryJobId
|
||||
&& typeof onRetry === 'function'
|
||||
&& !queueLocked
|
||||
&& !isOrphanCancelled
|
||||
&& !['converter', 'audiobook'].includes(String(jobMediaProfile || '').trim().toLowerCase())
|
||||
&& ['ERROR', 'CANCELLED'].includes(jobStatusUpper)
|
||||
&& (
|
||||
['RIPPING', 'CD_RIPPING'].includes(jobLastStateUpper)
|
||||
|| jobErrorMessageLower.includes('server-neustart')
|
||||
|| jobErrorMessageLower.includes('rip ist unvollständig')
|
||||
|| jobErrorMessageLower.includes('rip-validierung fehlgeschlagen')
|
||||
)
|
||||
);
|
||||
const blockMetadataAndReviewForRipRetry = retryRipRecoveryRequired;
|
||||
const discTypeLabel = jobMediaProfile === 'bluray' ? 'Blu-ray' : 'DVD';
|
||||
const isDiscTitleSelectionRequired = Boolean(
|
||||
(jobMediaProfile === 'dvd' || jobMediaProfile === 'bluray')
|
||||
@@ -2241,6 +2349,7 @@ export default function PipelineStatusCard({
|
||||
const canRestartReviewFromRaw = Boolean(
|
||||
retryJobId
|
||||
&& !running
|
||||
&& !blockMetadataAndReviewForRipRetry
|
||||
&& (pipeline?.context?.canRestartReviewFromRaw || pipeline?.context?.rawPath)
|
||||
);
|
||||
const allowConverterConfigEdit = showConverterConfig && allowReviewSelectionEdit && state === 'READY_TO_ENCODE';
|
||||
@@ -2640,6 +2749,14 @@ export default function PipelineStatusCard({
|
||||
mediaInfoReview?.titles,
|
||||
mediaInfoReview?.seriesBatchParent
|
||||
]);
|
||||
const discWorkflowKind = resolveDiscWorkflowKind({
|
||||
mediaProfile: jobMediaProfile,
|
||||
selectedMetadata,
|
||||
analyzeContext,
|
||||
pipelineContext: pipeline?.context || null,
|
||||
fallbackIsSeries: isSeriesDvdReview
|
||||
});
|
||||
const discWorkflowBadgeLabel = getDiscWorkflowBadgeLabel(jobMediaProfile, discWorkflowKind);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -3551,6 +3668,7 @@ export default function PipelineStatusCard({
|
||||
{(state === 'METADATA_SELECTION' || state === 'WAITING_FOR_USER_DECISION' || isOrphanCancelled)
|
||||
&& retryJobId
|
||||
&& typeof onOpenMetadata === 'function'
|
||||
&& !blockMetadataAndReviewForRipRetry
|
||||
&& !(jobMediaProfile === 'converter' && !selectedMetadata?.imdbId) ? (
|
||||
<Button
|
||||
label="Metadaten öffnen"
|
||||
@@ -3579,6 +3697,7 @@ export default function PipelineStatusCard({
|
||||
&& retryJobId
|
||||
&& typeof onReassignOmdb === 'function'
|
||||
&& !isOrphanCancelled
|
||||
&& !blockMetadataAndReviewForRipRetry
|
||||
&& !(jobMediaProfile === 'converter' && state === 'READY_TO_START' && !selectedMetadata?.imdbId) ? (
|
||||
<Button
|
||||
label="Metadaten neu zuweisen"
|
||||
@@ -3738,7 +3857,7 @@ export default function PipelineStatusCard({
|
||||
)
|
||||
) : null}
|
||||
|
||||
{(state === 'ERROR' || state === 'CANCELLED') && retryJobId && !isOrphanCancelled && !ripCompleted && (
|
||||
{(state === 'ERROR' || state === 'CANCELLED') && retryJobId && !isOrphanCancelled && (retryRipRecoveryRequired || !ripCompleted) && (
|
||||
<Button
|
||||
label="Retry Rippen"
|
||||
icon="pi pi-refresh"
|
||||
|
||||
@@ -16,8 +16,44 @@ export function useWebSocket({ onMessage }) {
|
||||
useEffect(() => {
|
||||
let socket;
|
||||
let reconnectTimer;
|
||||
let progressFlushTimer = null;
|
||||
let progressFlushRaf = null;
|
||||
let latestProgressMessage = null;
|
||||
let isUnmounted = false;
|
||||
|
||||
const clearProgressFlush = () => {
|
||||
if (progressFlushRaf !== null && typeof window !== 'undefined' && typeof window.cancelAnimationFrame === 'function') {
|
||||
window.cancelAnimationFrame(progressFlushRaf);
|
||||
}
|
||||
if (progressFlushTimer) {
|
||||
clearTimeout(progressFlushTimer);
|
||||
}
|
||||
progressFlushRaf = null;
|
||||
progressFlushTimer = null;
|
||||
};
|
||||
|
||||
const flushProgressMessage = () => {
|
||||
progressFlushRaf = null;
|
||||
progressFlushTimer = null;
|
||||
const message = latestProgressMessage;
|
||||
latestProgressMessage = null;
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
onMessageRef.current?.(message);
|
||||
};
|
||||
|
||||
const scheduleProgressFlush = () => {
|
||||
if (progressFlushRaf !== null || progressFlushTimer) {
|
||||
return;
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
|
||||
progressFlushRaf = window.requestAnimationFrame(flushProgressMessage);
|
||||
return;
|
||||
}
|
||||
progressFlushTimer = setTimeout(flushProgressMessage, 16);
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (isUnmounted) {
|
||||
return;
|
||||
@@ -28,6 +64,11 @@ export function useWebSocket({ onMessage }) {
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message?.type === 'PIPELINE_PROGRESS') {
|
||||
latestProgressMessage = message;
|
||||
scheduleProgressFlush();
|
||||
return;
|
||||
}
|
||||
onMessageRef.current?.(message);
|
||||
} catch (error) {
|
||||
// ignore invalid json
|
||||
@@ -51,6 +92,7 @@ export function useWebSocket({ onMessage }) {
|
||||
|
||||
return () => {
|
||||
isUnmounted = true;
|
||||
clearProgressFlush();
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
}
|
||||
|
||||
@@ -546,6 +546,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
|
||||
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
|
||||
const [deleteEntryIncludeRelated, setDeleteEntryIncludeRelated] = useState(true);
|
||||
const [deleteEntrySelectedJobIds, setDeleteEntrySelectedJobIds] = useState([]);
|
||||
const [deleteEntrySelectedRawPaths, setDeleteEntrySelectedRawPaths] = useState([]);
|
||||
const [deleteEntrySelectedMoviePaths, setDeleteEntrySelectedMoviePaths] = useState([]);
|
||||
const [downloadBusyTarget, setDownloadBusyTarget] = useState(null);
|
||||
@@ -556,6 +557,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const [cdMetadataDialogVisible, setCdMetadataDialogVisible] = useState(false);
|
||||
const [cdMetadataDialogContext, setCdMetadataDialogContext] = useState(null);
|
||||
const [cdMetadataAssignBusy, setCdMetadataAssignBusy] = useState(false);
|
||||
const [generateNfoBusy, setGenerateNfoBusy] = useState(false);
|
||||
const [acknowledgeErrorBusy, setAcknowledgeErrorBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
||||
@@ -1190,11 +1192,11 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const handleRetry = async (row) => {
|
||||
const title = row?.title || row?.detected_title || `Job #${row?.id}`;
|
||||
const mediaType = resolveMediaType(row);
|
||||
const actionLabel = mediaType === 'cd' ? 'CD-Rip' : 'Retry';
|
||||
const diskLabel = mediaType === 'cd' ? 'CD' : 'Disk';
|
||||
const confirmed = await confirmModal({
|
||||
header: `${actionLabel} neu starten`,
|
||||
message: `${actionLabel} für "${title}" neu starten?`,
|
||||
acceptLabel: 'Neu starten',
|
||||
header: 'Rip neu starten',
|
||||
message: `Bitte lege die korrekte ${diskLabel} für "${title}" ein.\nSoll der Rip jetzt gestartet werden?`,
|
||||
acceptLabel: 'Rip starten',
|
||||
rejectLabel: 'Abbrechen'
|
||||
});
|
||||
if (!confirmed) {
|
||||
@@ -1234,6 +1236,39 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateNfo = async (row) => {
|
||||
const jobId = Number(row?.id || 0);
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
setGenerateNfoBusy(true);
|
||||
try {
|
||||
const response = await api.generateJobNfo(jobId);
|
||||
const nfoPath = String(response?.result?.nfoPath || '').trim() || null;
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'NFO erzeugt',
|
||||
detail: nfoPath ? `Datei erstellt: ${nfoPath}` : 'NFO-Datei wurde erstellt.',
|
||||
life: 4000
|
||||
});
|
||||
if (response?.job) {
|
||||
setSelectedJob(response.job);
|
||||
} else {
|
||||
await refreshDetailIfOpen(jobId);
|
||||
}
|
||||
await load();
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'NFO-Erzeugung fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setGenerateNfoBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignOmdb = (row) => {
|
||||
const jobId = Number(row?.id || 0);
|
||||
if (!jobId) return;
|
||||
@@ -1493,6 +1528,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
setDeleteEntryPreviewLoading(false);
|
||||
setDeleteEntryTargetBusy(null);
|
||||
setDeleteEntryIncludeRelated(true);
|
||||
setDeleteEntrySelectedJobIds([]);
|
||||
setDeleteEntrySelectedRawPaths([]);
|
||||
setDeleteEntrySelectedMoviePaths([]);
|
||||
};
|
||||
@@ -1509,6 +1545,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
setDeleteEntryDialogRow(row);
|
||||
setDeleteEntryPreview(null);
|
||||
setDeleteEntryIncludeRelated(includeRelated);
|
||||
setDeleteEntrySelectedJobIds([]);
|
||||
setDeleteEntrySelectedRawPaths([]);
|
||||
setDeleteEntrySelectedMoviePaths([]);
|
||||
setDeleteEntryDialogVisible(true);
|
||||
@@ -1517,7 +1554,14 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
try {
|
||||
const response = await api.getJobDeletePreview(jobId, { includeRelated });
|
||||
const preview = response?.preview || null;
|
||||
const relatedJobs = Array.isArray(preview?.relatedJobs) ? preview.relatedJobs : [];
|
||||
const selectedJobIds = Array.isArray(preview?.selectedJobIds)
|
||||
? preview.selectedJobIds.map((id) => normalizeJobId(id)).filter(Boolean)
|
||||
: relatedJobs
|
||||
.map((item) => normalizeJobId(item?.id))
|
||||
.filter(Boolean);
|
||||
setDeleteEntryPreview(preview);
|
||||
setDeleteEntrySelectedJobIds(selectedJobIds);
|
||||
const rawCandidates = Array.isArray(preview?.pathCandidates?.raw) ? preview.pathCandidates.raw : [];
|
||||
const defaultSelectedRawPaths = rawCandidates
|
||||
.filter((item) => Boolean(item?.exists))
|
||||
@@ -1541,13 +1585,80 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
setDeleteEntryDialogRow(null);
|
||||
setDeleteEntryPreview(null);
|
||||
setDeleteEntryIncludeRelated(true);
|
||||
setDeleteEntrySelectedJobIds([]);
|
||||
setDeleteEntrySelectedRawPaths([]);
|
||||
setDeleteEntrySelectedMoviePaths([]);
|
||||
} finally {
|
||||
setDeleteEntryPreviewLoading(false);
|
||||
setDeleteEntryBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshDeleteEntryPreview = async (jobId, selectedJobIds) => {
|
||||
setDeleteEntryPreviewLoading(true);
|
||||
try {
|
||||
const response = await api.getJobDeletePreview(jobId, {
|
||||
includeRelated: deleteEntryIncludeRelated,
|
||||
selectedJobIds
|
||||
});
|
||||
const preview = response?.preview || null;
|
||||
const relatedJobs = Array.isArray(preview?.relatedJobs) ? preview.relatedJobs : [];
|
||||
const nextSelectedJobIds = Array.isArray(preview?.selectedJobIds)
|
||||
? preview.selectedJobIds.map((id) => normalizeJobId(id)).filter(Boolean)
|
||||
: relatedJobs
|
||||
.map((item) => normalizeJobId(item?.id))
|
||||
.filter(Boolean);
|
||||
setDeleteEntryPreview(preview);
|
||||
setDeleteEntrySelectedJobIds(nextSelectedJobIds);
|
||||
const rawCandidates = Array.isArray(preview?.pathCandidates?.raw) ? preview.pathCandidates.raw : [];
|
||||
const defaultSelectedRawPaths = rawCandidates
|
||||
.filter((item) => Boolean(item?.exists))
|
||||
.map((item) => String(item?.path || '').trim())
|
||||
.filter(Boolean);
|
||||
setDeleteEntrySelectedRawPaths(defaultSelectedRawPaths);
|
||||
const movieCandidates = Array.isArray(preview?.pathCandidates?.movie) ? preview.pathCandidates.movie : [];
|
||||
const defaultSelectedMoviePaths = movieCandidates
|
||||
.filter((item) => Boolean(item?.exists))
|
||||
.map((item) => String(item?.path || '').trim())
|
||||
.filter(Boolean);
|
||||
setDeleteEntrySelectedMoviePaths(defaultSelectedMoviePaths);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Löschvorschau fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setDeleteEntryPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDeleteRelatedJobSelection = async (relatedJobId, checked) => {
|
||||
const normalizedRelatedJobId = normalizeJobId(relatedJobId);
|
||||
const dialogJobId = normalizeJobId(deleteEntryDialogRow?.id);
|
||||
if (!normalizedRelatedJobId || !dialogJobId) {
|
||||
return;
|
||||
}
|
||||
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||
const knownJobIds = relatedJobs
|
||||
.map((item) => normalizeJobId(item?.id))
|
||||
.filter(Boolean);
|
||||
const currentSelected = new Set(
|
||||
deleteEntrySelectedJobIds
|
||||
.map((item) => normalizeJobId(item))
|
||||
.filter(Boolean)
|
||||
);
|
||||
if (checked) {
|
||||
currentSelected.add(normalizedRelatedJobId);
|
||||
} else {
|
||||
currentSelected.delete(normalizedRelatedJobId);
|
||||
}
|
||||
const orderedSelected = knownJobIds.filter((jobId) => currentSelected.has(jobId));
|
||||
setDeleteEntrySelectedJobIds(orderedSelected);
|
||||
await refreshDeleteEntryPreview(dialogJobId, orderedSelected);
|
||||
};
|
||||
|
||||
const confirmDeleteEntry = async (target) => {
|
||||
const normalizedTarget = String(target || '').trim().toLowerCase();
|
||||
if (!['raw', 'movie', 'both', 'none'].includes(normalizedTarget)) {
|
||||
@@ -1561,6 +1672,16 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||
if (deleteEntryIncludeRelated && relatedJobs.length > 0 && deleteEntrySelectedJobIds.length === 0) {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'Historie-Auswahl erforderlich',
|
||||
detail: 'Bitte mindestens einen Rip/Encode-Eintrag auswählen.',
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(normalizedTarget === 'raw' || normalizedTarget === 'both')
|
||||
&& rawDeleteSelectionEnabled
|
||||
@@ -1593,6 +1714,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
try {
|
||||
const response = await api.deleteJobEntry(jobId, normalizedTarget, {
|
||||
includeRelated: deleteEntryIncludeRelated,
|
||||
selectedJobIds: deleteEntrySelectedJobIds,
|
||||
selectedRawPaths: deleteEntrySelectedRawPaths,
|
||||
selectedMoviePaths: deleteEntrySelectedMoviePaths
|
||||
});
|
||||
@@ -1630,6 +1752,88 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteFilesOnly = async (target = 'both') => {
|
||||
const normalizedTarget = String(target || '').trim().toLowerCase();
|
||||
if (!['raw', 'movie', 'both'].includes(normalizedTarget)) {
|
||||
return;
|
||||
}
|
||||
const jobId = Number(deleteEntryDialogRow?.id || 0);
|
||||
if (!jobId) {
|
||||
return;
|
||||
}
|
||||
const relatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||
if (deleteEntryIncludeRelated && relatedJobs.length > 0 && deleteEntrySelectedJobIds.length === 0) {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'Historie-Auswahl erforderlich',
|
||||
detail: 'Bitte mindestens einen Rip/Encode-Eintrag auswählen.',
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(normalizedTarget === 'raw' || normalizedTarget === 'both')
|
||||
&& rawDeleteSelectionEnabled
|
||||
&& deleteEntrySelectedRawPaths.length === 0
|
||||
) {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: 'RAW-Auswahl erforderlich',
|
||||
detail: 'Bitte mindestens einen RAW-Pfad zum Löschen auswählen.',
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(normalizedTarget === 'movie' || normalizedTarget === 'both')
|
||||
&& previewMovieExisting.length > 0
|
||||
&& deleteEntrySelectedMoviePaths.length === 0
|
||||
) {
|
||||
toastRef.current?.show({
|
||||
severity: 'warn',
|
||||
summary: `${deleteEntryOutputShortLabel}-Dateien auswählen`,
|
||||
detail: `Bitte mindestens eine ${deleteEntryOutputShortLabel}-Datei zum Löschen auswählen.`,
|
||||
life: 3500
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteEntryBusy(true);
|
||||
setDeleteEntryTargetBusy(`files-${normalizedTarget}`);
|
||||
try {
|
||||
const response = await api.deleteJobFiles(jobId, normalizedTarget, {
|
||||
includeRelated: deleteEntryIncludeRelated,
|
||||
selectedJobIds: deleteEntrySelectedJobIds,
|
||||
selectedRawPaths: deleteEntrySelectedRawPaths,
|
||||
selectedMoviePaths: deleteEntrySelectedMoviePaths
|
||||
});
|
||||
const summary = response?.summary || {};
|
||||
const rawFiles = Number(summary?.raw?.filesDeleted || 0);
|
||||
const movieFiles = Number(summary?.movie?.filesDeleted || 0);
|
||||
const rawDirs = Number(summary?.raw?.dirsRemoved || 0);
|
||||
const movieDirs = Number(summary?.movie?.dirsRemoved || 0);
|
||||
toastRef.current?.show({
|
||||
severity: 'success',
|
||||
summary: 'Dateien gelöscht',
|
||||
detail: `Einträge bleiben bestehen | RAW: ${rawFiles} Dateien, ${rawDirs} Ordner | ${deleteEntryOutputShortLabel}: ${movieFiles} Dateien, ${movieDirs} Ordner`,
|
||||
life: 5000
|
||||
});
|
||||
|
||||
closeDeleteEntryDialog();
|
||||
await load();
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Datei-Löschen fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 5000
|
||||
});
|
||||
} finally {
|
||||
setDeleteEntryTargetBusy(null);
|
||||
setDeleteEntryBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDeleteMoviePathSelection = (moviePath, checked) => {
|
||||
const normalizedPath = String(moviePath || '').trim();
|
||||
if (!normalizedPath) {
|
||||
@@ -2251,24 +2455,36 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
};
|
||||
|
||||
const previewRelatedJobs = Array.isArray(deleteEntryPreview?.relatedJobs) ? deleteEntryPreview.relatedJobs : [];
|
||||
const previewSelectedJobIds = Array.isArray(deleteEntryPreview?.selectedJobIds)
|
||||
? deleteEntryPreview.selectedJobIds.map((item) => normalizeJobId(item)).filter(Boolean)
|
||||
: previewRelatedJobs
|
||||
.filter((item) => Boolean(item?.selected))
|
||||
.map((item) => normalizeJobId(item?.id))
|
||||
.filter(Boolean);
|
||||
const previewRawPaths = Array.isArray(deleteEntryPreview?.pathCandidates?.raw) ? deleteEntryPreview.pathCandidates.raw : [];
|
||||
const previewMoviePaths = Array.isArray(deleteEntryPreview?.pathCandidates?.movie) ? deleteEntryPreview.pathCandidates.movie : [];
|
||||
const deleteEntryIsMergeJob = isMultipartMergeHistoryRow(deleteEntryDialogRow);
|
||||
const deleteEntrySupportsFilesOnly = isContainerHistoryRow(deleteEntryDialogRow);
|
||||
const previewRawExisting = previewRawPaths.filter((item) => Boolean(item?.exists));
|
||||
const previewMovieExisting = previewMoviePaths.filter((item) => Boolean(item?.exists));
|
||||
const rawDeleteSelectionEnabled = !deleteEntryIsMergeJob && previewRawExisting.length > 2;
|
||||
const previewRawDisplay = rawDeleteSelectionEnabled
|
||||
? previewRawPaths
|
||||
: (previewRawExisting.length > 0 ? previewRawExisting : previewRawPaths.slice(0, 1));
|
||||
const previewMovieDisplay = previewMovieExisting.length > 0 ? previewMovieExisting : previewMoviePaths.slice(0, 1);
|
||||
const rawDeleteSelectionEnabled = !deleteEntryIsMergeJob && previewRawExisting.length > 0;
|
||||
const previewRawDisplay = previewRawPaths;
|
||||
const previewMovieDisplay = previewMoviePaths;
|
||||
const selectedDeleteRawPathSet = useMemo(
|
||||
() => new Set(deleteEntrySelectedRawPaths.map((item) => String(item || '').trim()).filter(Boolean)),
|
||||
[deleteEntrySelectedRawPaths]
|
||||
);
|
||||
const selectedDeleteRelatedJobSet = useMemo(
|
||||
() => new Set(deleteEntrySelectedJobIds.map((item) => normalizeJobId(item)).filter(Boolean)),
|
||||
[deleteEntrySelectedJobIds]
|
||||
);
|
||||
const selectedDeleteMoviePathSet = useMemo(
|
||||
() => new Set(deleteEntrySelectedMoviePaths.map((item) => String(item || '').trim()).filter(Boolean)),
|
||||
[deleteEntrySelectedMoviePaths]
|
||||
);
|
||||
const relatedDeleteSelectionRequired = deleteEntryIncludeRelated
|
||||
&& previewRelatedJobs.length > 0
|
||||
&& deleteEntrySelectedJobIds.length === 0;
|
||||
const rawDeleteSelectionRequired = !deleteEntryIsMergeJob
|
||||
&& rawDeleteSelectionEnabled
|
||||
&& deleteEntrySelectedRawPaths.length === 0;
|
||||
@@ -2357,6 +2573,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
onRetry={resolveMediaType(selectedJob) === 'converter' ? null : handleRetry}
|
||||
onAssignOmdb={handleAssignOmdb}
|
||||
onAssignCdMetadata={handleAssignCdMetadata}
|
||||
onGenerateNfo={handleGenerateNfo}
|
||||
onAcknowledgeError={handleAcknowledgeError}
|
||||
onDeleteFiles={handleDeleteFiles}
|
||||
onDeleteEntry={handleDeleteEntry}
|
||||
@@ -2371,6 +2588,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
restoreMergeBusy={actionBusy}
|
||||
omdbAssignBusy={omdbAssignBusy}
|
||||
cdMetadataAssignBusy={cdMetadataAssignBusy}
|
||||
generateNfoBusy={generateNfoBusy}
|
||||
acknowledgeErrorBusy={acknowledgeErrorBusy}
|
||||
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
||||
deleteEntryBusy={deleteEntryBusy}
|
||||
@@ -2394,7 +2612,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
modal
|
||||
>
|
||||
<p>
|
||||
{`Es werden ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge entfernt.`}
|
||||
{`Es sind ${previewRelatedJobs.length || 1} Historien-Eintrag/Einträge verknüpft. Ausgewählt: ${deleteEntrySelectedJobIds.length || previewSelectedJobIds.length || 0}.`}
|
||||
</p>
|
||||
<small className="history-dv-subtle">
|
||||
{deleteEntryIsMergeJob
|
||||
@@ -2418,13 +2636,29 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
<ul className="history-delete-preview-list">
|
||||
{previewRelatedJobs.map((item) => (
|
||||
<li key={`delete-related-${item.id}`}>
|
||||
<strong>#{item.id}</strong> | {item.title || '-'} | {getStatusLabel(item.status)} {item.isPrimary ? '(aktuell)' : '(Alt-Eintrag)'}
|
||||
<label className="history-delete-preview-checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDeleteRelatedJobSet.has(normalizeJobId(item?.id))}
|
||||
onChange={(event) => toggleDeleteRelatedJobSelection(item.id, Boolean(event.target.checked))}
|
||||
/>
|
||||
<span>
|
||||
<strong>#{item.id}</strong>
|
||||
{' '}| {item.title || '-'}
|
||||
{' '}| {item.roleLabel || 'Job'}
|
||||
{' '}| {getStatusLabel(item.status)}
|
||||
{' '}{item.isPrimary ? '(aktuell)' : '(verknüpft)'}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<small className="history-dv-subtle">Keine verknüpften Alt-Einträge erkannt.</small>
|
||||
)}
|
||||
{relatedDeleteSelectionRequired ? (
|
||||
<small className="history-dv-subtle">Bitte mindestens einen Historien-Eintrag auswählen.</small>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!deleteEntryIsMergeJob ? (
|
||||
@@ -2517,7 +2751,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('movie')}
|
||||
loading={deleteEntryTargetBusy === 'movie'}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -2528,7 +2762,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('raw')}
|
||||
loading={deleteEntryTargetBusy === 'raw'}
|
||||
disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired}
|
||||
disabled={deleteTargetActionsDisabled || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||
/>
|
||||
<Button
|
||||
label={`Eintrag + nur ${deleteEntryOutputShortLabel}`}
|
||||
@@ -2537,7 +2771,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('movie')}
|
||||
loading={deleteEntryTargetBusy === 'movie'}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||
/>
|
||||
<Button
|
||||
label={`Eintrag + RAW & ${deleteEntryOutputShortLabel}`}
|
||||
@@ -2545,10 +2779,21 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
severity="danger"
|
||||
onClick={() => confirmDeleteEntry('both')}
|
||||
loading={deleteEntryTargetBusy === 'both'}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{deleteEntrySupportsFilesOnly ? (
|
||||
<Button
|
||||
label="Nur ausgewählte Dateien löschen"
|
||||
icon="pi pi-folder-minus"
|
||||
severity="help"
|
||||
outlined
|
||||
onClick={() => confirmDeleteFilesOnly('both')}
|
||||
loading={deleteEntryTargetBusy === 'files-both'}
|
||||
disabled={deleteTargetActionsDisabled || movieDeleteSelectionRequired || rawDeleteSelectionRequired || relatedDeleteSelectionRequired}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
label="Nur Eintrag löschen"
|
||||
icon="pi pi-database"
|
||||
@@ -2556,7 +2801,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
outlined
|
||||
onClick={() => confirmDeleteEntry('none')}
|
||||
loading={deleteEntryTargetBusy === 'none'}
|
||||
disabled={deleteTargetActionsDisabled}
|
||||
disabled={deleteTargetActionsDisabled || relatedDeleteSelectionRequired}
|
||||
/>
|
||||
<Button
|
||||
label="Abbrechen"
|
||||
|
||||
@@ -771,6 +771,86 @@ function mediaIndicatorMeta(job) {
|
||||
return { mediaType, src: otherIndicatorIcon, alt: 'Sonstiges Medium', title: 'Sonstiges Medium' };
|
||||
}
|
||||
|
||||
function normalizeDiscWorkflowKind(value) {
|
||||
const raw = String(value || '').trim().toLowerCase();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (['series', 'tv', 'season', 'episode', 'tv_series', 'tv-season', 'tv_season'].includes(raw)) {
|
||||
return 'series';
|
||||
}
|
||||
if (['film', 'movie', 'feature'].includes(raw)) {
|
||||
return 'film';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDiscWorkflowKind({
|
||||
mediaType,
|
||||
selectedMetadata = null,
|
||||
analyzeContext = null,
|
||||
pipelineContext = null,
|
||||
fallbackIsSeries = false
|
||||
} = {}) {
|
||||
if (mediaType !== 'dvd' && mediaType !== 'bluray') {
|
||||
return null;
|
||||
}
|
||||
const selected = selectedMetadata && typeof selectedMetadata === 'object' ? selectedMetadata : {};
|
||||
const analyze = analyzeContext && typeof analyzeContext === 'object' ? analyzeContext : {};
|
||||
const pipelineCtx = pipelineContext && typeof pipelineContext === 'object' ? pipelineContext : {};
|
||||
const workflowCandidates = [
|
||||
selected?.workflowKind,
|
||||
selected?.kind,
|
||||
analyze?.workflowKind,
|
||||
pipelineCtx?.workflowKind
|
||||
];
|
||||
for (const candidate of workflowCandidates) {
|
||||
const normalized = normalizeDiscWorkflowKind(candidate);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
const metadataKindCandidates = [
|
||||
selected?.metadataKind,
|
||||
analyze?.metadataKind
|
||||
];
|
||||
for (const candidate of metadataKindCandidates) {
|
||||
const normalized = normalizeDiscWorkflowKind(candidate);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
const providerCandidates = [
|
||||
selected?.metadataProvider,
|
||||
analyze?.metadataProvider,
|
||||
pipelineCtx?.metadataProvider
|
||||
];
|
||||
for (const candidate of providerCandidates) {
|
||||
const provider = String(candidate || '').trim().toLowerCase();
|
||||
if (!provider) {
|
||||
continue;
|
||||
}
|
||||
if (provider === 'tmdb' || provider === 'themoviedb') {
|
||||
return 'series';
|
||||
}
|
||||
if (provider === 'omdb') {
|
||||
return 'film';
|
||||
}
|
||||
}
|
||||
return fallbackIsSeries ? 'series' : 'film';
|
||||
}
|
||||
|
||||
function getDiscWorkflowBadgeLabel(mediaType, workflowKind) {
|
||||
if (mediaType !== 'dvd' && mediaType !== 'bluray') {
|
||||
return null;
|
||||
}
|
||||
const kind = normalizeDiscWorkflowKind(workflowKind) || 'film';
|
||||
if (mediaType === 'bluray') {
|
||||
return kind === 'series' ? 'Blu-ray-Serie' : 'Blu-ray-Film';
|
||||
}
|
||||
return kind === 'series' ? 'DVD-Serie' : 'DVD-Film';
|
||||
}
|
||||
|
||||
function JobStepChecks({ backupSuccess, encodeSuccess }) {
|
||||
const hasAny = Boolean(backupSuccess || encodeSuccess);
|
||||
if (!hasAny) {
|
||||
@@ -2308,6 +2388,20 @@ export default function RipperPage({
|
||||
|
||||
const handleRetry = async (jobId) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
const retryJobRow = ripperJobs.find((item) => normalizeJobId(item?.id) === normalizedJobId) || null;
|
||||
const title = retryJobRow?.title || retryJobRow?.detected_title || `Job #${normalizedJobId || jobId}`;
|
||||
const mediaType = retryJobRow ? resolveMediaType(retryJobRow) : null;
|
||||
const diskLabel = mediaType === 'cd' ? 'CD' : 'Disk';
|
||||
const confirmed = await confirmModal({
|
||||
header: 'Rip neu starten',
|
||||
message: `Bitte lege die korrekte ${diskLabel} für "${title}" ein.\nSoll der Rip jetzt gestartet werden?`,
|
||||
acceptLabel: 'Rip starten',
|
||||
rejectLabel: 'Abbrechen'
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
||||
try {
|
||||
const response = await api.retryJob(jobId);
|
||||
@@ -3304,7 +3398,6 @@ export default function RipperPage({
|
||||
const pipelineForJob = pipelineByJobId.get(jobId) || pipeline;
|
||||
const jobTitle = job?.title || job?.detected_title || `Job #${jobId}`;
|
||||
const mediaIndicator = mediaIndicatorMeta(job);
|
||||
const seriesTagLabel = mediaIndicator.mediaType === 'bluray' ? 'Blu-ray Serie' : 'DVD Serie';
|
||||
const isResumable = (
|
||||
normalizedStatus === 'READY_TO_ENCODE'
|
||||
|| (mediaIndicator.mediaType === 'audiobook' && normalizedStatus === 'READY_TO_START')
|
||||
@@ -3345,6 +3438,14 @@ export default function RipperPage({
|
||||
const seriesTitleSuffix = mediaIndicator.isSeriesDvd
|
||||
? `${seriesSeasonLabel}${seriesDiscLabel}${seriesContainerLabel}`
|
||||
: '';
|
||||
const discWorkflowKind = resolveDiscWorkflowKind({
|
||||
mediaType: mediaIndicator.mediaType,
|
||||
selectedMetadata,
|
||||
analyzeContext,
|
||||
pipelineContext: pipelineForJob?.context || null,
|
||||
fallbackIsSeries: Boolean(mediaIndicator.isSeriesVideo)
|
||||
});
|
||||
const discWorkflowBadgeLabel = getDiscWorkflowBadgeLabel(mediaIndicator.mediaType, discWorkflowKind);
|
||||
const jobKindRaw = String(job?.job_kind || '').trim().toLowerCase();
|
||||
const isMultipartMovie = Number(job?.is_multipart_movie || 0) === 1
|
||||
|| jobKindRaw === 'multipart_movie_child'
|
||||
@@ -3478,7 +3579,7 @@ export default function RipperPage({
|
||||
) : null}
|
||||
<div className="ripper-job-badges">
|
||||
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
||||
{mediaIndicator.isSeriesDvd ? <Tag value={seriesTagLabel} severity="secondary" /> : null}
|
||||
{discWorkflowBadgeLabel ? <Tag value={discWorkflowBadgeLabel} severity="secondary" /> : null}
|
||||
{isMultipartMerge ? <Tag value="Merge Job" severity="warning" /> : null}
|
||||
{!isMultipartMerge && isMultipartMovie ? <Tag value="Multipart Movie" severity="secondary" /> : null}
|
||||
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||
@@ -3486,7 +3587,6 @@ export default function RipperPage({
|
||||
{normalizedStatus === 'READY_TO_ENCODE'
|
||||
? <Tag value={reviewConfirmed ? 'Review bestätigt' : 'Review offen'} severity={reviewConfirmed ? 'success' : 'warning'} />
|
||||
: null}
|
||||
<JobStepChecks backupSuccess={Boolean(job?.backupSuccess)} encodeSuccess={Boolean(job?.encodeSuccess)} />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -3872,7 +3972,7 @@ export default function RipperPage({
|
||||
</div>
|
||||
<div className="ripper-job-badges">
|
||||
<Tag value={statusBadgeValue} severity={statusBadgeSeverity} />
|
||||
{mediaIndicator.isSeriesDvd ? <Tag value={seriesTagLabel} severity="secondary" /> : null}
|
||||
{discWorkflowBadgeLabel ? <Tag value={discWorkflowBadgeLabel} severity="secondary" /> : null}
|
||||
{isMultipartMerge ? <Tag value="Merge Job" severity="warning" /> : null}
|
||||
{!isMultipartMerge && isMultipartMovie ? <Tag value="Multipart Movie" severity="secondary" /> : null}
|
||||
{isCurrentSession ? <Tag value="Aktive Session" severity="info" /> : null}
|
||||
|
||||
+19
-1
@@ -1,5 +1,5 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { defineConfig } from 'vite';
|
||||
import { createLogger, defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
const appPackage = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
||||
@@ -24,8 +24,26 @@ if (publicOrigin) {
|
||||
};
|
||||
}
|
||||
|
||||
const viteLogger = createLogger();
|
||||
const customLogger = {
|
||||
...viteLogger,
|
||||
error(message, options) {
|
||||
const text = String(message || '');
|
||||
const stack = String(options?.error?.stack || '');
|
||||
const isWsProxyMessage = text.includes('ws proxy socket error');
|
||||
const isEpipe = text.includes('EPIPE') || stack.includes('EPIPE');
|
||||
|
||||
if (isWsProxyMessage && isEpipe) {
|
||||
return;
|
||||
}
|
||||
|
||||
viteLogger.error(message, options);
|
||||
}
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
customLogger,
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(appPackage.version)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user