0.10.2-10 DVD Title Scan
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.10.2-9",
|
||||
"version": "0.10.2-10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.10.2-9",
|
||||
"version": "0.10.2-10",
|
||||
"dependencies": {
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.10.2-9",
|
||||
"version": "0.10.2-10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -706,7 +706,13 @@ export const api = {
|
||||
return request('/downloads/summary');
|
||||
},
|
||||
downloadPreparedArchive(downloadId) {
|
||||
return download(`/downloads/${encodeURIComponent(downloadId)}/file`);
|
||||
const link = document.createElement('a');
|
||||
link.href = `${API_BASE}/downloads/${encodeURIComponent(downloadId)}/file`;
|
||||
link.download = '';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
return Promise.resolve();
|
||||
},
|
||||
deleteDownload(downloadId) {
|
||||
return request(`/downloads/${encodeURIComponent(downloadId)}`, {
|
||||
|
||||
@@ -547,6 +547,23 @@ export default function JobDetailDialog({
|
||||
: null;
|
||||
const encodePlanUserPresetId = Number(encodePlanUserPreset?.id);
|
||||
const reviewUserPresets = encodePlanUserPreset ? [encodePlanUserPreset] : [];
|
||||
const encodePlanManualTrackSelection = job?.encodePlan?.manualTrackSelection
|
||||
&& typeof job.encodePlan.manualTrackSelection === 'object'
|
||||
? job.encodePlan.manualTrackSelection
|
||||
: null;
|
||||
const reviewTrackSelectionByTitle = encodePlanManualTrackSelection?.titleId != null
|
||||
? {
|
||||
[encodePlanManualTrackSelection.titleId]: {
|
||||
audioTrackIds: Array.isArray(encodePlanManualTrackSelection.audioTrackIds)
|
||||
? encodePlanManualTrackSelection.audioTrackIds
|
||||
: [],
|
||||
subtitleTrackIds: Array.isArray(encodePlanManualTrackSelection.subtitleTrackIds)
|
||||
? encodePlanManualTrackSelection.subtitleTrackIds
|
||||
: []
|
||||
}
|
||||
}
|
||||
: {};
|
||||
const reviewSelectedEncodeTitleId = job?.encodePlan?.encodeInputTitleId ?? null;
|
||||
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');
|
||||
@@ -846,6 +863,8 @@ export default function JobDetailDialog({
|
||||
<MediaInfoReviewPanel
|
||||
review={job.encodePlan}
|
||||
commandOutputPath={job.output_path || null}
|
||||
selectedEncodeTitleId={reviewSelectedEncodeTitleId}
|
||||
trackSelectionByTitle={reviewTrackSelectionByTitle}
|
||||
availableScripts={configuredSelection.scriptCatalog}
|
||||
availableChains={configuredSelection.chainCatalog}
|
||||
preEncodeItems={reviewPreEncodeItems}
|
||||
|
||||
@@ -310,6 +310,7 @@ export default function PipelineStatusCard({
|
||||
onRestartReview,
|
||||
onConfirmReview,
|
||||
onSelectPlaylist,
|
||||
onSelectHandBrakeTitle,
|
||||
onCancel,
|
||||
onRetry,
|
||||
isQueued = false,
|
||||
@@ -331,6 +332,7 @@ export default function PipelineStatusCard({
|
||||
const jobMediaProfile = resolvePipelineMediaProfile(pipeline, mediaInfoReview);
|
||||
const [selectedEncodeTitleId, setSelectedEncodeTitleId] = useState(null);
|
||||
const [selectedPlaylistId, setSelectedPlaylistId] = useState(null);
|
||||
const [selectedHandBrakeTitleIdState, setSelectedHandBrakeTitleIdState] = useState(null);
|
||||
const [trackSelectionByTitle, setTrackSelectionByTitle] = useState({});
|
||||
const [settingsMap, setSettingsMap] = useState({});
|
||||
const [presetDisplayMap, setPresetDisplayMap] = useState({});
|
||||
@@ -527,6 +529,44 @@ export default function PipelineStatusCard({
|
||||
return dedup;
|
||||
}, [playlistAnalysis, pipeline?.context?.playlistCandidates]);
|
||||
|
||||
const handBrakeTitleDecisionRequired = state === 'WAITING_FOR_USER_DECISION'
|
||||
&& Boolean(pipeline?.context?.handBrakeTitleDecisionRequired);
|
||||
|
||||
const waitingHandBrakeTitleRows = useMemo(() => {
|
||||
const raw = Array.isArray(pipeline?.context?.handBrakeTitleCandidates)
|
||||
? pipeline.context.handBrakeTitleCandidates
|
||||
: [];
|
||||
return raw
|
||||
.map((item) => {
|
||||
const titleId = Number(item?.handBrakeTitleId);
|
||||
if (!Number.isFinite(titleId) || titleId <= 0) return null;
|
||||
const durationSeconds = Number(item?.durationSeconds || 0);
|
||||
const minutes = Math.floor(durationSeconds / 60);
|
||||
const seconds = durationSeconds % 60;
|
||||
const durationLabel = `${minutes}:${String(seconds).padStart(2, '0')} min`;
|
||||
return {
|
||||
handBrakeTitleId: Math.trunc(titleId),
|
||||
durationSeconds,
|
||||
durationLabel,
|
||||
durationMinutes: Number(item?.durationMinutes || 0),
|
||||
audioTrackCount: Number(item?.audioTrackCount || 0),
|
||||
subtitleTrackCount: Number(item?.subtitleTrackCount || 0),
|
||||
sizeBytes: Number(item?.sizeBytes || 0)
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.durationSeconds - a.durationSeconds);
|
||||
}, [pipeline?.context?.handBrakeTitleCandidates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!handBrakeTitleDecisionRequired) {
|
||||
setSelectedHandBrakeTitleIdState(null);
|
||||
return;
|
||||
}
|
||||
const best = waitingHandBrakeTitleRows[0]?.handBrakeTitleId || null;
|
||||
setSelectedHandBrakeTitleIdState(best);
|
||||
}, [handBrakeTitleDecisionRequired, retryJobId, waitingHandBrakeTitleRows]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== 'WAITING_FOR_USER_DECISION') {
|
||||
setSelectedPlaylistId(null);
|
||||
@@ -551,7 +591,7 @@ export default function PipelineStatusCard({
|
||||
pipeline?.context?.selectedPlaylist
|
||||
]);
|
||||
|
||||
const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION';
|
||||
const playlistDecisionRequiredBeforeStart = state === 'WAITING_FOR_USER_DECISION' && !handBrakeTitleDecisionRequired;
|
||||
const commandOutputPath = useMemo(
|
||||
() => buildOutputPathPreview(settingsMap, jobMediaProfile, selectedMetadata, retryJobId),
|
||||
[settingsMap, jobMediaProfile, selectedMetadata, retryJobId]
|
||||
@@ -696,6 +736,18 @@ export default function PipelineStatusCard({
|
||||
/>
|
||||
)}
|
||||
|
||||
{handBrakeTitleDecisionRequired && retryJobId && (
|
||||
<Button
|
||||
label="DVD Titel bestätigen"
|
||||
icon="pi pi-check"
|
||||
severity="warning"
|
||||
outlined
|
||||
onClick={() => onSelectHandBrakeTitle?.(retryJobId, selectedHandBrakeTitleIdState)}
|
||||
loading={busy}
|
||||
disabled={!selectedHandBrakeTitleIdState}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state === 'READY_TO_ENCODE' && retryJobId ? (
|
||||
<Button
|
||||
label={isPreRipReview ? 'Backup + Encoding starten' : 'Encoding starten'}
|
||||
@@ -851,6 +903,42 @@ export default function PipelineStatusCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{handBrakeTitleDecisionRequired && !queueLocked ? (
|
||||
<div className="playlist-decision-block">
|
||||
<h3>DVD Titel-Auswahl erforderlich</h3>
|
||||
<small>
|
||||
Mehrere DVD-Titel gefunden, die die Mindestlänge erfüllen. Bitte den gewünschten Titel auswählen.
|
||||
</small>
|
||||
{waitingHandBrakeTitleRows.length > 0 ? (
|
||||
<div className="playlist-decision-list">
|
||||
{waitingHandBrakeTitleRows.map((row) => (
|
||||
<div key={row.handBrakeTitleId} className="playlist-decision-item">
|
||||
<label className="readonly-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedHandBrakeTitleIdState === row.handBrakeTitleId}
|
||||
disabled={queueLocked}
|
||||
onChange={() => {
|
||||
const next = selectedHandBrakeTitleIdState === row.handBrakeTitleId ? null : row.handBrakeTitleId;
|
||||
setSelectedHandBrakeTitleIdState(next);
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{`Titel -t ${row.handBrakeTitleId}`}
|
||||
{row.durationLabel ? ` | ${row.durationLabel}` : ''}
|
||||
{row.audioTrackCount > 0 ? ` | Audio: ${row.audioTrackCount}` : ''}
|
||||
{row.subtitleTrackCount > 0 ? ` | Untertitel: ${row.subtitleTrackCount}` : ''}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<small>Keine Kandidaten verfügbar. Bitte Analyse erneut ausführen.</small>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedMetadata ? (
|
||||
<div className="pipeline-meta-inline">
|
||||
{selectedMetadata.poster ? (
|
||||
|
||||
@@ -1611,6 +1611,24 @@ export default function DashboardPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectHandBrakeTitle = async (jobId, selectedHandBrakeTitleId = null) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
||||
try {
|
||||
await api.selectMetadata({
|
||||
jobId,
|
||||
selectedHandBrakeTitleId: selectedHandBrakeTitleId || null
|
||||
});
|
||||
await refreshPipeline();
|
||||
await loadDashboardJobs();
|
||||
setExpandedJobId(normalizedJobId);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
if (normalizedJobId) setJobBusy(normalizedJobId, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = async (jobId) => {
|
||||
const normalizedJobId = normalizeJobId(jobId);
|
||||
if (normalizedJobId) setJobBusy(normalizedJobId, true);
|
||||
@@ -2095,9 +2113,8 @@ export default function DashboardPage({
|
||||
<div className="page-grid">
|
||||
<Toast ref={toastRef} />
|
||||
|
||||
<div className="dashboard-3col-grid">
|
||||
<div className="dashboard-col dashboard-col-left">
|
||||
<Card title="Hardware Monitoring">
|
||||
{monitoringState.enabled ? (
|
||||
<Card title="Hardware Monitoring">
|
||||
<div className="hardware-monitor-head">
|
||||
<Tag value={`Letztes Update: ${formatUpdatedAt(monitoringState.updatedAt)}`} severity="warning" />
|
||||
</div>
|
||||
@@ -2253,6 +2270,78 @@ export default function DashboardPage({
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="dashboard-3col-grid">
|
||||
<div className="dashboard-col dashboard-col-left">
|
||||
<Card title="Audiobook Upload">
|
||||
<FileUpload
|
||||
ref={audiobookFileUploadRef}
|
||||
accept=".aax"
|
||||
maxFileSize={10737418240}
|
||||
customUpload
|
||||
uploadHandler={() => void handleAudiobookUpload()}
|
||||
disabled={audiobookUploadBusy}
|
||||
onSelect={(e) => setAudiobookUploadFile(e.files[0] || null)}
|
||||
onClear={() => setAudiobookUploadFile(null)}
|
||||
onRemove={() => setAudiobookUploadFile(null)}
|
||||
chooseOptions={{ icon: 'pi pi-images', iconOnly: true, className: 'p-button-rounded p-button-outlined' }}
|
||||
uploadOptions={{ icon: 'pi pi-cloud-upload', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-success' }}
|
||||
cancelOptions={{ icon: 'pi pi-times', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-danger' }}
|
||||
itemTemplate={(file, options) => (
|
||||
<div className="aax-file-item">
|
||||
<i className="pi pi-headphones aax-file-icon" />
|
||||
<div className="aax-file-info">
|
||||
<span className="aax-file-name" title={file.name}>{file.name}</span>
|
||||
<small>{options.formatSize}</small>
|
||||
</div>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
rounded
|
||||
severity="danger"
|
||||
size="small"
|
||||
onClick={options.onRemove}
|
||||
disabled={audiobookUploadBusy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
emptyTemplate={() => (
|
||||
<div className="aax-drop-zone">
|
||||
<i className="pi pi-headphones aax-drop-icon" />
|
||||
<p>AAX-Datei hier ablegen</p>
|
||||
<small>oder oben „Auswählen" klicken</small>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{audiobookUploadStatusVisible ? (
|
||||
<div className={`audiobook-upload-status tone-${audiobookUploadStatusTone}`}>
|
||||
<div className="audiobook-upload-status-head">
|
||||
<strong>{audiobookUploadStatusLabel}</strong>
|
||||
<Tag value={audiobookUploadStatusLabel} severity={audiobookUploadStatusTone} />
|
||||
</div>
|
||||
{audiobookUpload?.statusText ? <small>{audiobookUpload.statusText}</small> : null}
|
||||
{audiobookUploadFileName ? (
|
||||
<small className="audiobook-upload-file" title={audiobookUploadFileName}>
|
||||
Datei: {audiobookUploadFileName}
|
||||
</small>
|
||||
) : null}
|
||||
<div
|
||||
className="dashboard-job-row-progress audiobook-upload-progress"
|
||||
aria-label={`Audiobook Upload ${Math.round(audiobookUploadProgress)} Prozent`}
|
||||
>
|
||||
<ProgressBar value={audiobookUploadProgress} showValue={false} />
|
||||
<small>
|
||||
{audiobookUploadPhase === 'processing'
|
||||
? '100% | Upload fertig, Job wird vorbereitet ...'
|
||||
: audiobookUploadTotalBytes > 0
|
||||
? `${Math.round(audiobookUploadProgress)}% | ${formatBytes(audiobookUploadLoadedBytes)} / ${formatBytes(audiobookUploadTotalBytes)}`
|
||||
: `${Math.round(audiobookUploadProgress)}%`}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card title="Disk-Information">
|
||||
<div className="actions-row">
|
||||
@@ -2452,6 +2541,7 @@ export default function DashboardPage({
|
||||
onRestartReview={handleRestartReviewFromRaw}
|
||||
onConfirmReview={handleConfirmReview}
|
||||
onSelectPlaylist={handleSelectPlaylist}
|
||||
onSelectHandBrakeTitle={handleSelectHandBrakeTitle}
|
||||
onCancel={handleCancel}
|
||||
onRetry={handleRetry}
|
||||
onRemoveFromQueue={handleRemoveQueuedJob}
|
||||
@@ -2525,75 +2615,6 @@ export default function DashboardPage({
|
||||
</div>
|
||||
|
||||
<div className="dashboard-col dashboard-col-right">
|
||||
<Card title="Audiobook Upload">
|
||||
<FileUpload
|
||||
ref={audiobookFileUploadRef}
|
||||
accept=".aax"
|
||||
maxFileSize={10737418240}
|
||||
customUpload
|
||||
uploadHandler={() => void handleAudiobookUpload()}
|
||||
disabled={audiobookUploadBusy}
|
||||
onSelect={(e) => setAudiobookUploadFile(e.files[0] || null)}
|
||||
onClear={() => setAudiobookUploadFile(null)}
|
||||
onRemove={() => setAudiobookUploadFile(null)}
|
||||
chooseOptions={{ icon: 'pi pi-images', iconOnly: true, className: 'p-button-rounded p-button-outlined' }}
|
||||
uploadOptions={{ icon: 'pi pi-cloud-upload', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-success' }}
|
||||
cancelOptions={{ icon: 'pi pi-times', iconOnly: true, className: 'p-button-rounded p-button-outlined p-button-danger' }}
|
||||
itemTemplate={(file, options) => (
|
||||
<div className="aax-file-item">
|
||||
<i className="pi pi-headphones aax-file-icon" />
|
||||
<div className="aax-file-info">
|
||||
<span className="aax-file-name" title={file.name}>{file.name}</span>
|
||||
<small>{options.formatSize}</small>
|
||||
</div>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
text
|
||||
rounded
|
||||
severity="danger"
|
||||
size="small"
|
||||
onClick={options.onRemove}
|
||||
disabled={audiobookUploadBusy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
emptyTemplate={() => (
|
||||
<div className="aax-drop-zone">
|
||||
<i className="pi pi-headphones aax-drop-icon" />
|
||||
<p>AAX-Datei hier ablegen</p>
|
||||
<small>oder oben „Auswählen" klicken</small>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{audiobookUploadStatusVisible ? (
|
||||
<div className={`audiobook-upload-status tone-${audiobookUploadStatusTone}`}>
|
||||
<div className="audiobook-upload-status-head">
|
||||
<strong>{audiobookUploadStatusLabel}</strong>
|
||||
<Tag value={audiobookUploadStatusLabel} severity={audiobookUploadStatusTone} />
|
||||
</div>
|
||||
{audiobookUpload?.statusText ? <small>{audiobookUpload.statusText}</small> : null}
|
||||
{audiobookUploadFileName ? (
|
||||
<small className="audiobook-upload-file" title={audiobookUploadFileName}>
|
||||
Datei: {audiobookUploadFileName}
|
||||
</small>
|
||||
) : null}
|
||||
<div
|
||||
className="dashboard-job-row-progress audiobook-upload-progress"
|
||||
aria-label={`Audiobook Upload ${Math.round(audiobookUploadProgress)} Prozent`}
|
||||
>
|
||||
<ProgressBar value={audiobookUploadProgress} showValue={false} />
|
||||
<small>
|
||||
{audiobookUploadPhase === 'processing'
|
||||
? '100% | Upload fertig, Job wird vorbereitet ...'
|
||||
: audiobookUploadTotalBytes > 0
|
||||
? `${Math.round(audiobookUploadProgress)}% | ${formatBytes(audiobookUploadLoadedBytes)} / ${formatBytes(audiobookUploadTotalBytes)}`
|
||||
: `${Math.round(audiobookUploadProgress)}%`}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card title="Job Queue" subTitle="Elemente können per Drag-and-Drop umsortiert werden.">
|
||||
<div className="pipeline-queue-meta">
|
||||
<Tag value={`Film max.: ${queueState?.maxParallelJobs || 1}`} severity="info" />
|
||||
|
||||
@@ -1056,11 +1056,12 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
void openDetail(row);
|
||||
}}
|
||||
>
|
||||
<div className="history-dv-grid-status-overlay">
|
||||
{renderStatusTag(row)}
|
||||
</div>
|
||||
|
||||
<div className="history-dv-grid-poster-wrap">
|
||||
{renderPoster(row, 'history-dv-poster-grid')}
|
||||
<div className="history-dv-grid-status-overlay">
|
||||
{renderStatusTag(row)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="history-dv-grid-main">
|
||||
|
||||
@@ -2258,6 +2258,7 @@ body {
|
||||
}
|
||||
|
||||
.history-dv-item-grid {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.65rem;
|
||||
@@ -2266,22 +2267,19 @@ body {
|
||||
}
|
||||
|
||||
.history-dv-grid-poster-wrap {
|
||||
position: relative;
|
||||
width: min(120px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.history-dv-grid-status-overlay {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
left: 0;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.history-dv-grid-status-overlay .p-tag {
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.25);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.history-dv-grid-main {
|
||||
|
||||
Reference in New Issue
Block a user