0.10.2 Download Files

This commit is contained in:
2026-03-15 12:15:46 +00:00
parent 52ef155c7c
commit 7d6c154909
12 changed files with 1313 additions and 28 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "ripster-backend", "name": "ripster-backend",
"version": "0.10.1-1", "version": "0.10.2",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
@@ -8,6 +8,7 @@
"dev": "nodemon src/index.js" "dev": "nodemon src/index.js"
}, },
"dependencies": { "dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.21.2", "express": "^4.21.2",

View File

@@ -1,8 +1,10 @@
const express = require('express'); const express = require('express');
const archiver = require('archiver');
const asyncHandler = require('../middleware/asyncHandler'); const asyncHandler = require('../middleware/asyncHandler');
const historyService = require('../services/historyService'); const historyService = require('../services/historyService');
const pipelineService = require('../services/pipelineService'); const pipelineService = require('../services/pipelineService');
const logger = require('../services/logger').child('HISTORY_ROUTE'); const logger = require('../services/logger').child('HISTORY_ROUTE');
const { errorToMeta } = require('../utils/errorMeta');
const router = express.Router(); const router = express.Router();
@@ -180,6 +182,72 @@ router.post(
}) })
); );
router.get(
'/:id/download',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
const target = String(req.query.target || '').trim();
const descriptor = await historyService.getJobArchiveDescriptor(id, target);
logger.info('get:job:download', {
reqId: req.reqId,
id,
target: descriptor.target,
sourceType: descriptor.sourceType,
sourcePath: descriptor.sourcePath
});
const archive = archiver('zip', { zlib: { level: 9 } });
const handleArchiveError = (error) => {
logger.error('get:job:download:failed', {
reqId: req.reqId,
id,
target: descriptor.target,
error: errorToMeta(error)
});
if (!res.headersSent) {
res.status(500).json({
error: {
message: 'ZIP-Download fehlgeschlagen.'
}
});
return;
}
res.destroy(error);
};
archive.on('warning', handleArchiveError);
archive.on('error', handleArchiveError);
res.on('close', () => {
if (!res.writableEnded) {
archive.abort();
}
});
res.setHeader('Content-Type', 'application/zip');
res.attachment(descriptor.archiveName);
archive.pipe(res);
if (descriptor.sourceType === 'directory') {
archive.directory(descriptor.sourcePath, descriptor.entryName);
} else {
archive.file(descriptor.sourcePath, { name: descriptor.entryName });
}
try {
const finalizeResult = archive.finalize();
if (finalizeResult && typeof finalizeResult.catch === 'function') {
finalizeResult.catch(handleArchiveError);
}
} catch (error) {
handleArchiveError(error);
}
})
);
router.get( router.get(
'/:id', '/:id',
asyncHandler(async (req, res) => { asyncHandler(async (req, res) => {

View File

@@ -764,6 +764,36 @@ function normalizeJobIdValue(value) {
return Math.trunc(parsed); return Math.trunc(parsed);
} }
function normalizeArchiveTarget(value) {
const raw = String(value || '').trim().toLowerCase();
if (raw === 'raw') {
return 'raw';
}
if (raw === 'output' || raw === 'movie' || raw === 'encode') {
return 'output';
}
return null;
}
function sanitizeArchiveNamePart(value, fallback = 'job') {
const normalized = String(value || '')
.normalize('NFKD')
.replace(/[^\x00-\x7F]+/g, '');
const safe = normalized
.replace(/[^A-Za-z0-9._-]+/g, '_')
.replace(/_+/g, '_')
.replace(/^[_-]+|[_-]+$/g, '')
.slice(0, 80);
return safe || fallback;
}
function buildJobArchiveName(job, target) {
const jobId = normalizeJobIdValue(job?.id) || 'unknown';
const titlePart = sanitizeArchiveNamePart(job?.title || job?.detected_title || '', 'job');
const targetPart = target === 'raw' ? 'raw' : 'encode';
return `job-${jobId}-${titlePart}-${targetPart}.zip`;
}
function parseSourceJobIdFromPlan(encodePlanRaw) { function parseSourceJobIdFromPlan(encodePlanRaw) {
const plan = parseInfoFromValue(encodePlanRaw, null); const plan = parseInfoFromValue(encodePlanRaw, null);
const sourceJobId = normalizeJobIdValue(plan?.sourceJobId); const sourceJobId = normalizeJobIdValue(plan?.sourceJobId);
@@ -1427,6 +1457,76 @@ class HistoryService {
}; };
} }
async getJobArchiveDescriptor(jobId, target) {
const normalizedJobId = normalizeJobIdValue(jobId);
if (!normalizedJobId) {
const error = new Error('Ungültige Job-ID.');
error.statusCode = 400;
throw error;
}
const normalizedTarget = normalizeArchiveTarget(target);
if (!normalizedTarget) {
const error = new Error('Ungültiges Download-Ziel. Erlaubt sind raw und output.');
error.statusCode = 400;
throw error;
}
const [job, settings] = await Promise.all([
this.getJobById(normalizedJobId),
settingsService.getSettingsMap()
]);
if (!job) {
const error = new Error('Job nicht gefunden.');
error.statusCode = 404;
throw error;
}
const resolvedPaths = resolveEffectiveStoragePathsForJob(settings, job);
const sourcePath = normalizedTarget === 'raw'
? resolvedPaths.effectiveRawPath
: resolvedPaths.effectiveOutputPath;
if (!sourcePath) {
const error = new Error(
normalizedTarget === 'raw'
? 'Kein RAW-Pfad für diesen Job vorhanden.'
: 'Kein Output-Pfad für diesen Job vorhanden.'
);
error.statusCode = 404;
throw error;
}
let sourceStat;
try {
sourceStat = await fs.promises.stat(sourcePath);
} catch (_error) {
const error = new Error(
normalizedTarget === 'raw'
? 'RAW-Pfad wurde nicht gefunden.'
: 'Output-Pfad wurde nicht gefunden.'
);
error.statusCode = 404;
throw error;
}
if (!sourceStat.isDirectory() && !sourceStat.isFile()) {
const error = new Error('Nur Dateien oder Verzeichnisse können als ZIP heruntergeladen werden.');
error.statusCode = 400;
throw error;
}
return {
jobId: normalizedJobId,
target: normalizedTarget,
sourcePath,
sourceType: sourceStat.isDirectory() ? 'directory' : 'file',
entryName: path.basename(sourcePath) || (normalizedTarget === 'raw' ? 'raw' : 'output'),
archiveName: buildJobArchiveName(job, normalizedTarget)
};
}
async getDatabaseRows(filters = {}) { async getDatabaseRows(filters = {}) {
const jobs = await this.getJobs(filters); const jobs = await this.getJobs(filters);
return jobs.map((job) => ({ return jobs.map((job) => ({

View File

@@ -1,12 +1,12 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.10.1-1", "version": "0.10.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.10.1-1", "version": "0.10.2",
"dependencies": { "dependencies": {
"primeicons": "^7.0.0", "primeicons": "^7.0.0",
"primereact": "^10.9.2", "primereact": "^10.9.2",

View File

@@ -1,6 +1,6 @@
{ {
"name": "ripster-frontend", "name": "ripster-frontend",
"version": "0.10.1-1", "version": "0.10.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -111,6 +111,69 @@ async function request(path, options = {}) {
return response.text(); 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 = {}) { async function requestWithXhr(path, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
@@ -630,6 +693,11 @@ export const api = {
afterMutationInvalidate(['/history', '/pipeline/queue']); afterMutationInvalidate(['/history', '/pipeline/queue']);
return result; 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 = {}) { getJob(jobId, options = {}) {
const query = new URLSearchParams(); const query = new URLSearchParams();
const includeLiveLog = Boolean(options.includeLiveLog); const includeLiveLog = Boolean(options.includeLiveLog);

View File

@@ -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({ export default function JobDetailDialog({
visible, visible,
job, job,
@@ -432,13 +468,15 @@ export default function JobDetailDialog({
onRetry, onRetry,
onDeleteFiles, onDeleteFiles,
onDeleteEntry, onDeleteEntry,
onDownloadArchive,
onRemoveFromQueue, onRemoveFromQueue,
isQueued = false, isQueued = false,
omdbAssignBusy = false, omdbAssignBusy = false,
cdMetadataAssignBusy = false, cdMetadataAssignBusy = false,
actionBusy = false, actionBusy = false,
reencodeBusy = false, reencodeBusy = false,
deleteEntryBusy = false deleteEntryBusy = false,
downloadBusyTarget = null
}) { }) {
const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS'; const mkDone = Boolean(job?.ripSuccessful) || !job?.makemkvInfo || job?.makemkvInfo?.status === 'SUCCESS';
const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status); const running = ['ANALYZING', 'RIPPING', 'MEDIAINFO_CHECK', 'ENCODING'].includes(job?.status);
@@ -510,6 +548,8 @@ export default function JobDetailDialog({
const encodePlanUserPresetId = Number(encodePlanUserPreset?.id); const encodePlanUserPresetId = Number(encodePlanUserPreset?.id);
const reviewUserPresets = encodePlanUserPreset ? [encodePlanUserPreset] : []; const reviewUserPresets = encodePlanUserPreset ? [encodePlanUserPreset] : [];
const executedHandBrakeCommand = buildExecutedHandBrakeCommand(job?.handbrakeInfo); 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 ( return (
<Dialog <Dialog
@@ -701,12 +741,20 @@ export default function JobDetailDialog({
<div> <div>
<strong>Ende:</strong> {job.end_time || '-'} <strong>Ende:</strong> {job.end_time || '-'}
</div> </div>
<div> <PathField
<strong>{isCd ? 'WAV Pfad:' : 'RAW Pfad:'}</strong> {job.raw_path || '-'} label={isCd ? 'WAV Pfad:' : 'RAW Pfad:'}
</div> value={job.raw_path}
<div> onDownload={canDownloadRaw ? () => onDownloadArchive?.(job, 'raw') : null}
<strong>Output:</strong> {job.output_path || '-'} downloadDisabled={!canDownloadRaw}
</div> downloadLoading={downloadBusyTarget === 'raw'}
/>
<PathField
label="Output:"
value={job.output_path}
onDownload={canDownloadOutput ? () => onDownloadArchive?.(job, 'output') : null}
downloadDisabled={!canDownloadOutput}
downloadLoading={downloadBusyTarget === 'output'}
/>
{!isCd ? ( {!isCd ? (
<div> <div>
<strong>Encode Input:</strong> {job.encode_input_path || '-'} <strong>Encode Input:</strong> {job.encode_input_path || '-'}

View File

@@ -370,6 +370,7 @@ export default function HistoryPage({ refreshToken = 0 }) {
const [deleteEntryPreview, setDeleteEntryPreview] = useState(null); const [deleteEntryPreview, setDeleteEntryPreview] = useState(null);
const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false); const [deleteEntryPreviewLoading, setDeleteEntryPreviewLoading] = useState(false);
const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null); const [deleteEntryTargetBusy, setDeleteEntryTargetBusy] = useState(null);
const [downloadBusyTarget, setDownloadBusyTarget] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [queuedJobIds, setQueuedJobIds] = useState([]); const [queuedJobIds, setQueuedJobIds] = useState([]);
const toastRef = useRef(null); 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 handleReencode = async (row) => {
const title = row.title || row.detected_title || `Job #${row.id}`; const title = row.title || row.detected_title || `Job #${row.id}`;
const confirmed = window.confirm(`RAW neu encodieren für "${title}" starten?`); const confirmed = window.confirm(`RAW neu encodieren für "${title}" starten?`);
@@ -1169,15 +1192,18 @@ export default function HistoryPage({ refreshToken = 0 }) {
onRetry={handleRetry} onRetry={handleRetry}
onDeleteFiles={handleDeleteFiles} onDeleteFiles={handleDeleteFiles}
onDeleteEntry={handleDeleteEntry} onDeleteEntry={handleDeleteEntry}
onDownloadArchive={handleDownloadArchive}
onRemoveFromQueue={handleRemoveFromQueue} onRemoveFromQueue={handleRemoveFromQueue}
isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))} isQueued={Boolean(selectedJob?.id && queuedJobIdSet.has(normalizeJobId(selectedJob.id)))}
actionBusy={actionBusy} actionBusy={actionBusy}
reencodeBusy={reencodeBusyJobId === selectedJob?.id} reencodeBusy={reencodeBusyJobId === selectedJob?.id}
deleteEntryBusy={deleteEntryBusy} deleteEntryBusy={deleteEntryBusy}
downloadBusyTarget={downloadBusyTarget}
onHide={() => { onHide={() => {
setDetailVisible(false); setDetailVisible(false);
setDetailLoading(false); setDetailLoading(false);
setLogLoadingMode(null); setLogLoadingMode(null);
setDownloadBusyTarget(null);
}} }}
/> />

View File

@@ -2205,6 +2205,30 @@ body {
font-weight: 600; 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 { .job-meta-col-span-2 {
grid-column: 1 / -1; grid-column: 1 / -1;
} }

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "ripster", "name": "ripster",
"version": "0.10.1-1", "version": "0.10.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ripster", "name": "ripster",
"version": "0.10.1-1", "version": "0.10.2",
"devDependencies": { "devDependencies": {
"concurrently": "^9.1.2" "concurrently": "^9.1.2"
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "ripster", "name": "ripster",
"private": true, "private": true,
"version": "0.10.1-1", "version": "0.10.2",
"scripts": { "scripts": {
"dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"", "dev": "concurrently \"npm run dev --prefix backend\" \"npm run dev --prefix frontend\"",
"dev:backend": "npm run dev --prefix backend", "dev:backend": "npm run dev --prefix backend",