1.0.0-rc4 rc4

This commit is contained in:
2026-06-08 12:15:36 +02:00
parent 1502d4d9d0
commit 6d3c6fed0f
23 changed files with 968 additions and 667 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ripster-frontend",
"version": "1.0.0-rc3",
"version": "1.0.0-rc4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ripster-frontend",
"version": "1.0.0-rc3",
"version": "1.0.0-rc4",
"dependencies": {
"chart.js": "^4.5.1",
"primeicons": "^7.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ripster-frontend",
"version": "1.0.0-rc3",
"version": "1.0.0-rc4",
"private": true,
"type": "module",
"scripts": {
+23 -2
View File
@@ -65,6 +65,14 @@ function isTerminalStage(value) {
return normalized === 'FINISHED' || normalized === 'ERROR' || normalized === 'CANCELLED';
}
function isInteractiveReviewStage(value) {
const normalized = normalizeStage(value);
return normalized === 'METADATA_LOOKUP'
|| normalized === 'METADATA_SELECTION'
|| normalized === 'WAITING_FOR_USER_DECISION'
|| normalized === 'READY_TO_ENCODE';
}
function parseTimestamp(value) {
if (!value) {
return 0;
@@ -209,8 +217,9 @@ function App() {
prevCdDrivesRef.current = current;
}, [pipeline?.cdDrives]);
// Trigger a jobs refresh when a tracked job transitions into a terminal
// state through PIPELINE_PROGRESS, so Ripper/History update immediately.
// Trigger a jobs refresh when a tracked job transitions into a terminal or
// interactive review state through PIPELINE_PROGRESS, so Ripper/History
// update immediately without a manual page reload.
useEffect(() => {
const current = pipeline?.jobProgress && typeof pipeline.jobProgress === 'object'
? pipeline.jobProgress
@@ -233,6 +242,13 @@ function App() {
) {
shouldRefresh = true;
}
if (
currentState
&& isInteractiveReviewStage(currentState)
&& currentState !== previousState
) {
shouldRefresh = true;
}
}
prevJobProgressStatesRef.current = nextStates;
@@ -394,6 +410,11 @@ function App() {
if (message.type === 'PIPELINE_STATE_CHANGED') {
setPipeline((prev) => mergePipelineStatePreservingNewestQueue(prev, message.payload));
const nextState = normalizeStage(message?.payload?.state);
if (isTerminalStage(nextState) || isInteractiveReviewStage(nextState)) {
setRipperJobsRefreshToken((token) => token + 1);
setHistoryJobsRefreshToken((token) => token + 1);
}
}
if (message.type === 'PIPELINE_PROGRESS') {
+7
View File
@@ -388,6 +388,13 @@ export const api = {
forceRefresh: options.forceRefresh
});
},
async checkMakeMKVBetaKey() {
const result = await request('/settings/makemkv/beta-key/check', {
method: 'POST'
});
afterMutationInvalidate(['/settings/makemkv/beta-key']);
return result;
},
async applyMakeMKVBetaKey(payload = {}) {
const result = await request('/settings/makemkv/beta-key/apply', {
method: 'POST',
+148 -43
View File
@@ -665,63 +665,146 @@ function isReadonlySetting(setting) {
}
}
function MakeMKVBetaKeyHint({ onApply, disabled = false }) {
function MakeMKVBetaKeyHint({ disabled = false, onNotify = null, onSettingApplied = null }) {
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [betaKey, setBetaKey] = useState('');
const [validUntil, setValidUntil] = useState('');
const [fetchedAt, setFetchedAt] = useState('');
const [appliedKey, setAppliedKey] = useState('');
const [error, setError] = useState('');
const [warning, setWarning] = useState('');
useEffect(() => {
let active = true;
const loadCachedState = async () => {
setLoading(true);
setError('');
setWarning('');
setValidUntil('');
try {
const response = await api.getMakeMKVBetaKey();
setBetaKey(String(response?.betaKey || '').trim());
setValidUntil(String(response?.validUntil || '').trim());
setFetchedAt(String(response?.fetchedAt || '').trim());
setAppliedKey(String(response?.appliedKey || '').trim());
if (response?.stale) {
setWarning('Zwischengespeicherter Betakey ist veraltet. Bitte bei Bedarf neu prüfen.');
} else if (!response?.betaKey) {
setWarning('Noch kein geprüfter Betakey im Cache. Prüfung startet nur per Button.');
} else if (response?.appliedMatchesCache) {
setWarning('Geprüfter Betakey ist bereits übernommen.');
} else {
setWarning('');
}
} catch (loadError) {
setBetaKey('');
setValidUntil('');
setFetchedAt('');
setAppliedKey('');
setError(loadError?.message || 'Betakey konnte nicht geladen werden.');
} finally {
setLoading(false);
}
};
api.getMakeMKVBetaKey()
.then((response) => {
if (!active) {
return;
}
setBetaKey(String(response?.betaKey || '').trim());
setValidUntil(String(response?.validUntil || '').trim());
setWarning(String(response?.warning || '').trim());
})
.catch((loadError) => {
if (!active) {
return;
}
setBetaKey('');
setValidUntil('');
setError(loadError?.message || 'Betakey konnte nicht geladen werden.');
})
.finally(() => {
if (active) {
setLoading(false);
}
});
return () => {
active = false;
};
useEffect(() => {
void loadCachedState();
}, []);
const handleCheck = async () => {
setChecking(true);
setError('');
setWarning('');
try {
const response = await api.checkMakeMKVBetaKey();
const nextKey = String(response?.betaKey || '').trim();
setBetaKey(nextKey);
setValidUntil(String(response?.validUntil || '').trim());
setFetchedAt(String(response?.fetchedAt || '').trim());
setWarning('');
if (typeof onNotify === 'function') {
onNotify({
severity: 'success',
summary: 'MakeMKV Betakey',
detail: nextKey ? 'Betakey erfolgreich geprüft.' : 'Betakey-Prüfung abgeschlossen.'
});
}
} catch (checkError) {
setError(checkError?.message || 'Betakey konnte nicht geprüft werden.');
if (typeof onNotify === 'function') {
onNotify({
severity: 'error',
summary: 'MakeMKV Betakey',
detail: checkError?.message || 'Betakey konnte nicht geprüft werden.'
});
}
} finally {
setChecking(false);
}
};
const handleApply = async () => {
if (!betaKey) {
return;
}
setApplying(true);
setError('');
try {
const response = await api.applyMakeMKVBetaKey({ betaKey });
const nextAppliedKey = String(response?.betaKey || betaKey).trim();
setAppliedKey(nextAppliedKey);
setWarning('Geprüfter Betakey ist bereits übernommen.');
if (typeof onSettingApplied === 'function') {
onSettingApplied({
key: 'makemkv_registration_key',
value: nextAppliedKey
});
}
if (typeof onNotify === 'function') {
onNotify({
severity: 'success',
summary: 'MakeMKV Betakey',
detail: 'Betakey wurde übernommen und gespeichert.'
});
}
} catch (applyError) {
setError(applyError?.message || 'Betakey konnte nicht übernommen werden.');
if (typeof onNotify === 'function') {
onNotify({
severity: 'error',
summary: 'MakeMKV Betakey',
detail: applyError?.message || 'Betakey konnte nicht übernommen werden.'
});
}
} finally {
setApplying(false);
}
};
return (
<div className="makemkv-beta-key-box">
<div className="makemkv-beta-key-head">
<span className="makemkv-beta-key-title">Betakey</span>
<Button
type="button"
size="small"
text
label="übernehmen"
disabled={disabled || loading || !betaKey}
onClick={() => onApply?.(betaKey)}
/>
<div className="settings-inline-actions">
<Button
type="button"
size="small"
text
label="prüfen"
disabled={disabled || loading || checking || applying}
onClick={() => void handleCheck()}
/>
<Button
type="button"
size="small"
text
label="übernehmen"
disabled={disabled || loading || checking || applying || !betaKey || betaKey === appliedKey}
onClick={() => void handleApply()}
/>
</div>
</div>
{loading ? (
<small className="setting-description">Aktueller Betakey wird geladen</small>
<small className="setting-description">Zwischengespeicherter Betakey wird geladen</small>
) : null}
{!loading && error ? (
<small className="error-text">{error}</small>
@@ -729,6 +812,9 @@ function MakeMKVBetaKeyHint({ onApply, disabled = false }) {
{!loading && !error && warning ? (
<small className="setting-description">{warning}</small>
) : null}
{!loading && !error && fetchedAt ? (
<small className="setting-description">Zuletzt geprüft: {new Date(fetchedAt).toLocaleString('de-DE')}</small>
) : null}
{!loading && !error && validUntil ? (
<small className="setting-description">Gültig bis: {new Date(validUntil).toLocaleString('de-DE')}</small>
) : null}
@@ -786,7 +872,9 @@ function SettingField({
ownerDirty,
onChange,
variant = 'default',
disabled = false
disabled = false,
onNotify = null,
onSettingApplied = null
}) {
const ownerKey = ownerSetting?.key;
const pathHasValue = Boolean(String(value ?? '').trim());
@@ -905,7 +993,8 @@ function SettingField({
{normalizeSettingKey(setting?.key) === 'makemkv_registration_key' ? (
<MakeMKVBetaKeyHint
disabled={isDisabled}
onApply={(nextKey) => onChange?.(setting.key, nextKey)}
onNotify={onNotify}
onSettingApplied={onSettingApplied}
/>
) : null}
{error ? (
@@ -946,7 +1035,16 @@ function SettingField({
}
function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effectivePaths }) {
function PathCategoryTab({
settings,
values,
errors,
dirtyKeys,
onChange,
effectivePaths,
onNotify,
onSettingApplied
}) {
const list = Array.isArray(settings) ? settings : [];
const settingsByKey = new Map(list.map((s) => [s.key, s]));
@@ -1153,6 +1251,8 @@ function PathCategoryTab({ settings, values, errors, dirtyKeys, onChange, effect
ownerError={ownerError}
ownerDirty={ownerDirty}
onChange={onChange}
onNotify={onNotify}
onSettingApplied={onSettingApplied}
/>
);
})}
@@ -1173,7 +1273,8 @@ export default function DynamicSettingsForm({
onChange,
effectivePaths,
activationBytes = [],
onNotify
onNotify,
onSettingApplied
}) {
const safeCategories = Array.isArray(categories) ? categories : [];
const expertModeEnabled = toBoolean(values?.[EXPERT_MODE_SETTING_KEY]);
@@ -1258,6 +1359,8 @@ export default function DynamicSettingsForm({
dirtyKeys={dirtyKeys}
onChange={onChange}
effectivePaths={effectivePaths}
onNotify={onNotify}
onSettingApplied={onSettingApplied}
/>
) : (() => {
const sections = buildSectionsForCategory(category?.category, category?.settings || []);
@@ -1339,6 +1442,8 @@ export default function DynamicSettingsForm({
onChange={onChangeFn}
variant={variant}
disabled={disabled}
onNotify={onNotify}
onSettingApplied={onSettingApplied}
/>
);
};
@@ -1588,6 +1588,7 @@ export default function MediaInfoReviewPanel({
commandOutputPathByTitle = {},
selectedEncodeTitleId = null,
selectedEncodeTitleIds = [],
displayOnlySelectedTitle = false,
titleSelectionTouched = false,
allowTitleSelection = false,
compactTitleSelection = false,
@@ -1670,6 +1671,9 @@ export default function MediaInfoReviewPanel({
|| selectedTitleIds[0]
|| null;
const selectedTitleIdSet = new Set(selectedTitleIds.map((id) => String(id)));
const visibleDisplayTitles = displayOnlySelectedTitle && currentSelectedId !== null
? displayTitles.filter((title) => normalizeTitleId(title?.id) === currentSelectedId)
: displayTitles;
const normalizedComparisonReviews = (Array.isArray(comparisonReviewItems) ? comparisonReviewItems : [])
.map((item) => {
const reviewCandidate = item?.review && typeof item.review === 'object'
@@ -2707,12 +2711,12 @@ export default function MediaInfoReviewPanel({
</div>
) : null}
<div className="media-title-list">
{displayTitles.length === 0 ? (
{visibleDisplayTitles.length === 0 ? (
<p>Keine Titel analysiert.</p>
) : displayTitles.map((title) => {
) : visibleDisplayTitles.map((title) => {
const normalizedTitleId = normalizeTitleId(title.id);
const displayTitleId = normalizeTitleId(title?.handBrakeTitleId) || normalizedTitleId;
const titleChecked = allowTitleSelection
const titleChecked = (allowTitleSelection || displayOnlySelectedTitle)
? (selectedTitleIdSet.size > 0
? selectedTitleIdSet.has(String(normalizedTitleId))
: Boolean(title.selectedForEncode))
@@ -2799,8 +2803,8 @@ export default function MediaInfoReviewPanel({
);
const allowTrackSelectionForTitle = Boolean(
allowTrackSelection
&& allowTitleSelection
&& titleChecked
&& (allowTitleSelection || displayOnlySelectedTitle)
);
return (
@@ -2813,6 +2817,15 @@ export default function MediaInfoReviewPanel({
if (!allowTitleSelection) {
return;
}
if (displayOnlySelectedTitle) {
if (typeof onToggleEncodeTitle === 'function') {
onToggleEncodeTitle(normalizedTitleId, true);
}
if (typeof onSelectEncodeTitle === 'function') {
onSelectEncodeTitle(normalizedTitleId);
}
return;
}
const checked = Boolean(event?.target?.checked);
if (typeof onToggleEncodeTitle === 'function') {
onToggleEncodeTitle(normalizedTitleId, checked);
+81 -2
View File
@@ -1635,17 +1635,29 @@ export default function PipelineStatusCard({
const reviewMode = String(mediaInfoReview?.mode || '').trim().toLowerCase();
const isPreRipReview = reviewMode === 'pre_rip' || Boolean(mediaInfoReview?.preRip);
const jobMediaProfile = resolvePipelineMediaProfile(pipeline, mediaInfoReview);
const hasReviewRecoverableRaw = Boolean(
!['converter', 'audiobook'].includes(String(jobMediaProfile || '').trim().toLowerCase())
&& ripCompleted
&& (
jobRow?.raw_path
|| pipeline?.context?.rawPath
|| jobRow?.makemkvInfo?.rawPath
)
);
const retryRipRecoveryRequired = Boolean(
!running
&& retryJobId
&& typeof onRetry === 'function'
&& !queueLocked
&& !isOrphanCancelled
&& !['converter', 'audiobook'].includes(String(jobMediaProfile || '').trim().toLowerCase())
&& !hasReviewRecoverableRaw
&& ['ERROR', 'CANCELLED'].includes(jobStatusUpper)
&& (
['RIPPING', 'CD_RIPPING'].includes(jobLastStateUpper)
|| jobErrorMessageLower.includes('server-neustart')
|| (
jobErrorMessageLower.includes('server-neustart')
&& !hasReviewRecoverableRaw
)
|| jobErrorMessageLower.includes('rip ist unvollständig')
|| jobErrorMessageLower.includes('rip-validierung fehlgeschlagen')
)
@@ -2374,6 +2386,15 @@ export default function PipelineStatusCard({
);
const allowTrackSelectionInReview = state === 'READY_TO_ENCODE' && allowReviewSelectionEdit;
const allowEncodeItemSelectionInReview = state === 'READY_TO_ENCODE' && allowReviewSelectionEdit;
const alternativeTitleSelection = mediaInfoReview?.alternativeTitleSelection
&& typeof mediaInfoReview.alternativeTitleSelection === 'object'
? mediaInfoReview.alternativeTitleSelection
: null;
const hasAlternativeTitleSelection = Boolean(
alternativeTitleSelection?.enabled
&& Array.isArray(mediaInfoReview?.titles)
&& mediaInfoReview.titles.length > 1
);
// Filter user presets by job media profile ('all' presets always shown)
const filteredUserPresets = (Array.isArray(userPresets) ? userPresets : []).filter((p) => {
@@ -2550,6 +2571,31 @@ export default function PipelineStatusCard({
}
return dedup;
}, [playlistAnalysis, pipeline?.context?.playlistCandidates]);
const alternativeTitleOptions = useMemo(() => {
if (!hasAlternativeTitleSelection) {
return [];
}
const titles = Array.isArray(mediaInfoReview?.titles) ? mediaInfoReview.titles : [];
return titles
.map((title) => {
const titleId = normalizeTitleId(title?.id);
if (!titleId) {
return null;
}
const playlistFile = String(title?.playlistFile || '').trim();
const durationLabel = formatDurationClock(title?.durationSeconds) || null;
const parts = [
playlistFile || `Titel #${titleId}`,
durationLabel
].filter(Boolean);
return {
value: titleId,
label: parts.join(' | ')
};
})
.filter(Boolean)
.sort((left, right) => Number(left.value) - Number(right.value));
}, [hasAlternativeTitleSelection, mediaInfoReview?.titles]);
const handBrakeTitleDecisionRequired = state === 'WAITING_FOR_USER_DECISION'
&& Boolean(pipeline?.context?.handBrakeTitleDecisionRequired);
@@ -4316,6 +4362,38 @@ export default function PipelineStatusCard({
commandOutputPathByTitle={commandOutputPathByTitle}
topContent={(
<>
{hasAlternativeTitleSelection ? (
<div className="playlist-decision-block">
<h3>Alternative Titel gefunden</h3>
<small>
Es wurden weitere gleichlange oder längere Titel erkannt. Das kann auf alternative Schnittfassungen
oder Playlist-Varianten derselben Disc hindeuten. Bitte waehle hier den Titel aus, der fuer Tracks,
Untertitel, Infos und den HandBrakeCLI-Command verwendet werden soll.
</small>
<div style={{ marginTop: '0.75rem' }}>
<label style={{ display: 'block', marginBottom: '0.35rem', fontWeight: 600 }}>
Zu verwendender Titel
</label>
<Dropdown
value={normalizeTitleId(selectedEncodeTitleId) || normalizeTitleId(mediaInfoReview?.encodeInputTitleId) || null}
options={alternativeTitleOptions}
optionLabel="label"
optionValue="value"
onChange={(e) => {
const nextTitleId = normalizeTitleId(e?.value);
if (!nextTitleId) {
return;
}
setSelectedEncodeTitleId(nextTitleId);
setSelectedEncodeTitleIds([nextTitleId]);
setTitleSelectionTouched(true);
}}
style={{ width: '100%' }}
disabled={!allowReviewSelectionEdit || alternativeTitleOptions.length <= 1}
/>
</div>
</div>
) : null}
{multipartSettingsLocked ? (
<div className="playlist-decision-block">
<h3>Multipart-Lock aktiv</h3>
@@ -4354,6 +4432,7 @@ export default function PipelineStatusCard({
)}
selectedEncodeTitleId={normalizeTitleId(selectedEncodeTitleId)}
selectedEncodeTitleIds={normalizeTitleIdList(selectedEncodeTitleIds)}
displayOnlySelectedTitle={hasAlternativeTitleSelection}
titleSelectionTouched={titleSelectionTouched}
allowTitleSelection={allowTitleSelectionInReview}
compactTitleSelection={isDiscTitleSelectionRequired}
+26 -2
View File
@@ -9,6 +9,21 @@ function buildWsUrl() {
return `${protocol}//${window.location.host}/ws`;
}
function normalizeStage(value) {
return String(value || '').trim().toUpperCase();
}
function isRunningStage(value) {
const normalized = normalizeStage(value);
return normalized === 'ANALYZING'
|| normalized === 'RIPPING'
|| normalized === 'MEDIAINFO_CHECK'
|| normalized === 'ENCODING'
|| normalized === 'CD_ANALYZING'
|| normalized === 'CD_RIPPING'
|| normalized === 'CD_ENCODING';
}
export function useWebSocket({ onMessage }) {
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
@@ -69,10 +84,19 @@ export function useWebSocket({ onMessage }) {
if (message?.type === 'PIPELINE_PROGRESS') {
const progressJobId = message?.payload?.activeJobId;
const jobKey = progressJobId == null ? '__global__' : String(progressJobId);
latestProgressMessagesByJob.set(jobKey, message);
scheduleProgressFlush();
const progressStage = normalizeStage(message?.payload?.state);
if (isRunningStage(progressStage)) {
latestProgressMessagesByJob.set(jobKey, message);
scheduleProgressFlush();
} else {
latestProgressMessagesByJob.delete(jobKey);
clearProgressFlush();
flushProgressMessage();
onMessageRef.current?.(message);
}
return;
}
flushProgressMessage();
onMessageRef.current?.(message);
} catch (error) {
// ignore invalid json
+13 -3
View File
@@ -1614,7 +1614,7 @@ export default function RipperPage({
// Detects when a background job reaches an interactive review/selection state via
// PIPELINE_PROGRESS (e.g. METADATA_LOOKUP, METADATA_SELECTION, READY_TO_ENCODE, WAITING_FOR_USER_DECISION),
// triggering a ripper jobs reload.
const jobProgressInteractiveKey = useMemo(() => Object.entries(pipeline?.jobProgress || {})
const jobProgressInteractiveSignature = useMemo(() => Object.entries(pipeline?.jobProgress || {})
.filter(([, v]) => {
const s = String(v?.state || '').toUpperCase();
return s === 'METADATA_LOOKUP'
@@ -1622,7 +1622,7 @@ export default function RipperPage({
|| s === 'READY_TO_ENCODE'
|| s === 'WAITING_FOR_USER_DECISION';
})
.map(([id]) => id)
.map(([id, value]) => `${id}:${String(value?.state || '').trim().toUpperCase()}`)
.sort()
.join(','),
[pipeline?.jobProgress]);
@@ -1918,7 +1918,17 @@ export default function RipperPage({
useEffect(() => {
void loadRipperJobs();
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, cdDriveLifecycleKey, jobsRefreshToken, jobProgressInteractiveKey]);
}, [pipeline?.state, pipeline?.activeJobId, pipeline?.context?.jobId, cdDriveLifecycleKey, jobsRefreshToken, jobProgressInteractiveSignature]);
useEffect(() => {
const normalizedPipelineState = String(pipeline?.state || '').trim().toUpperCase();
const isReviewState = normalizedPipelineState === 'READY_TO_ENCODE'
|| normalizedPipelineState === 'WAITING_FOR_USER_DECISION';
if (!isReviewState || !currentPipelineJobId) {
return;
}
setExpandedJobId(currentPipelineJobId);
}, [pipeline?.state, currentPipelineJobId]);
useEffect(() => {
api.getDetectedDrives()
+9
View File
@@ -1476,6 +1476,15 @@ export default function SettingsPage() {
effectivePaths={effectivePaths}
activationBytes={activationBytes}
onNotify={(msg) => toastRef.current?.show(msg)}
onSettingApplied={(setting) => {
const key = String(setting?.key || '').trim();
if (!key) {
return;
}
setDraftValues((prev) => ({ ...prev, [key]: setting?.value }));
setInitialValues((prev) => ({ ...prev, [key]: setting?.value }));
setErrors((prev) => ({ ...prev, [key]: null }));
}}
/>
)}
</TabPanel>
+7
View File
@@ -2228,6 +2228,13 @@ body {
gap: 0.5rem;
}
.settings-inline-actions {
display: inline-flex;
align-items: center;
gap: 0.25rem;
flex-wrap: wrap;
}
.makemkv-beta-key-title {
font-size: 0.8rem;
font-weight: 700;