feat: Download-Knopf fuer fertige Rips — Dateien direkt im Browser statt scp
Ampel / ampel (push) Successful in 29s
Ampel / ampel (push) Successful in 29s
- GET /jobs/{id}/files: Dateiliste aus job.output_path (Name + Groesse)
- GET /jobs/{id}/files/{name}: FileResponse-Stream; Validierung strikt —
output_path muss unter /app/media liegen, nackter Dateiname (kein
Slash/.., kein Dotfile), realpath-Check gegen Symlink-Ausbrueche.
Mit Test (test_dateiname_validierung_blockt_pfad_tricks).
- UI: 'Download'-Knopf in der Aktion-Spalte bei fertigen Jobs; die
Dateiliste mit Groessen + Download-Links lebt im Job-Detail-Popup
(ein Dropdown wuerde im overflow-x-auto-Tabellencontainer clippen).
- nginx: proxy_buffering off + proxy_read_timeout 3600s waren fuer SSE
schon gesetzt — grosse Downloads brauchen keine Aenderung.
Wunsch aus der Uebernahme-Session (Commander-Sammelliste 24.07.).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Film, Loader2, FolderOpen, AlertCircle } from 'lucide-react'
|
||||
import { X, Film, Loader2, FolderOpen, AlertCircle, Download } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
|
||||
@@ -48,19 +48,34 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
failed: 'Fehler',
|
||||
}
|
||||
|
||||
interface JobDatei {
|
||||
name: string
|
||||
size_mb: number | null
|
||||
}
|
||||
|
||||
export default function JobDetailModal({ jobId, onClose }: { jobId: string | null, onClose: () => void }) {
|
||||
const [detail, setDetail] = useState<JobDetail | null>(null)
|
||||
const [dateien, setDateien] = useState<JobDatei[] | null>(null)
|
||||
const [laedt, setLaedt] = useState(false)
|
||||
const { theme } = useDarkMode()
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) {
|
||||
setDetail(null)
|
||||
setDateien(null)
|
||||
return
|
||||
}
|
||||
setLaedt(true)
|
||||
api.get(`/jobs/${jobId}/detail`)
|
||||
.then(r => setDetail(r.data))
|
||||
.then(r => {
|
||||
setDetail(r.data)
|
||||
// Fertige Jobs: Dateiliste für die Download-Knöpfe nachladen
|
||||
if (r.data?.status === 'completed') {
|
||||
api.get(`/jobs/${jobId}/files`)
|
||||
.then(f => setDateien(f.data.files))
|
||||
.catch(() => setDateien(null))
|
||||
}
|
||||
})
|
||||
.catch(() => setDetail(null))
|
||||
.finally(() => setLaedt(false))
|
||||
}, [jobId])
|
||||
@@ -159,6 +174,33 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download der fertigen Dateien — vorher kam man nur per scp dran */}
|
||||
{detail.status === 'completed' && dateien && dateien.length > 0 && (
|
||||
<div className={`rounded-lg p-4 ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
|
||||
<p className={`text-sm font-medium mb-2 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>
|
||||
<Download size={15} /> Dateien herunterladen
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{dateien.map(f => (
|
||||
<a
|
||||
key={f.name}
|
||||
href={`/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`}
|
||||
download
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${theme === 'dark' ? 'text-indigo-300 hover:bg-slate-800' : 'text-indigo-700 hover:bg-slate-100'}`}
|
||||
>
|
||||
<Download size={14} className="flex-shrink-0" />
|
||||
<span className="truncate min-w-0">{f.name}</span>
|
||||
{f.size_mb != null && (
|
||||
<span className={`ml-auto text-xs flex-shrink-0 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.error && (
|
||||
<div className={`rounded-lg p-4 flex items-start gap-2.5 text-sm ${theme === 'dark' ? 'bg-rose-900/30 text-rose-300' : 'bg-rose-50 text-rose-700'}`}>
|
||||
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Clock, Activity, AlertCircle, CheckCircle, Disc } from 'lucide-react'
|
||||
import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
import DeviceDiscovery from '../components/DeviceDiscovery'
|
||||
@@ -304,6 +304,16 @@ export default function Dashboard() {
|
||||
{new Date(job.startTime).toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{job.status === 'completed' && (
|
||||
<button
|
||||
onClick={() => setDetailJobId(job.id)}
|
||||
title="Fertige Dateien herunterladen (öffnet die Job-Details)"
|
||||
className={`inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-emerald-300 hover:bg-slate-600' : 'bg-slate-100 text-emerald-700 hover:bg-slate-200'}`}
|
||||
>
|
||||
<Download size={13} />
|
||||
Download
|
||||
</button>
|
||||
)}
|
||||
{job.status === 'failed' && (
|
||||
<button
|
||||
onClick={() => api.post(`/jobs/${job.id}/retry-transcode`).catch(() => {})}
|
||||
|
||||
Reference in New Issue
Block a user