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:
@@ -332,6 +332,11 @@ auffiel, in einem Rutsch. Alles gebaut, Tests dabei, Ampel-Blocker behoben.
|
||||
Claude and KrBrZ".
|
||||
- [x] README: Media-Server, Benachrichtigungen, UHD-Arbeitsverzeichnis,
|
||||
„Rippy woanders bereitstellen" (beliebiger Docker-Host).
|
||||
- [x] **Download-Knopf für fertige Rips** (Wunsch aus der Übernahme-Session):
|
||||
GET /jobs/{id}/files (+ Datei-Stream, Pfad-Validierung strikt unter
|
||||
/app/media inkl. realpath-Check), Download-Knopf in der Aktion-Spalte,
|
||||
Dateiliste mit Größen im Job-Detail-Popup — vorher kam man an fertige
|
||||
MKVs nur per scp. nginx hatte proxy_buffering off schon (SSE).
|
||||
|
||||
**Offen aus dieser Runde:** nichts — Rest siehe Etappe 12 und Ideen unten.
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ Zwei Ursachen, beide behoben:
|
||||
**MakeMKV-Beta-Key im UI pflegbar** — gilt ab dem nächsten Rip, ohne Rebuild.
|
||||
- **Job-Detail-Popup** (Klick auf Titel in „Neueste Jobs"): Poster, Jahr,
|
||||
Beschreibung, Genres, Ablagepfad, Fehler (GET /jobs/{id}/detail).
|
||||
- **Download fertiger Rips im Browser**: „Download"-Knopf bei fertigen Jobs
|
||||
(Aktion-Spalte) → Dateiliste mit Größen im Detail-Popup, Stream via
|
||||
GET /jobs/{id}/files/{name} (Pfad-Validierung strikt unter /app/media,
|
||||
realpath-Check gegen Symlink-Ausbrüche) — vorher nur per scp erreichbar.
|
||||
- **UI-Feedback**: Toast-System (ToastContext, ploppt oben rechts), drehende
|
||||
Refresh-Knöpfe, Logs-Pills um Warnung/Fehler ergänzt, Datei-Browser zeigt
|
||||
jetzt auch DATEIEN (grau, mit Größe) — der „leere" Bluray-Ordner war voll.
|
||||
|
||||
+73
-1
@@ -1,6 +1,6 @@
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Dict
|
||||
from pathlib import Path
|
||||
@@ -305,6 +305,78 @@ async def get_job_detail(job_id: str):
|
||||
return detail
|
||||
|
||||
|
||||
def _sicherer_dateiname(name: str) -> bool:
|
||||
"""Pure Funktion (testbar): nur nackte Dateinamen, keine Pfad-Tricks."""
|
||||
return bool(name) and "/" not in name and "\\" not in name and not name.startswith(".")
|
||||
|
||||
|
||||
def _job_ausgabeordner(job: dict) -> str:
|
||||
"""Validierter Ausgabeordner eines Jobs — strikt unter /app/media."""
|
||||
ausgabe = os.path.normpath(job.get("output_path") or "")
|
||||
if not ausgabe.startswith(MEDIA_ROOT):
|
||||
raise HTTPException(status_code=404, detail="Job hat keinen Ausgabeordner unter /app/media")
|
||||
return ausgabe
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}/files")
|
||||
async def list_job_files(job_id: str):
|
||||
"""Dateien eines fertigen Jobs — fürs Download-Menü im Dashboard.
|
||||
|
||||
Vorher kam man an fertige MKVs nur per scp auf die VM.
|
||||
"""
|
||||
job = await asyncio.to_thread(db.get_job, job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||
ausgabe = _job_ausgabeordner(job)
|
||||
|
||||
def liste():
|
||||
try:
|
||||
eintraege = sorted(os.listdir(ausgabe))
|
||||
except OSError:
|
||||
return None
|
||||
dateien = []
|
||||
for name in eintraege:
|
||||
pfad = os.path.join(ausgabe, name)
|
||||
if os.path.isfile(pfad):
|
||||
try:
|
||||
groesse_mb = round(os.path.getsize(pfad) / 1024**2, 1)
|
||||
except OSError:
|
||||
groesse_mb = None
|
||||
dateien.append({"name": name, "size_mb": groesse_mb})
|
||||
return dateien
|
||||
|
||||
dateien = await asyncio.to_thread(liste)
|
||||
if dateien is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Ausgabeordner nicht lesbar — Job noch nicht fertig oder Ziel ausgehängt?",
|
||||
)
|
||||
return {"job_id": job_id, "output_path": ausgabe, "files": dateien}
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}/files/{dateiname}")
|
||||
async def download_job_file(job_id: str, dateiname: str):
|
||||
"""Streamt EINE Datei eines Jobs zum Browser (Download-Knopf).
|
||||
|
||||
Pfad-Validierung strikt: nackter Dateiname, realpath muss unter
|
||||
/app/media bleiben (kein ..-Ausbruch, kein Symlink nach draußen).
|
||||
"""
|
||||
job = await asyncio.to_thread(db.get_job, job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job nicht gefunden")
|
||||
ausgabe = _job_ausgabeordner(job)
|
||||
if not _sicherer_dateiname(dateiname):
|
||||
raise HTTPException(status_code=422, detail="Ungültiger Dateiname")
|
||||
pfad = os.path.join(ausgabe, dateiname)
|
||||
|
||||
def pruefe():
|
||||
return os.path.isfile(pfad) and os.path.realpath(pfad).startswith(MEDIA_ROOT)
|
||||
|
||||
if not await asyncio.to_thread(pruefe):
|
||||
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
||||
return FileResponse(pfad, filename=dateiname, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@app.get("/storage-targets")
|
||||
async def storage_targets():
|
||||
"""Verfügbare Ablageziele: Verzeichnisse unter /app/media inkl. Mounts.
|
||||
|
||||
@@ -22,6 +22,18 @@ def test_main_importierbar_und_routen_verdrahtet():
|
||||
assert pfad in routen, f"Route {pfad} fehlt"
|
||||
|
||||
|
||||
def test_dateiname_validierung_blockt_pfad_tricks():
|
||||
"""Download-Endpoint: nur nackte Dateinamen — kein .., kein Slash, kein Dotfile."""
|
||||
from main import _sicherer_dateiname
|
||||
|
||||
assert _sicherer_dateiname("film.mkv") is True
|
||||
assert _sicherer_dateiname("../../etc/passwd") is False
|
||||
assert _sicherer_dateiname("a/b.mkv") is False
|
||||
assert _sicherer_dateiname("a\\b.mkv") is False
|
||||
assert _sicherer_dateiname(".versteckt") is False
|
||||
assert _sicherer_dateiname("") is False
|
||||
|
||||
|
||||
def test_worker_task_name_passt_zum_celery_client():
|
||||
"""API schickt an 'worker.tasks.rip_disc' — der Name ist Vertrag mit dem Worker."""
|
||||
import inspect
|
||||
|
||||
@@ -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