0.12.0-1 Plugins und diverses

This commit is contained in:
2026-03-19 18:57:05 +00:00
parent 0a9cf6969f
commit 24955a956d
33 changed files with 3978 additions and 479 deletions
+164 -40
View File
@@ -91,6 +91,21 @@ function normalizePositiveInteger(value) {
return Math.trunc(parsed);
}
function normalizeCdText(value) {
return String(value || '')
.normalize('NFC')
.replace(/\s+/g, ' ')
.trim();
}
function isPlaceholderCdTrackTitle(value) {
const normalized = normalizeCdText(value).toLowerCase();
if (!normalized) {
return true;
}
return /^track\s*\d+$/i.test(normalized);
}
function formatDurationSeconds(totalSeconds) {
const parsed = Number(totalSeconds);
if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -352,7 +367,11 @@ function resolveCdDetails(job) {
selectedMetadata?.mbId
|| selectedMetadata?.musicBrainzId
|| selectedMetadata?.musicbrainzId
|| selectedMetadata?.musicbrainz_id
|| selectedMetadata?.music_brainz_id
|| selectedMetadata?.musicbrainz
|| selectedMetadata?.mbid
|| job?.imdb_id
|| ''
).trim() || null;
@@ -568,11 +587,13 @@ export default function JobDetailDialog({
onResumeReady,
onRestartEncode,
onRestartReview,
onRestartCdReview,
onReencode,
onRetry,
onDeleteFiles,
onDeleteEntry,
onDownloadArchive,
onDownloadOutputFolder,
onRemoveFromQueue,
isQueued = false,
omdbAssignBusy = false,
@@ -580,7 +601,8 @@ export default function JobDetailDialog({
actionBusy = false,
reencodeBusy = false,
deleteEntryBusy = false,
downloadBusyTarget = null
downloadBusyTarget = null,
downloadFolderBusyPath = null
}) {
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING', 'CD_RIPPING', 'CD_ENCODING'].includes(job?.status);
@@ -588,8 +610,76 @@ export default function JobDetailDialog({
const mediaType = resolveMediaType(job);
const isCd = mediaType === 'cd';
const isAudiobook = mediaType === 'audiobook';
const canReencode = !!(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true && !running
&& (isCd || mkDone));
const cdRawPathCandidates = [
job?.raw_path,
job?.makemkvInfo?.rawPath,
job?.makemkvInfo?.importContext?.requestedRawPath,
job?.makemkvInfo?.importContext?.originalRawPath
]
.map((value) => String(value || '').trim())
.filter(Boolean);
const hasCdRawInputCandidate = Boolean(job?.rawStatus?.exists) || cdRawPathCandidates.length > 0;
const hasReencodeRawInput = isCd
? hasCdRawInputCandidate
: Boolean(job?.rawStatus?.exists && job?.rawStatus?.isEmpty !== true);
const canReencode = !!(hasReencodeRawInput && !running && (isCd || mkDone));
// For CDs: direct re-encode requires track data from the last run
const cdEncodePlan = isCd && job?.encodePlan && typeof job.encodePlan === 'object' ? job.encodePlan : null;
const cdSelectedMeta = isCd && job?.makemkvInfo?.selectedMetadata && typeof job.makemkvInfo.selectedMetadata === 'object'
? job.makemkvInfo.selectedMetadata
: {};
const cdPlanTracks = isCd && Array.isArray(cdEncodePlan?.tracks)
? cdEncodePlan.tracks
: [];
const cdSelectedTrackPositions = isCd
? (
Array.isArray(cdEncodePlan?.selectedTracks) && cdEncodePlan.selectedTracks.length > 0
? cdEncodePlan.selectedTracks
: cdPlanTracks.filter((track) => track?.selected !== false).map((track) => track?.position)
)
.map((value) => normalizePositiveInteger(value))
.filter(Boolean)
: [];
const cdTrackByPosition = new Map(
cdPlanTracks
.map((track) => {
const position = normalizePositiveInteger(track?.position);
if (!position) {
return null;
}
return [position, track];
})
.filter(Boolean)
);
const cdHasCoreMetadata = Boolean(
normalizeCdText(cdSelectedMeta?.title || cdSelectedMeta?.album || job?.title || job?.detected_title)
&& normalizeCdText(cdSelectedMeta?.artist)
);
const cdHasMeaningfulTrackTitles = cdSelectedTrackPositions.some((position) => {
const track = cdTrackByPosition.get(position);
const title = normalizeCdText(track?.title);
return Boolean(title) && !isPlaceholderCdTrackTitle(title);
});
const cdHasPriorRunEvidence = Boolean(
cdEncodePlan?.directReencodeReady
|| (Array.isArray(job?.handbrakeInfo?.tracks) && job.handbrakeInfo.tracks.length > 0)
|| String(job?.handbrakeInfo?.status || '').trim().toUpperCase() === 'SUCCESS'
);
const cdHasLastRunData = Boolean(
cdEncodePlan
&& Array.isArray(cdEncodePlan.tracks)
&& cdEncodePlan.tracks.length > 0
&& cdEncodePlan.format
);
const canCdDirectEncode = Boolean(
canReencode
&& cdHasLastRunData
&& cdHasCoreMetadata
&& cdHasMeaningfulTrackTitles
&& cdHasPriorRunEvidence
);
const canCdStartReview = !!(hasCdRawInputCandidate && !running
&& isCd && typeof onRestartCdReview === 'function');
const canResumeReady = Boolean(
(String(job?.status || '').trim().toUpperCase() === 'READY_TO_ENCODE' || String(job?.last_state || '').trim().toUpperCase() === 'READY_TO_ENCODE')
&& !running
@@ -634,7 +724,9 @@ export default function JobDetailDialog({
const mediaTypeAlt = mediaTypeLabel;
const statusMeta = statusBadgeMeta(job?.status, queueLocked);
const omdbInfo = job?.omdbInfo && typeof job.omdbInfo === 'object' ? job.omdbInfo : {};
const pluginExecution = resolveJobPluginExecution(job, null);
const pluginExecution = resolveJobPluginExecution(job, null, {
currentStatus: job?.status
});
const configuredSelection = buildConfiguredScriptAndChainSelection(job);
const hasConfiguredSelection = configuredSelection.preScriptIds.length > 0
|| configuredSelection.postScriptIds.length > 0
@@ -647,6 +739,8 @@ export default function JobDetailDialog({
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo);
const canDownloadRaw = Boolean(job?.raw_path && 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);
return (
<Dialog
@@ -867,13 +961,34 @@ export default function JobDetailDialog({
downloadDisabled={!canDownloadRaw}
downloadLoading={downloadBusyTarget === 'raw'}
/>
<PathField
label="Output:"
value={job.output_path}
onDownload={canDownloadOutput ? () => onDownloadArchive?.(job, 'output') : null}
downloadDisabled={!canDownloadOutput}
downloadLoading={downloadBusyTarget === 'output'}
/>
{outputFolders.length > 0 ? (
outputFolders.map((folder, idx) => {
const folderPath = String(folder?.output_path || '').trim();
if (!folderPath) {
return null;
}
const label = outputFolders.length > 1 ? `Output ${idx + 1}:` : 'Output:';
const canDownloadFolder = Boolean(typeof onDownloadOutputFolder === 'function');
return (
<PathField
key={folder.id || folderPath}
label={label}
value={folderPath}
onDownload={canDownloadFolder ? () => onDownloadOutputFolder?.(job, folderPath) : null}
downloadDisabled={!canDownloadFolder}
downloadLoading={downloadFolderBusyPath === folderPath}
/>
);
})
) : (
<PathField
label="Output:"
value={job.output_path}
onDownload={canDownloadOutput ? () => onDownloadArchive?.(job, 'output') : null}
downloadDisabled={!canDownloadOutput}
downloadLoading={downloadBusyTarget === 'output'}
/>
)}
{job.error_message ? (
<div><strong>Fehler:</strong> {job.error_message}</div>
) : null}
@@ -1003,13 +1118,7 @@ export default function JobDetailDialog({
{tracksRaw.map((track, i) => {
const pos = Number(track.position ?? (i + 1));
const label = String(track.title || track.name || `Track ${pos}`).trim();
const artist = String(track.artist || '').trim();
const durationSec = Number(track.durationMs || 0) > 0
? Number(track.durationMs) / 1000
: Number(track.durationSec || 0);
const durLabel = durationSec > 0
? `${Math.floor(durationSec / 60)}:${String(Math.floor(durationSec % 60)).padStart(2, '0')} min`
: '-';
const artist = String(track.artist || '').trim() || String(job?.makemkvInfo?.selectedMetadata?.artist || '').trim();
const selected = selectedSet ? selectedSet.has(pos) : track.selected !== false;
const encodeResult = encodeResultMap.get(pos);
const outcome = !selected
@@ -1018,8 +1127,8 @@ export default function JobDetailDialog({
? (encodeResult.success ? 'success' : 'error')
: (job?.ripSuccessful != null ? (job.ripSuccessful ? 'success' : 'error') : null);
return (
<div key={pos} className="track-item">
<span>#{pos} | {label}{artist ? ` | ${artist}` : ''} | {durLabel}</span>
<div key={pos} className="track-item track-item-inline-status">
<span className="track-item-main">#{pos} {artist ? `${artist} - ` : ''}{label}</span>
<SelectionStateNote selected={selected} outcome={outcome} />
</div>
);
@@ -1070,8 +1179,8 @@ export default function JobDetailDialog({
: stepStatus === 'ERROR' ? 'tone-no'
: '';
return (
<div key={id} className="track-item">
<span>#{id} | {label} | {durLabel}</span>
<div key={id} className={`track-item${isSplitAudiobook ? ' track-item-inline-status' : ''}`}>
<span className={isSplitAudiobook ? 'track-item-main' : undefined}>#{id} | {label} | {durLabel}</span>
{isSplitAudiobook ? (
<SelectionStateNote selected outcome={outcome} />
) : (
@@ -1164,25 +1273,40 @@ export default function JobDetailDialog({
size="small"
onClick={() => onReencode?.(job)}
loading={reencodeBusy}
disabled={!canReencode || typeof onReencode !== 'function'}
disabled={!canCdDirectEncode || typeof onReencode !== 'function'}
/>
<span className="action-desc">Encodiert die vorhandenen WAV-Rohdaten erneut ohne die CD neu zu lesen. Nützlich wenn sich Format oder Metadaten geändert haben.</span>
<span className="action-desc">
{canCdDirectEncode
? 'Encodiert die vorhandenen WAV-Rohdaten mit den zuletzt bestätigten Einstellungen erneut — ohne die CD neu zu lesen.'
: 'Direktes Encoding ist gesperrt, solange kein bestätigter Vorlauf mit vollständigen Metadaten vorhanden ist. Bitte zuerst "Vorprüfung starten".'}
</span>
</div>
<div className="action-item">
<Button
label="Vorprüfung starten"
icon="pi pi-search"
severity="secondary"
outlined
size="small"
onClick={() => onRestartCdReview?.(job)}
loading={actionBusy}
disabled={!canCdStartReview}
/>
<span className="action-desc">Öffnet den Vorprüfungs-Workflow: MusicBrainz-Suche, Trackauswahl, Ausgabeeinstellungen dann Encode aus vorhandenen WAV-Daten starten.</span>
</div>
<div className="action-item">
<Button
label="MusicBrainz neu zuweisen"
icon="pi pi-tag"
severity="secondary"
outlined
size="small"
onClick={() => onAssignCdMetadata?.(job)}
loading={cdMetadataAssignBusy}
disabled={running || typeof onAssignCdMetadata !== 'function'}
/>
<span className="action-desc">Öffnet die MusicBrainz-Suche, um Album-Metadaten (Titel, Interpret, Tracks) neu zuzuweisen.</span>
</div>
{typeof onAssignCdMetadata === 'function' ? (
<div className="action-item">
<Button
label="MusicBrainz neu zuweisen"
icon="pi pi-search"
severity="secondary"
outlined
size="small"
onClick={() => onAssignCdMetadata?.(job)}
loading={cdMetadataAssignBusy}
disabled={running}
/>
<span className="action-desc">Öffnet die MusicBrainz-Suche, um Album-Metadaten (Titel, Interpret, Tracks) neu zuzuweisen.</span>
</div>
) : null}
</div>
) : (
<div className="actions-group">
@@ -1294,7 +1418,7 @@ export default function JobDetailDialog({
size="small"
onClick={() => onDeleteFiles?.(job, 'movie')}
loading={actionBusy}
disabled={!job.outputStatus?.exists || typeof onDeleteFiles !== 'function'}
disabled={!hasAnyOutputFolder || typeof onDeleteFiles !== 'function'}
/>
<span className="action-desc">Löscht {isCd ? 'die fertig gerippten Audiodateien' : isAudiobook ? 'die fertig konvertierte Audiobook-Ausgabe' : 'die fertig encodierte Filmdatei'}.</span>
</div>