0.10.2 Download Files
This commit is contained in:
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.10.1-1",
|
||||
"version": "0.10.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.10.1-1",
|
||||
"version": "0.10.2",
|
||||
"dependencies": {
|
||||
"primeicons": "^7.0.0",
|
||||
"primereact": "^10.9.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ripster-frontend",
|
||||
"version": "0.10.1-1",
|
||||
"version": "0.10.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -111,6 +111,69 @@ async function request(path, options = {}) {
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function resolveFilenameFromDisposition(contentDisposition, fallback = 'download.zip') {
|
||||
const raw = String(contentDisposition || '').trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const encodedMatch = raw.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
||||
if (encodedMatch?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(encodedMatch[1]);
|
||||
} catch (_error) {
|
||||
// ignore malformed content-disposition values
|
||||
}
|
||||
}
|
||||
|
||||
const plainMatch = raw.match(/filename\s*=\s*"([^"]+)"/i) || raw.match(/filename\s*=\s*([^;]+)/i);
|
||||
if (plainMatch?.[1]) {
|
||||
return String(plainMatch[1]).trim();
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function download(path, options = {}) {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
headers: options?.headers || {},
|
||||
method: options?.method || 'GET'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorPayload = null;
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
errorPayload = await response.json();
|
||||
message = errorPayload?.error?.message || message;
|
||||
} catch (_error) {
|
||||
// ignore parse errors
|
||||
}
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
error.details = errorPayload?.error?.details || null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
const fallbackFilename = String(options?.filename || 'download.zip').trim() || 'download.zip';
|
||||
const filename = resolveFilenameFromDisposition(response.headers.get('content-disposition'), fallbackFilename);
|
||||
|
||||
link.href = objectUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
|
||||
return {
|
||||
filename,
|
||||
sizeBytes: blob.size
|
||||
};
|
||||
}
|
||||
|
||||
async function requestWithXhr(path, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
@@ -630,6 +693,11 @@ export const api = {
|
||||
afterMutationInvalidate(['/history', '/pipeline/queue']);
|
||||
return result;
|
||||
},
|
||||
downloadJobArchive(jobId, target = 'raw') {
|
||||
const query = new URLSearchParams();
|
||||
query.set('target', String(target || 'raw').trim());
|
||||
return download(`/history/${jobId}/download?${query.toString()}`);
|
||||
},
|
||||
getJob(jobId, options = {}) {
|
||||
const query = new URLSearchParams();
|
||||
const includeLiveLog = Boolean(options.includeLiveLog);
|
||||
|
||||
@@ -416,6 +416,42 @@ function BoolState({ value }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PathField({
|
||||
label,
|
||||
value,
|
||||
onDownload = null,
|
||||
downloadDisabled = false,
|
||||
downloadLoading = false
|
||||
}) {
|
||||
const hasValue = Boolean(String(value || '').trim());
|
||||
const canDownload = hasValue && typeof onDownload === 'function' && !downloadDisabled;
|
||||
|
||||
return (
|
||||
<div className="job-path-field">
|
||||
<strong>{label}</strong>
|
||||
<div className="job-path-field-value">
|
||||
<span>{hasValue ? value : '-'}</span>
|
||||
{canDownload ? (
|
||||
<Button
|
||||
type="button"
|
||||
icon="pi pi-download"
|
||||
text
|
||||
rounded
|
||||
size="small"
|
||||
className="job-path-download-button"
|
||||
aria-label={`${label} als ZIP herunterladen`}
|
||||
tooltip={`${label} als ZIP herunterladen`}
|
||||
tooltipOptions={{ position: 'top' }}
|
||||
onClick={onDownload}
|
||||
disabled={downloadDisabled || downloadLoading}
|
||||
loading={downloadLoading}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JobDetailDialog({
|
||||
visible,
|
||||
job,
|
||||
@@ -432,13 +468,15 @@ export default function JobDetailDialog({
|
||||
onRetry,
|
||||
onDeleteFiles,
|
||||
onDeleteEntry,
|
||||
onDownloadArchive,
|
||||
onRemoveFromQueue,
|
||||
isQueued = false,
|
||||
omdbAssignBusy = false,
|
||||
cdMetadataAssignBusy = false,
|
||||
actionBusy = false,
|
||||
reencodeBusy = false,
|
||||
deleteEntryBusy = false
|
||||
deleteEntryBusy = false,
|
||||
downloadBusyTarget = null
|
||||
}) {
|
||||
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
|
||||
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status);
|
||||
@@ -510,6 +548,8 @@ export default function JobDetailDialog({
|
||||
const encodePlanUserPresetId = Number(encodePlanUserPreset?.id);
|
||||
const reviewUserPresets = encodePlanUserPreset ? [encodePlanUserPreset] : [];
|
||||
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');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -701,12 +741,20 @@ export default function JobDetailDialog({
|
||||
<div>
|
||||
<strong>Ende:</strong> {job.end_time || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{isCd ? 'WAV Pfad:' : 'RAW Pfad:'}</strong> {job.raw_path || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Output:</strong> {job.output_path || '-'}
|
||||
</div>
|
||||
<PathField
|
||||
label={isCd ? 'WAV Pfad:' : 'RAW Pfad:'}
|
||||
value={job.raw_path}
|
||||
onDownload={canDownloadRaw ? () => onDownloadArchive?.(job, 'raw') : null}
|
||||
downloadDisabled={!canDownloadRaw}
|
||||
downloadLoading={downloadBusyTarget === 'raw'}
|
||||
/>
|
||||
<PathField
|
||||
label="Output:"
|
||||
value={job.output_path}
|
||||
onDownload={canDownloadOutput ? () => onDownloadArchive?.(job, 'output') : null}
|
||||
downloadDisabled={!canDownloadOutput}
|
||||
downloadLoading={downloadBusyTarget === 'output'}
|
||||
/>
|
||||
{!isCd ? (
|
||||
<div>
|
||||
<strong>Encode Input:</strong> {job.encode_input_path || '-'}
|
||||
|
||||
@@ -370,6 +370,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
const [deleteEntryPreview, setDeleteEntryPreview] = useState(null);
|
||||
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
|
||||
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
|
||||
const [downloadBusyTarget, setDownloadBusyTarget] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [queuedJobIds, setQueuedJobIds] = useState([]);
|
||||
const toastRef = useRef(null);
|
||||
@@ -557,6 +558,28 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadArchive = async (row, target) => {
|
||||
const jobId = Number(row?.id || selectedJob?.id || 0);
|
||||
const normalizedTarget = String(target || '').trim().toLowerCase();
|
||||
if (!jobId || !['raw', 'output'].includes(normalizedTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDownloadBusyTarget(normalizedTarget);
|
||||
try {
|
||||
await api.downloadJobArchive(jobId, normalizedTarget);
|
||||
} catch (error) {
|
||||
toastRef.current?.show({
|
||||
severity: 'error',
|
||||
summary: 'Download fehlgeschlagen',
|
||||
detail: error.message,
|
||||
life: 4500
|
||||
});
|
||||
} finally {
|
||||
setDownloadBusyTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReencode = async (row) => {
|
||||
const title = row.title || row.detected_title || `Job #${row.id}`;
|
||||
const confirmed = window.confirm(`RAW neu encodieren für "${title}" starten?`);
|
||||
@@ -1169,15 +1192,18 @@ export default function HistoryPage({ refreshToken = 0 }) {
|
||||
onRetry={handleRetry}
|
||||
onDeleteFiles={handleDeleteFiles}
|
||||
onDeleteEntry={handleDeleteEntry}
|
||||
onDownloadArchive={handleDownloadArchive}
|
||||
onRemoveFromQueue={handleRemoveFromQueue}
|
||||
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
|
||||
actionBusy={actionBusy}
|
||||
reencodeBusy={reencodeBusyJobId === selectedJob?.id}
|
||||
deleteEntryBusy={deleteEntryBusy}
|
||||
downloadBusyTarget={downloadBusyTarget}
|
||||
onHide={() => {
|
||||
setDetailVisible(false);
|
||||
setDetailLoading(false);
|
||||
setLogLoadingMode(null);
|
||||
setDownloadBusyTarget(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -2205,6 +2205,30 @@ body {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.job-path-field {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.job-path-field-value {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.35rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.job-path-field-value > span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.job-path-download-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.job-meta-col-span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user