Ein Plan für das, was an keinem Aufruf hängt

Sichern, Datenbank aufräumen, verwaiste Bilder wegwerfen — alle drei Tage,
alle sieben Tage, von selbst.

**Der erste Hintergrunddienst in diesem Server, und das mit Bedacht.** An
drei Stellen steht sinngemäss, ein eigener Dienst wäre Aufwand ohne
Gegenwert, und für das Wegräumen abgelaufener Gerätecodes stimmt das:
solche Arbeit hängt an einem Aufruf, der ohnehin kommt. Eine Sicherung
hängt an nichts. Sie soll alle drei Tage geschehen, auch wenn niemand etwas
anklickt, und dafür gibt es eine Uhr. Alle fünfzehn Minuten eine Frage an
eine Tabelle mit einer Handvoll Zeilen; ist nichts fällig — der Normalfall
—, kostet es nichts.

Der letzte Lauf steht in der Datenbank, nicht im Arbeitsspeicher. Ein
Server, der jede Nacht neu startet, sicherte sonst jeden Morgen — oder,
bei einem Zähler, der bei null anfängt, nie. Auch ein Fehlschlag wird
vermerkt, sonst versuchte der Plan es alle fünfzehn Minuten wieder und
eine volle Platte erzeugte ein volles Protokoll.

**Die Sicherung ist `VACUUM INTO`, nicht Datei kopieren.** Die Datenbank
läuft im WAL-Modus: neben `flaro.db` stehen `-wal` und `-shm`, und wer nur
die erste Datei kopiert, bekommt einen Stand von vor Stunden oder einen
halben. `VACUUM INTO` schreibt eine vollständige, aufgeräumte Kopie im
laufenden Betrieb. Der Zielordner ist einstellbar und geht als gebundener
Wert in die Anweisung — eine Einstellung soll keine SQL-Anweisung werden.

Die Prüfung verlässt sich nicht darauf, dass eine Datei entstanden ist: sie
öffnet die Kopie als Datenbank und zählt die Zeilen. Eine Sicherung, die
sich nicht öffnen lässt, ist schlimmer als keine — man verlässt sich
darauf und merkt es erst, wenn es zu spät ist.

**Bilder nur, wenn verwaist.** Den ganzen Bildspeicher zu leeren gibt es
als Knopf und ist gefahrlos, kostet danach aber tausende Abrufe bei TMDB.
Hier fliegt nur, worauf kein Titel mehr zeigt — mit einer Stunde
Schonfrist, weil ein Bild abgelegt wird, bevor der Verweis darauf
gespeichert ist. Ohne diese Frist räumte ein Lauf mitten im Scan genau das
weg, was gerade geholt wurde.

**Eine eigene Seite „Wartung".** Die drei Kratzplatzzahlen standen unter
„Wiedergabe" — dort ist ihr Anlass, aber nicht ihr Thema. Sie ziehen mit
um: alles auf dieser Seite ist etwas, das der Server ohne Zutun erledigt,
und man sieht es an einem Ort nach. Je Aufgabe steht da, wann sie zuletzt
lief und was herauskam, dazu ein Knopf „Jetzt ausführen" — wer gleich etwas
Grosses vorhat, will die Kopie jetzt und nicht in drei Tagen.

725 Tests grün, davon elf neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-08 21:02:42 +02:00
co-authored by Claude Opus 5
parent c4af182d31
commit 532473b1f9
11 changed files with 1017 additions and 63 deletions
+21 -1
View File
@@ -1035,7 +1035,27 @@ export type Aufraeumbericht = {
export const kratzplatzAufraeumen = () =>
anfrage<Aufraeumbericht>('/system/storage/transcode/cleanup', { method: 'POST' });
export type Wartungseinstellung = { capGb: number; unusedDays: number; graceMinutes: number };
export type Wartungseinstellung = {
capGb: number; unusedDays: number; graceMinutes: number;
backupDays: number; backupKeep: number; backupFolder: string;
optimizeDays: number; imageDays: number;
};
/** Was eine Wartungsaufgabe zuletzt getan hat. */
export type Wartungsstand = {
aufgabe: 'Sicherung' | 'Optimieren' | 'Bilder';
tage: number;
zuletzt: string | null;
faellig: string | null;
ergebnis: string | null;
fehler: string | null;
};
export const wartungsstand = () => anfrage<Wartungsstand[]>('/system/maintenance');
/** Führt eine Wartungsaufgabe sofort aus, ohne auf den Plan zu warten. */
export const wartungAusfuehren = (aufgabe: string) =>
anfrage<{ ergebnis: string }>(`/system/maintenance/${aufgabe}`, { method: 'POST' });
export const wartungseinstellung = () =>
anfrage<Wartungseinstellung>('/settings/maintenance');
+5 -1
View File
@@ -10,12 +10,13 @@ import { wiedergabebereich } from './wiedergabe';
import { sprachbereich } from './metadaten';
import { benutzerbereich } from './benutzer';
import { protokollbereich } from './protokolle';
import { wartungsbereich } from './wartung';
/** Welcher Teil der Einstellungen offen ist. */
export type Bereich =
| 'konto' | 'geschaut'
| 'uebersicht' | 'mediatheken' | 'pruefung' | 'zuordnung'
| 'wiedergabe' | 'metadaten' | 'benutzer' | 'protokolle';
| 'wiedergabe' | 'metadaten' | 'benutzer' | 'wartung' | 'protokolle';
type Eintrag = { wert: Bereich; text: string; bild: IconName };
@@ -34,6 +35,7 @@ const SERVER: Eintrag[] = [
{ wert: 'wiedergabe', text: 'Wiedergabe', bild: 'stufen' },
{ wert: 'metadaten', text: 'Metadaten', bild: 'stift' },
{ wert: 'benutzer', text: 'Benutzer', bild: 'serien' },
{ wert: 'wartung', text: 'Wartung', bild: 'stufen' },
{ wert: 'protokolle', text: 'Protokolle', bild: 'protokoll' },
];
@@ -162,6 +164,8 @@ export function einstellungenseite(
return [sprachbereich()];
case 'benutzer':
return [benutzerbereich()];
case 'wartung':
return [wartungsbereich()];
case 'protokolle':
return [protokollbereich()];
case 'uebersicht':
+196
View File
@@ -0,0 +1,196 @@
import {
wartungsstand, wartungAusfuehren, wartungseinstellung, wartungSetzen,
type Wartungsstand, type Wartungseinstellung,
} from '../../api';
import { fehlerzeile, knopf, meldung, hinweisZeigen } from '../../util';
import { dialog, type Dialogfeld } from '../../dialog';
import { abschnitt, zeile } from './teile';
/** Was der Server in Abständen von selbst tut.
*
* **Warum das eine eigene Seite ist.** Die drei Zahlen zum Kratzplatz
* standen zuerst unter „Wiedergabe" — dort ist ihr Anlass, aber nicht ihr
* Thema. Sobald es eine Sicherung und ein Aufräumen der Datenbank gibt,
* gehören sie zusammen: alles hier ist etwas, das der Server ohne Zutun
* erledigt, und man sieht es an einem Ort nach.
*/
/** Wie die Aufgaben heissen, wenn man sie liest statt sie aufzurufen. */
const NAMEN: Record<Wartungsstand['aufgabe'], { titel: string; was: string }> = {
Sicherung: {
titel: 'Sicherung der Datenbank',
was: 'Eine vollständige Kopie, im laufenden Betrieb geschrieben. '
+ 'Das ist das Stück, das im Ernstfall zählt.',
},
Optimieren: {
titel: 'Datenbank aufräumen',
was: 'Packt die Datei zusammen und frischt die Statistiken auf, nach '
+ 'denen SQLite seine Abfragen plant. Sperrt dabei für ein paar Sekunden.',
},
Bilder: {
titel: 'Verwaiste Bilder entfernen',
was: 'Nur Bilder, auf die kein Titel mehr zeigt. Den ganzen Bildspeicher '
+ 'zu leeren gibt es als Knopf in der Übersicht — das kostet danach '
+ 'aber tausende Abrufe bei TMDB.',
},
};
export function wartungsbereich(): HTMLElement {
const bereich = document.createElement('section');
bereich.appendChild(abschnitt('Wartung'));
const liste = document.createElement('div');
liste.className = 'einstellungen';
liste.innerHTML = '<div class="spinner"></div>';
bereich.appendChild(liste);
void laden();
return bereich;
async function laden(): Promise<void> {
let stand: Wartungsstand[];
let werte: Wartungseinstellung;
try {
[stand, werte] = await Promise.all([wartungsstand(), wartungseinstellung()]);
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile(meldung(fehler)));
return;
}
liste.innerHTML = '';
stand.forEach(s => liste.appendChild(aufgabe(s, werte)));
/* ---------- Der Kratzplatz ---------- */
liste.appendChild(abschnitt('Kratzplatz'));
liste.appendChild(zeile(
'Obergrenze',
werte.capGb > 0 ? `${werte.capGb} GB` : 'Unbegrenzt',
'Wird sie überschritten, geht das am längsten Ungenutzte zuerst. '
+ 'Was gerade heruntergeladen wird, bleibt in jedem Fall. 0 heisst unbegrenzt.',
() => zahlFragen('Obergrenze', 'Gigabyte', werte.capGb, v => wartungSetzen({ capGb: v }))));
liste.appendChild(zeile(
'Unbenutzt löschen nach',
werte.unusedDays > 0 ? `${werte.unusedDays} Tagen` : 'Nie',
'Gilt auch unterhalb der Obergrenze: ein Topf, in dem seit Wochen '
+ 'nichts gebraucht wurde, besetzt ein Volume, das anderes braucht.',
() => zahlFragen('Unbenutzt löschen nach', 'Tagen', werte.unusedDays,
v => wartungSetzen({ unusedDays: v }))));
liste.appendChild(zeile(
'Schonfrist',
`${werte.graceMinutes} Minuten`,
'So lange bleibt eine frisch gebrauchte Datei in jedem Fall liegen. '
+ 'Weniger als fünf Minuten lässt der Server nicht zu — das löschte '
+ 'eine Datei mitten im Herunterladen weg.',
() => zahlFragen('Schonfrist', 'Minuten', werte.graceMinutes,
v => wartungSetzen({ graceMinutes: v }))));
}
/** Eine Aufgabe mit Abstand, letztem Lauf und einem Knopf zum Sofortstart. */
function aufgabe(s: Wartungsstand, werte: Wartungseinstellung): HTMLElement {
const { titel, was } = NAMEN[s.aufgabe];
const takt = s.tage > 0
? (s.tage === 1 ? 'Täglich' : `Alle ${s.tage} Tage`)
: 'Abgeschaltet';
// **Der letzte Lauf gehört in dieselbe Zeile wie der Takt.** „Alle 3
// Tage" allein sagt nicht, ob es je geschehen ist — und genau das will
// man bei einer Sicherung wissen.
const wann = s.fehler
? `zuletzt gescheitert: ${s.fehler}`
: s.zuletzt
? `zuletzt ${datum(s.zuletzt)}${s.ergebnis ? ` — ${s.ergebnis}` : ''}`
: 'noch nie gelaufen';
const d = zeile(titel, `${takt} · ${wann}`, was,
() => takteFragen(s, werte));
const jetzt = knopf('Jetzt ausführen', 'btn');
jetzt.onclick = async () => {
jetzt.disabled = true;
const vorher = jetzt.textContent;
jetzt.textContent = 'Läuft …';
try {
const { ergebnis } = await wartungAusfuehren(s.aufgabe.toLowerCase());
hinweisZeigen(`${titel}: ${ergebnis}`);
await laden();
} catch (fehler) {
hinweisZeigen(meldung(fehler), 'fehler');
jetzt.disabled = false;
jetzt.textContent = vorher;
}
};
d.querySelector('.knopfreihe')?.appendChild(jetzt);
return d;
}
/** Der Dialog je Aufgabe — Takt, und bei der Sicherung zwei Felder mehr. */
function takteFragen(s: Wartungsstand, werte: Wartungseinstellung): void {
const felder: Dialogfeld[] = [{
name: 'tage', beschriftung: 'Alle wie viele Tage (0 = aus)',
wert: String(s.tage), art: 'number',
}];
if (s.aufgabe === 'Sicherung') {
felder.push(
{
name: 'behalten', beschriftung: 'Wie viele Kopien behalten',
wert: String(werte.backupKeep), art: 'number',
},
{
name: 'ordner', beschriftung: 'Ordner (leer = neben der Datenbank)',
wert: werte.backupFolder, art: 'ordner',
hinweis: 'Am besten ein anderes Volume als die Datenbank — eine '
+ 'Kopie neben dem Original hilft gegen Versehen, nicht gegen '
+ 'eine kaputte Platte.',
});
}
dialog({
titel: NAMEN[s.aufgabe].titel,
felder,
bestaetigen: 'Übernehmen',
tun: async lesen => {
const tage = Number(lesen('tage'));
await wartungSetzen(
s.aufgabe === 'Sicherung'
? {
backupDays: tage,
backupKeep: Number(lesen('behalten')),
backupFolder: lesen('ordner'),
}
: s.aufgabe === 'Optimieren'
? { optimizeDays: tage }
: { imageDays: tage });
hinweisZeigen('Übernommen.');
await laden();
},
});
}
function zahlFragen(
titel: string, beschriftung: string, wert: number,
setzen: (wert: number) => Promise<unknown>,
): void {
dialog({
titel,
felder: [{ name: 'wert', beschriftung, wert: String(wert), art: 'number' }],
bestaetigen: 'Übernehmen',
tun: async lesen => {
await setzen(Number(lesen('wert')));
hinweisZeigen('Übernommen.');
await laden();
},
});
}
}
function datum(wert: string): string {
return new Date(wert).toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' });
}
+2 -54
View File
@@ -1,6 +1,4 @@
import {
wiedergabeEinstellung, wiedergabeSetzen, wartungseinstellung, wartungSetzen,
} from '../../api';
import { wiedergabeEinstellung, wiedergabeSetzen } from '../../api';
import { fehlerzeile, meldung, hinweisZeigen } from '../../util';
import { dialog } from '../../dialog';
@@ -49,9 +47,8 @@ export function wiedergabebereich(): HTMLElement {
async function laden(): Promise<void> {
let stand;
let wartung;
try {
[stand, wartung] = await Promise.all([wiedergabeEinstellung(), wartungseinstellung()]);
stand = await wiedergabeEinstellung();
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile(meldung(fehler)));
@@ -109,55 +106,6 @@ export function wiedergabebereich(): HTMLElement {
wert: stand.encoder,
setzen: v => wiedergabeSetzen({ encoder: v }),
})));
/* ---------- Was die Wiedergabe auf der Platte hinterlässt ---------- */
// **Hier und nicht unter einem eigenen Punkt.** Der Kratzplatz ist, was
// die Wiedergabe hinterlässt: umgepackte Dateien, Segmente, Untertitel.
// Drei Felder rechtfertigen keine eigene Seite in der Leiste.
liste.appendChild(abschnitt('Kratzplatz'));
liste.appendChild(zeile(
'Obergrenze',
wartung.capGb > 0 ? `${wartung.capGb} GB` : 'Unbegrenzt',
'Wird sie überschritten, geht das am längsten Ungenutzte zuerst. '
+ 'Was gerade heruntergeladen wird, bleibt in jedem Fall. 0 heisst unbegrenzt.',
() => zahlFragen('Obergrenze', 'Gigabyte', String(wartung!.capGb),
v => wartungSetzen({ capGb: Number(v) }))));
liste.appendChild(zeile(
'Unbenutzt löschen nach',
wartung.unusedDays > 0 ? `${wartung.unusedDays} Tagen` : 'Nie',
'Gilt auch unterhalb der Obergrenze: ein Topf, in dem seit Wochen '
+ 'nichts gebraucht wurde, besetzt ein Volume, das anderes braucht. '
+ '0 heisst: nur die Obergrenze zählt.',
() => zahlFragen('Unbenutzt löschen nach', 'Tagen', String(wartung!.unusedDays),
v => wartungSetzen({ unusedDays: Number(v) }))));
liste.appendChild(zeile(
'Schonfrist',
`${wartung.graceMinutes} Minuten`,
'So lange bleibt eine frisch gebrauchte Datei in jedem Fall liegen. '
+ 'Weniger als fünf Minuten lässt der Server nicht zu — das löschte '
+ 'eine Datei mitten im Herunterladen weg.',
() => zahlFragen('Schonfrist', 'Minuten', String(wartung!.graceMinutes),
v => wartungSetzen({ graceMinutes: Number(v) }))));
}
/** Ein Dialog für eine einzelne Zahl. */
function zahlFragen(
titel: string, beschriftung: string, wert: string, setzen: (wert: string) => Promise<unknown>,
): void {
dialog({
titel,
felder: [{ name: 'wert', beschriftung, wert, art: 'number' }],
bestaetigen: 'Übernehmen',
tun: async lesen => {
await setzen(lesen('wert'));
hinweisZeigen('Übernommen.');
await laden();
},
});
}
/** Ein Auswahldialog, der danach meldet, was er umgeworfen hat. */