fix(ui+api): das "Neuladen" war HTTP 429 - und "Neu" komprimierte immer
Ampel / ampel (push) Successful in 30s

Zwei Commander-Befunde, beide mit derselben Wurzel: Rippy hat sich selbst
ausgebremst und dann geschwiegen.

## "Wenn der Worker installiert ist, wird dieser Bereich oft neu geladen"

Gemessen statt geraten. Die Antworten von /jobs und /capabilities waren ueber
zwanzig Sekunden byteweise identisch, alle Endpunkte antworteten unter 30 ms -
es wurde also gar nichts neu geladen. Im nginx-Log standen dagegen 97 Antworten
mit HTTP 429.

Drei Fehler griffen ineinander:

1. Das Limit war zu klein fuer Rippy selbst: 100 Anfragen/min, waehrend ein
   offener Tab 111/min verursacht (Dashboard 75 + Log-Kasten 24 + Laufwerke 12)
   und der Windows-Tray weitere 12/min dazulegt.
2. Der nginx gab die Client-Adresse nicht weiter. Fuer die API kam damit ALLES
   von 172.19.0.6 - Browser, zweiter Tab und Tray teilten sich einen Eimer
   (812 von 876 Anfragen). Das erklaert die Kopplung an den Worker: tray.py
   fragt /api/jobs ueber Port 80, also durch denselben Proxy.
3. Ein abgewiesener Abruf leerte das UI. `catch(() => [])` heisst "es gibt
   keine Jobs" - richtig waere "ich weiss gerade nichts Neues". Fuer einen Takt
   stand "Keine Jobs", die Zaehler sprangen auf (0), vier Sekunden spaeter war
   alles zurueck.

Behoben: X-Real-IP im nginx, Grenze auf 600/min mit vorgerechneter Herleitung,
jeder Fehlschlag laesst den alten Stand stehen (null statt []), axios bekommt
eine Zeitgrenze, und das Dashboard trennt schnelle Daten (Jobs/Laufwerke, 4 s)
von langsamen (Hardware/Worker/Ablagen, 12 s) - 75/min werden zu 30/min.
Ein greifendes Limit steht ab jetzt im Log, gedrosselt auf eine Meldung pro
Client und Minute.

## "Hier gibt es den Button 'neu' aber WAS wird dann gemacht?"

Immer die Komprimierung - auch bei einem Job, dessen RIP abgebrochen war. Am
26.07.2026 waeren aus 5,1 GB Bruchstueck (von rund 40 GB) brav ein Film
geworden, der bei 12 % aufhoert.

Die Phase war nach `status = "failed"` nicht mehr feststellbar, also wird sie
jetzt vermerkt (rip_fertig in den Job-Metadaten: false beim Rip-Start, true bei
der Uebergabe an die Kompression). Daraus folgt die Beschriftung: "Neu
komprimieren", "Neu rippen" - oder bei Bestandsjobs ohne Vermerk ein Dialog,
der beide Wege erklaert und die Groesse der Rohdaten als Entscheidungshilfe
nennt. Geraten wird nicht. Fuer den Rip-Fall gibt es POST
/jobs/{id}/retry-rip: neuer Job mit neuer ID (sonst laege das Bruchstueck im
Roh-Verzeichnis des neuen Rips), Titel/Ablage/Sprachwahl uebernommen, mit
ehrlicher Absage wenn keine Disc im Laufwerk liegt.

10 neue Tests (289 gruen), darunter eine Kopplungspruefung: der Name der
Phasen-Marke muss in worker/tasks.py und api/phasen.py zusammenpassen - genau
diese Sorte Auseinanderdriften hat die Zombie-Erkennung ein Release lang blind
gemacht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-26 16:49:10 +02:00
parent f7555a17d1
commit bfb13f44a5
11 changed files with 811 additions and 42 deletions
+165
View File
@@ -0,0 +1,165 @@
import { AlertTriangle, HelpCircle, RotateCcw, Disc } from 'lucide-react'
import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
/*
* Was wird bei „Neu" eigentlich wiederholt?
*
* Commander-Frage 26.07.2026: „Hier gibt es den Button neu' aber WAS wird dann
* gemacht? Komprimierung? Neu Gerippt? Das muss ja je nach fehlgeschlagenem Job
* eine Option anbieten." Bis dahin hieß der Knopf nur „Neu" und rief immer die
* Kompression — auch bei einem Job, dessen RIP abgebrochen war. Aus 5,1 GB
* Bruchstück (von rund 40 GB) wäre brav ein Film geworden, der bei 12 % endet.
*
* Dieser Dialog nennt beim Namen, was passiert, und in welchen Punkten sich die
* beiden Wege unterscheiden: Braucht es die Disc? Wie lange dauert es? Was
* passiert mit dem, was schon auf der Platte liegt?
*
* Die Phase kommt aus api/phasen.py. Kennt Rippy sie nicht (Jobs von vor der
* Einführung der Marke), wird nicht geraten — dann stehen beide Wege da, und
* die Größe der Rohdaten ist die Entscheidungshilfe: Wer ungefähr eine
* Disc-Größe daliegen sieht, hatte einen fertigen Rip.
*/
interface RetryDialogProps {
offen: boolean
titel: string
plan: 'transcode' | 'rip' | 'unklar'
rohdaten: { gb: number, dateien: number, pfade: string[] } | null
onClose: () => void
onStart: (art: 'transcode' | 'rip') => void
}
function Rohdaten({ rohdaten }: { rohdaten: RetryDialogProps['rohdaten'] }) {
if (!rohdaten) return null
if (!rohdaten.gb) {
return (
<p className="text-xs text-slate-500 dark:text-slate-400">
Auf der Platte liegt zu diesem Job nichts (mehr) neu rippen ist damit
der einzige Weg.
</p>
)
}
return (
<p className="text-xs text-slate-500 dark:text-slate-400">
Auf der Platte liegen <strong>{rohdaten.gb} GB</strong> Rohdaten
{rohdaten.dateien > 0 ? ` in ${rohdaten.dateien} Datei${rohdaten.dateien === 1 ? '' : 'en'}` : ''}.
{' '}Eine Blu-ray bringt roh etwa 2545 GB mit, eine 4K-UHD 50100 GB, eine
DVD 48 GB. Passt die Zahl dazu, war der Rip fertig.
</p>
)
}
export default function RetryDialog({
offen, titel, plan, rohdaten, onClose, onStart,
}: RetryDialogProps) {
const name = titel || 'Dieser Job'
return (
<Modal isOpen={offen} onClose={onClose} maxWidth="lg">
{plan === 'transcode' && (
<>
<div className="flex items-start gap-4">
<RotateCcw className="text-indigo-500 w-8 h-8 flex-shrink-0" />
<div className="space-y-2 flex-1">
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
{name}" neu komprimieren?
</h2>
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
Der Rip war fertig — abgebrochen ist erst die Kompression. Rippy
nimmt die vorhandene Roh-Datei und komprimiert sie noch einmal.
<strong> Die Disc wird nicht gebraucht</strong>, das Laufwerk
bleibt frei, und die Stunde fürs Rippen fällt nicht erneut an.
</p>
<Rohdaten rohdaten={rohdaten} />
</div>
</div>
<div className="mt-6 flex flex-wrap justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
<Button variant="ghost" onClick={() => onStart('rip')}>
<Disc size={14} /> Doch von der Disc neu rippen
</Button>
<Button variant="primary" onClick={() => onStart('transcode')}>
<RotateCcw size={14} /> Neu komprimieren
</Button>
</div>
</>
)}
{plan === 'rip' && (
<>
<div className="flex items-start gap-4">
<AlertTriangle className="text-amber-500 w-8 h-8 flex-shrink-0" />
<div className="space-y-2 flex-1">
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
„{name}" neu rippen?
</h2>
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
Hier ist der <strong>Rip selbst</strong> abgebrochen. Was davon
auf der Platte liegt, ist ein Bruchstück komprimieren würde
daraus einen Film machen, der mitten drin aufhört. Rippy liest
die Disc deshalb von vorn.
</p>
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
<strong>Die Disc muss dafür im Laufwerk liegen.</strong> Nach dem
Fehlschlag wurde sie möglicherweise ausgeworfen. Titel, Ablage,
Sprachwahl und Encoder-Worker werden vom alten Job übernommen.
</p>
<Rohdaten rohdaten={rohdaten} />
<p className="text-xs text-slate-500 dark:text-slate-400">
Der alte Eintrag bleibt zum Nachlesen stehen; das Bruchstück
lässt sich über den Papierkorb daran mitlöschen.
</p>
</div>
</div>
<div className="mt-6 flex justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
<Button variant="amber" onClick={() => onStart('rip')}>
<Disc size={14} /> Neu rippen
</Button>
</div>
</>
)}
{plan === 'unklar' && (
<>
<div className="flex items-start gap-4">
<HelpCircle className="text-indigo-500 w-8 h-8 flex-shrink-0" />
<div className="space-y-2 flex-1">
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
{name}": was soll wiederholt werden?
</h2>
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
Bei diesem Job kann Rippy nicht mehr feststellen, ob der Rip
fertig war oder mitten drin abbrach — er ist älter als der
Vermerk dafür. Deshalb wird hier nicht geraten:
</p>
<ul className="text-sm leading-relaxed text-slate-600 dark:text-slate-300 space-y-1 list-disc pl-5">
<li>
<strong>Neu komprimieren</strong> — schnell, braucht die Disc
nicht. Richtig, wenn der Rip durchlief.
</li>
<li>
<strong>Neu rippen</strong> — dauert wieder eine Weile und
braucht die Disc im Laufwerk. Immer richtig, nur teurer.
</li>
</ul>
<Rohdaten rohdaten={rohdaten} />
</div>
</div>
<div className="mt-6 flex flex-wrap justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
<Button variant="secondary" onClick={onClose}>Abbrechen</Button>
{!!rohdaten?.gb && (
<Button variant="primary" onClick={() => onStart('transcode')}>
<RotateCcw size={14} /> Neu komprimieren
</Button>
)}
<Button variant="amber" onClick={() => onStart('rip')}>
<Disc size={14} /> Neu rippen
</Button>
</div>
</>
)}
</Modal>
)
}
+6 -1
View File
@@ -4,4 +4,9 @@ import axios from 'axios'
// den api-Container weiter. Vorher stand in vier Dateien http://localhost:8000
// hartkodiert: vom PC aus fragte der Browser damit den PC SELBST nach
// Laufwerken und Jobs — deshalb blieb das UI immer leer.
export const api = axios.create({ baseURL: '/api' })
// Zeitgrenze, damit ein hängender Aufruf nicht ewig offen bleibt und sich die
// Abfragen des 4-Sekunden-Takts stapeln (Befund 26.07.2026: axios wartet ohne
// `timeout` unbegrenzt). 20 s sind reichlich für jeden Endpunkt — die
// langsamsten sind der Update-Check und der Titel-Scan-Start, alles andere
// antwortet in Millisekunden.
export const api = axios.create({ baseURL: '/api', timeout: 20000 })
+142 -32
View File
@@ -9,6 +9,7 @@ import ConfirmDialog from '../components/ConfirmDialog'
import DeviceDiscovery from '../components/DeviceDiscovery'
import JobDetailModal from '../components/JobDetailModal'
import LiveLogSection from '../components/LiveLogSection'
import RetryDialog from '../components/RetryDialog'
interface JobMeta {
year?: number
@@ -30,6 +31,8 @@ interface Job {
progress: number
title?: string
can_retry?: boolean
// "transcode" | "rip" | "unklar" | "" — welche Wiederholung passt (phasen.py)
retry_art?: string
meta?: JobMeta | null
// Restzeit von der API (eta.py). '' = noch keine Aussage möglich — dann wird
// bewusst nichts angezeigt statt einer erfundenen Zahl.
@@ -83,12 +86,26 @@ function posterUrl(meta?: JobMeta | null): string | null {
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
}
async function fetchJobs(): Promise<Job[]> {
/*
* Ein fehlgeschlagener Abruf gibt `null` — NICHT eine leere Liste.
*
* Commander-Befund 26.07.2026: „Wenn der Worker installiert ist, wird dieser
* Bereich oft neu geladen." Gemessen war es kein Neuladen: Die Antworten von
* /jobs und /capabilities waren über zwanzig Sekunden byteweise IDENTISCH, alle
* Endpunkte antworteten in unter 30 ms. Der Grund lag im Fehlerzweig — hier
* stand `return []`, und jeder einzelne verpasste Abruf setzte damit die
* Job-Liste auf LEER. Für einen Takt zeigte die Tabelle „Keine Jobs", die Zähler
* sprangen auf (0), vier Sekunden später war alles wieder da.
*
* Ein verpasster Abruf ist keine Nachricht über die Welt. Wer nichts Neues
* weiß, behält, was er wusste.
*/
async function fetchJobs(): Promise<Job[] | null> {
try {
const response = await api.get('/jobs')
return response.data
return Array.isArray(response.data) ? response.data : null
} catch {
return []
return null
}
}
@@ -102,6 +119,9 @@ export default function Dashboard() {
const [loeschKandidat, setLoeschKandidat] = useState<Job | null>(null)
const [rohdaten, setRohdaten] = useState<{ gb: number, dateien: number, pfade: string[] } | null>(null)
const [rohdatenMitloeschen, setRohdatenMitloeschen] = useState(false)
// Job, für den ein neuer Versuch bestätigt werden soll (siehe wiederholen()).
const [neuKandidat, setNeuKandidat] = useState<Job | null>(null)
const [neuRohdaten, setNeuRohdaten] = useState<{ gb: number, dateien: number, pfade: string[] } | null>(null)
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
const [laufwerke, setLaufwerke] = useState<LaufwerkLive[]>([])
@@ -130,6 +150,57 @@ export default function Dashboard() {
}
}
/*
* Der „Neu"-Knopf sagt jetzt, WAS er tut.
*
* Commander-Frage 26.07.2026: „Hier gibt es den Button neu' aber WAS wird
* dann gemacht? Komprimierung? Neu Gerippt?" — Antwort damals: immer
* Komprimierung, egal wie der Job gestorben war. Für einen abgebrochenen Rip
* war das falsch (5,1 GB Bruchstück von rund 40 GB).
*
* `job.retry_art` kommt aus api/phasen.py und kennt drei Fälle:
* 'transcode' → der Rip war fertig → „Neu komprimieren"
* 'rip' → der Rip starb → „Neu rippen" (Disc muss drin sein)
* 'unklar' → Bestandsjob → fragen, mit der Rohdaten-Größe als
* Entscheidungshilfe
*
* Sonderfall: Phase 'transcode', aber die Rohdaten sind fort (`can_retry`
* false) — dann hilft auch hier nur ein neuer Rip.
*/
const retryPlan = (job: Job): 'transcode' | 'rip' | 'unklar' => {
if (job.retry_art === 'transcode') return job.can_retry ? 'transcode' : 'rip'
if (job.retry_art === 'rip') return 'rip'
return 'unklar'
}
const wiederholen = async (job: Job) => {
setNeuKandidat(job)
setNeuRohdaten(null)
try {
const r = await api.get(`/jobs/${job.id}/rohdaten`)
setNeuRohdaten(r.data)
} catch {
setNeuRohdaten(null) // dann eben ohne Zahl fragen
}
}
const neuStarten = async (art: 'transcode' | 'rip') => {
const job = neuKandidat
if (!job) return
setNeuKandidat(null)
const weg = art === 'rip' ? 'retry-rip' : 'retry-transcode'
try {
await api.post(`/jobs/${job.id}/${weg}`)
toast('success', art === 'rip'
? 'Neuer Rip gestartet — der alte Eintrag bleibt zum Nachlesen stehen'
: 'Kompression neu eingereiht — die Disc wird nicht gebraucht')
const frisch = await fetchJobs()
if (frisch) setJobs(frisch)
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Fehlgeschlagen')
}
}
const jobLoeschen = async () => {
const job = loeschKandidat
if (!job) return
@@ -141,7 +212,8 @@ export default function Dashboard() {
const befreit = r.data?.rohdaten_geloescht_gb
toast('success', `${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`
+ (befreit ? `${befreit} GB Rohdaten gelöscht` : ''))
setJobs(await fetchJobs())
const frisch = await fetchJobs()
if (frisch) setJobs(frisch)
} catch (e: any) {
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen')
}
@@ -152,43 +224,71 @@ export default function Dashboard() {
try {
const r = await api.delete('/jobs')
toast('success', `${r.data.deleted} erledigte Jobs entfernt — Dateien bleiben liegen`)
setJobs(await fetchJobs())
const frisch = await fetchJobs()
if (frisch) setJobs(frisch)
} catch {
toast('error', 'Aufräumen fehlgeschlagen')
}
}
/*
* ZWEI TAKTE statt einem.
*
* ⚠️ JEDER Abruf hier gibt bei Fehlschlag `null` — und `null` bedeutet „nichts
* Neues erfahren", nicht „es gibt nichts". Vor dem 26.07.2026 stand überall
* `catch(() => [])`: ein einziger verpasster Abruf leerte Job-Liste,
* Laufwerke und Ablagen, vier Sekunden später war alles wieder da. Genau das
* hat der Commander als „wird oft neu geladen" gemeldet. Siehe `fetchJobs`.
*
* Und verpasst wurde reichlich: Fünf Endpunkte alle vier Sekunden sind 75
* Anfragen pro Minute, dazu Log-Kasten und Laufwerks-Suche — die API wies bei
* ihrer damaligen Grenze von 100/min laufend mit HTTP 429 ab (gemessen: 97
* Abweisungen im Log). Die Grenze ist jetzt der echten Last angemessen
* (ratelimit.py), aber das rechtfertigt keine Verschwendung: Jobs und
* Laufwerke ändern sich sekündlich, die drei anderen praktisch nie.
* 75/min sind damit 15 + 15/min geworden.
*/
useEffect(() => {
const loadData = async () => {
const nichts = () => null
// Schnell (4 s): was sich während eines Rips wirklich bewegt.
const schnellLaden = async () => {
try {
const [jobsData, sysData, capsData, devData, ablData] = await Promise.all([
const [jobsData, devData] = await Promise.all([
fetchJobs(),
api.get('/system/info').then(r => r.data).catch(() => null),
// Für die ECHTE Online-Zahl: /capabilities kennt den Celery-Ping,
// /system/info nur die Registrierung. Der Endpunkt ist seit v3.15
// schnell (0,003 s, Ping läuft im Hintergrund) — er darf hier also
// im 4-Sekunden-Takt mitlaufen.
api.get('/capabilities').then(r => r.data?.workers || []).catch(() => []),
// Die Laufwerke: Ohne sie behauptete die Server-Status-Karte
// „keine Disc in Arbeit", während oben die erkannte Disc stand.
api.get('/devices').then(r => r.data || []).catch(() => []),
// Die Ablageziele inkl. eingehängter Freigaben — ohne sie zeigte der
// Server-Status nur die Container-Platte (Commander-Befund).
api.get('/storage-targets').then(r => Array.isArray(r.data) ? r.data : []).catch(() => []),
api.get('/devices').then(r => Array.isArray(r.data) ? r.data : null).catch(nichts),
])
setJobs(jobsData)
if (sysData) setSystemInfo(sysData)
setWorkersLive(capsData)
setLaufwerke(devData)
setAblagen(ablData)
if (jobsData) setJobs(jobsData)
if (devData) setLaufwerke(devData)
} finally {
setLoading(false)
}
}
loadData()
const interval = setInterval(loadData, 4000)
return () => clearInterval(interval)
// Langsam (12 s): Hardware, Worker-Liste, Ablagen. Ein Worker, der online
// geht, oder eine Freigabe, die wegbricht, darf drei Takte später auffallen.
const langsamLaden = async () => {
const [sysData, capsData, ablData] = await Promise.all([
api.get('/system/info').then(r => r.data).catch(nichts),
// Für die ECHTE Online-Zahl: /capabilities kennt den Celery-Ping,
// /system/info nur die Registrierung.
api.get('/capabilities').then(r => r.data?.workers ?? null).catch(nichts),
// Die Ablageziele inkl. eingehängter Freigaben — ohne sie zeigte der
// Server-Status nur die Container-Platte (Commander-Befund).
api.get('/storage-targets').then(r => Array.isArray(r.data) ? r.data : null).catch(nichts),
])
if (sysData) setSystemInfo(sysData)
if (capsData) setWorkersLive(capsData)
if (ablData) setAblagen(ablData)
}
schnellLaden()
langsamLaden()
const schnell = setInterval(schnellLaden, 4000)
const langsam = setInterval(langsamLaden, 12000)
return () => { clearInterval(schnell); clearInterval(langsam) }
}, [])
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
@@ -505,17 +605,17 @@ export default function Dashboard() {
</Button>
)}
{job.status === 'failed' && job.can_retry && (
{job.status === 'failed' && (
<Button
variant="secondary"
size="sm"
onClick={() => {
api.post(`/jobs/${job.id}/retry-transcode`)
.then(() => toast('success', 'Kompression neu eingereiht'))
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Fehlgeschlagen'))
}}
onClick={() => wiederholen(job)}
title="Fragt vorher, was genau wiederholt wird"
>
<RotateCcw size={13} /> Neu
<RotateCcw size={13} />
{retryPlan(job) === 'transcode' ? 'Neu komprimieren'
: retryPlan(job) === 'rip' ? 'Neu rippen'
: 'Neu …'}
</Button>
)}
@@ -773,6 +873,16 @@ export default function Dashboard() {
type="warning"
/>
{/* „Neu" nennt jetzt beim Namen, was es tut (siehe wiederholen()). */}
<RetryDialog
offen={neuKandidat !== null}
titel={neuKandidat?.title || neuKandidat?.id.slice(0, 8) || ''}
plan={neuKandidat ? retryPlan(neuKandidat) : 'unklar'}
rohdaten={neuRohdaten}
onClose={() => setNeuKandidat(null)}
onStart={neuStarten}
/>
{/* Entfernen eines EINZELNEN Jobs — mit der Zahl, die vorher fehlte. */}
<ConfirmDialog
isOpen={loeschKandidat !== null}