Der Fortschritt sagt, woran er gerade ist — und schreibt es mit

Vorher stand in der Verwaltung minutenlang "Wird durchsucht…", ohne
Hinweis, ob überhaupt etwas passiert. Bei vierzigtausend Dateien ist das
nicht zu unterscheiden von einem hängenden Lauf.

Scanschritt trägt Phase, Gegenstand und Zählstand und bildet daraus den
Satz selbst — im Kern und nicht in der Oberfläche, weil die Apps denselben
brauchen und zweimal formuliert er auseinanderdriftet. Sechs Phasen von
"Suche Medien in /media/Filme" über "Suche ‚Ich einfach unverbesserlich'
bei der Metadatenquelle — 44 von 1204" bis "Räume auf und lege Doppeltes
zusammen". Pfade werden auf die letzten zwei Bestandteile gekürzt.

ScanAsync und EnrichAsync bekommen je einen optionalen IProgress; optional
heisst, dass kein vorhandener Aufrufer bricht. Der Koordinator hält nur
den letzten Schritt — eine Liste über vierzigtausend Titel wäre ein Leck
im Arbeitsspeicher.

Dazu das Tagebuch: eine Datei je Tag unter config/logs, nach lokaler Zeit
und nicht nach UTC, weil ein Scan um 23 Uhr am heutigen Datum gesucht
wird. Dreissig Tage Aufbewahrung, einstellbar über FLARO_LOG_TAGE — ohne
Grenze läuft die config-Partition eines NAS lautlos voll. Es wirft nie:
ein volles Dateisystem darf keinen Scan abbrechen.

In der Verwaltung lesbar, nach Fehlern filterbar und herunterladbar. Das
Containerprotokoll ist nach einem Neustart weg und nur über SSH
erreichbar — die Frage "warum wurde der Film gestern Nacht nicht erkannt?"
liess sich damit nicht beantworten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 12:15:18 +02:00
co-authored by Claude Opus 5
parent 637e95fbb1
commit f359807561
12 changed files with 411 additions and 16 deletions
+10
View File
@@ -85,6 +85,8 @@ export type Scanstand = {
removed: number | null; unrecognised: number | null; probed: number | null;
/** Wie viele Dateien dieser Lauf als verschwunden gemeldet hat. */
missing: number | null;
/** Woran der Lauf gerade ist, in einem Satz. */
step: string | null;
problems: { path: string; reason: string }[] | null; error: string | null;
metadata: { moviesMatched: number; seriesMatched: number; episodesMatched: number;
skipped: boolean; error: string | null } | null;
@@ -430,6 +432,14 @@ export type Ordnerstand = {
note: string | null;
};
export type Protokolltag = { date: string; bytes: number };
/** Welche Tage protokolliert sind, neueste zuerst. */
export const protokolltage = () => anfrage<Protokolltag[]>('/system/logs');
/** Die Adresse, unter der ein Protokolltag liegt — zum Lesen und Herunterladen. */
export const protokollAdresse = (tag: string) => `/api/v1/system/logs/${tag}`;
export type VermissteDatei = {
fileId: string; path: string; versionLabel: string | null;
missingSince: string; libraryName: string;
+15
View File
@@ -634,3 +634,18 @@ h2 { font-size: 19px; font-weight: 700; letter-spacing: -.01em; margin: 0 0 var(
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
display: block;
}
/* Protokollanzeige — Festbreitenschrift, begrenzte Höhe, eigener Bildlauf. */
.protokoll {
margin: var(--s-regular) 0 0;
padding: var(--s-regular);
background: var(--canvas);
border: 1px solid var(--line);
border-radius: var(--r-control);
font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--text-dim);
max-height: 420px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
+93 -3
View File
@@ -2,6 +2,7 @@ import {
bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen,
scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen,
vermisste, vermisstesEntfernen, type VermissteDatei,
protokolltage, protokollAdresse,
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
servernameSetzen, systemauskunft,
laufendeWiedergaben, serverzustand, bildAdresse,
@@ -26,7 +27,8 @@ export function verwaltung(
): HTMLElement {
const wurzel = document.createElement('div');
wurzel.append(dashboard(signal), vermisstenbereich(geaendert),
bibliotheksbereich(geaendert, signal), benutzerbereich(), serverbereich(geaendert));
bibliotheksbereich(geaendert, signal), benutzerbereich(), serverbereich(geaendert),
protokollbereich());
return wurzel;
}
@@ -158,6 +160,92 @@ function laufzeit(sekunden: number): string {
return `${minuten} Min.`;
}
/* ---------- Protokolle ---------- */
/** Eine Datei je Tag, lesbar ohne Verbindung auf das NAS.
*
* Das Containerprotokoll ist nach einem Neustart weg und nur über SSH
* erreichbar — die Frage „warum wurde der Film gestern Nacht nicht erkannt?"
* lässt sich damit nicht beantworten. */
function protokollbereich(): HTMLElement {
const bereich = document.createElement('section');
bereich.appendChild(abschnitt('Protokolle'));
const liste = document.createElement('div');
liste.className = 'einstellungen';
liste.innerHTML = '<div class="spinner"></div>';
bereich.appendChild(liste);
const anzeige = document.createElement('pre');
anzeige.className = 'protokoll hidden';
bereich.appendChild(anzeige);
void (async () => {
let tage;
try { tage = await protokolltage(); } catch { bereich.classList.add('hidden'); return; }
liste.innerHTML = '';
if (tage.length === 0) {
liste.appendChild(leer('Noch nichts protokolliert.'));
return;
}
tage.forEach(t => {
const d = document.createElement('div');
d.className = 'einstellung';
const oben = document.createElement('div');
oben.className = 'knopfreihe';
const text = document.createElement('div');
text.className = 'zeile';
text.style.flex = '1';
const datum = new Date(t.date + 'T12:00:00').toLocaleDateString('de-CH',
{ weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' });
text.innerHTML = `<strong>${esc(datum)}</strong>`
+ `<span class="winzig">${Math.max(1, Math.round(t.bytes / 1024))} KB</span>`;
oben.appendChild(text);
const lesen = knopf('Ansehen', 'btn');
lesen.onclick = () => void zeigen(t.date, false);
const nurFehler = knopf('Nur Fehler', 'btn');
nurFehler.onclick = () => void zeigen(t.date, true);
const holen = document.createElement('a');
holen.className = 'btn';
holen.href = protokollAdresse(t.date);
holen.download = `flaro-${t.date}.log`;
holen.textContent = 'Herunterladen';
oben.append(lesen, nurFehler, holen);
d.appendChild(oben);
liste.appendChild(d);
});
})();
async function zeigen(tag: string, nurFehler: boolean): Promise<void> {
anzeige.classList.remove('hidden');
anzeige.textContent = 'Wird geladen…';
try {
const antwort = await fetch(protokollAdresse(tag));
if (!antwort.ok) throw new Error(`${antwort.status}`);
const text = await antwort.text();
const zeilen = text.split('\n');
// **Das Ende ist interessanter als der Anfang.** Ein Protokolltag kann
// zehntausend Zeilen haben; wer etwas sucht, sucht meist das Letzte.
const gefiltert = nurFehler
? zeilen.filter(z => z.includes('FEHLER') || z.includes('WARNUNG'))
: zeilen;
anzeige.textContent = gefiltert.length === 0
? (nurFehler ? 'Keine Fehler an diesem Tag.' : 'Leer.')
: gefiltert.slice(-800).join('\n');
anzeige.scrollTop = anzeige.scrollHeight;
} catch (fehler) {
anzeige.textContent = `Konnte nicht gelesen werden: ${(fehler as Error).message}`;
}
}
return bereich;
}
/* ---------- Nicht mehr gefunden ---------- */
/** Was in der Mediathek steht, aber nicht mehr auf der Platte liegt.
@@ -362,8 +450,10 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
for (let i = 0; i < 900; i++) {
if (signal.aborted) return;
const s = await scanstand(b.id);
if (s.state === 'running') stand.textContent = 'Wird durchsucht…';
else if (s.state === 'enriching') stand.textContent = 'Metadaten werden geholt…';
// **Was der Server sagt, nicht was wir raten.** Vorher stand hier
// minutenlang „Wird durchsucht…", ohne Hinweis, ob etwas passiert.
if (s.state === 'running') stand.textContent = s.step ?? 'Wird durchsucht…';
else if (s.state === 'enriching') stand.textContent = s.step ?? 'Metadaten werden geholt…';
else if (s.state === 'failed') { stand.textContent = s.error ?? 'Gescheitert.'; return; }
else if (s.state === 'finished') {
const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];