Die Verwaltung zeichnete sich endlos neu

Gemessen am laufenden Server: nach dem Scan lief eine Schleife mit zwei
Abrufen je Runde, sichtbar als vier dauernd drehende Spinner.

Der Weg dahin: verfolgen() lief bei jedem Aufbau der Mediathekenzeile, sah
den Zustand finished, meldete das über geaendert(), und geaendert() war
mediathekenNeuLaden — also neu zeichnen. Der Neuaufbau startete verfolgen()
erneut, das wieder finished sah. Dass der Lauf längst vorbei war, machte
keinen Unterschied; der Zustand bleibt im Speicher stehen.

Zwei Dinge dagegen. verfolgen() meldet nur noch, wenn es selbst einen Lauf
angestossen hat — beim Aufbau zeigt es den Stand an und schweigt. Und die
Verwaltung frischt über zaehlerAuffrischen() nur noch die Seitenleiste auf
statt die ganze Seite neu zu zeichnen.

Dazu zwei Befunde aus dem Plan, die sich beim Nachsehen als falsch
erwiesen: die Takte werden sehr wohl abbestellt, dafür gibt es seit je
einen MutationObserver, und das Dashboard tauscht seinen Inhalt erst nach
dem Abruf. Geblieben ist der Spinner in den Listen, der bei jedem
Auffrischen erschien — der kommt jetzt nur noch beim ersten Aufbau.

Dazu der Zwischenspeicher für Ansichten. Der Router hängt sie aus statt
sie zu zerstören; beim Zurückkommen stehen sie sofort wieder da, mitsamt
Bildlauf, offenem Reiter und gesetzten Filtern, und laden leise nach.
Gemessen: null Abrufe und 4 von 4 Kacheln beim Zurückkommen aus der
Verwaltung. Höchstens fünf Ansichten, Verfall nach fünf Minuten,
weggeworfen bei allem, was den Bestand ändert. Detailseite und Verwaltung
sind ausgenommen — beide zeigen Laufendes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 12:11:06 +02:00
co-authored by Claude Opus 5
parent 8b67a7ec88
commit 637e95fbb1
5 changed files with 212 additions and 21 deletions
+131
View File
@@ -0,0 +1,131 @@
/* Ansichten überleben einen kurzen Wechsel.
*
* **Das Problem.** Der Router warf die Mediathek weg und baute sie beim
* Zurückkommen von Grund auf neu: 458 Kacheln, ein Netzabruf, und die
* Bildlaufposition war hin. Für einen Sprung in die Einstellungen und
* zurück ist das zu viel Aufwand für zu wenig Nutzen.
*
* **Was hier passiert.** Der Knoten einer Ansicht wird beim Verlassen
* ausgehängt statt zerstört. Kommt man zurück, hängt er wieder dort, wo er
* war — mitsamt Bildlauf, offenem Reiter und gesetzten Filtern. Im
* Hintergrund frischt er sich auf, damit nicht stillschweigend Altes
* dasteht.
*
* **Was hier nicht passiert.** Die Detailseite wird nicht behalten. Sie ist
* billig gebaut, und ein veralteter Sehfortschritt darauf wäre ärgerlicher
* als der Neuaufbau. Die Verwaltung auch nicht — sie zeigt Laufendes. */
/** Wie viele Ansichten gleichzeitig vorgehalten werden. */
const HOECHSTENS = 5;
/** Nach dieser Zeit wird neu gebaut statt wiederverwendet. */
const VERFALL_MS = 5 * 60 * 1000;
type Eintrag = {
knoten: HTMLElement;
abbruch: AbortController;
scroll: number;
gebaut: number;
/** Wird beim Wiedereinhängen gerufen — holt im Hintergrund Frisches. */
auffrischen?: () => void;
};
const gemerkt = new Map<string, Eintrag>();
/** Was eine Ansicht beim Zurückkommen nachladen will.
*
* Als `WeakMap` und nicht als Eigenschaft am Knoten: so hängt nichts am DOM,
* und mit dem Knoten verschwindet auch der Eintrag. */
const nachlader = new WeakMap<HTMLElement, () => void>();
/** Meldet an, was beim Wiedereinhängen im Hintergrund geholt werden soll.
*
* **Ohne das sähe man beim Zurückkommen veraltete Daten und merkte es nicht.**
* Die Ansicht steht sofort da — das Frische kommt, wenn es da ist. */
export function beiRueckkehr(knoten: HTMLElement, tun: () => void): void {
nachlader.set(knoten, tun);
}
/** Die Ansicht, die gerade hängt — damit ihr Bildlauf beim Verlassen mitkommt. */
let aktuell: { schluessel: string; eintrag: Eintrag } | null = null;
/** Hängt die laufende Ansicht aus und merkt sich, wo sie stand. */
export function ablegen(): void {
if (!aktuell) return;
aktuell.eintrag.scroll = window.scrollY;
aktuell.eintrag.knoten.remove();
aktuell = null;
}
/** Holt eine Ansicht aus dem Speicher oder baut sie — und hängt sie ein.
*
* `bauen` bekommt ein `AbortSignal`. Jede Ansicht, die einen Takt setzt oder
* nachlädt, prüft es, bevor sie weitermacht: ohne das liefen nach zehn
* Wechseln zehn Takte nebeneinander. */
export function einhaengen(
schluessel: string,
ziel: HTMLElement,
bauen: (signal: AbortSignal) => HTMLElement,
optionen: { merken?: boolean } = {},
): void {
ablegen();
const merken = optionen.merken ?? false;
const vorhanden = merken ? gemerkt.get(schluessel) : undefined;
const frisch = vorhanden && Date.now() - vorhanden.gebaut < VERFALL_MS;
if (vorhanden && !frisch) {
vorhanden.abbruch.abort();
gemerkt.delete(schluessel);
}
const eintrag: Eintrag = frisch ? vorhanden : (() => {
const abbruch = new AbortController();
return { knoten: bauen(abbruch.signal), abbruch, scroll: 0, gebaut: Date.now() };
})();
ziel.appendChild(eintrag.knoten);
if (frisch) {
// **Erst hängen, dann scrollen.** Vor dem Einhängen hat das Dokument die
// Höhe noch nicht, und `scrollTo` landete auf 0.
window.scrollTo(0, eintrag.scroll);
// **Danach nachladen, nicht davor.** Die alte Ansicht steht sofort da,
// das Frische kommt, wenn es da ist. Umgekehrt sähe man wieder einen
// Spinner, und der ganze Aufwand wäre umsonst.
nachlader.get(eintrag.knoten)?.();
} else {
window.scrollTo(0, 0);
}
if (!merken) {
aktuell = { schluessel, eintrag };
return;
}
gemerkt.delete(schluessel);
gemerkt.set(schluessel, eintrag);
aktuell = { schluessel, eintrag };
// Die am längsten nicht benutzte fliegt raus. `Map` hält die Einfügereihen-
// folge, und durch das Löschen oben rutscht jede Benutzung ans Ende.
while (gemerkt.size > HOECHSTENS) {
const aeltester = gemerkt.keys().next().value!;
gemerkt.get(aeltester)!.abbruch.abort();
gemerkt.delete(aeltester);
}
}
/** Wirft gemerkte Ansichten weg — alle, oder die mit diesem Anfang.
*
* Gerufen von allem, was den Bestand ändert: ein fertiger Scan, eine neue
* Mediathek, ein veröffentlichter Titel. **Nicht** von „gesehen" — das ändert
* eine Kachel, und dafür die ganze Ansicht wegzuwerfen wäre Verschwendung. */
export function vergessen(praefix?: string): void {
for (const [schluessel, eintrag] of [...gemerkt]) {
if (praefix && !schluessel.startsWith(praefix)) continue;
if (aktuell?.schluessel === schluessel) continue;
eintrag.abbruch.abort();
gemerkt.delete(schluessel);
}
}
+2
View File
@@ -83,6 +83,8 @@ export type Lieferung = {
export type Scanstand = {
state: string; filesFound: number | null; added: number | null;
removed: number | null; unrecognised: number | null; probed: number | null;
/** Wie viele Dateien dieser Lauf als verschwunden gemeldet hat. */
missing: number | null;
problems: { path: string; reason: string }[] | null; error: string | null;
metadata: { moviesMatched: number; seriesMatched: number; episodesMatched: number;
skipped: boolean; error: string | null } | null;
+37 -7
View File
@@ -5,6 +5,7 @@ import {
} from './api';
import { icons, icon } from './icons';
import { einrichtung } from './views/setup';
import { einhaengen, ablegen, vergessen } from './ansichten';
import { anmeldung } from './views/login';
import { startseite } from './views/home';
import { mediathek } from './views/library';
@@ -76,6 +77,19 @@ export async function mediathekenNeuLaden(): Promise<void> {
zeichnen();
}
/** Frischt nur die Seitenleiste auf — ohne die Ansicht neu zu zeichnen.
*
* **Der Unterschied ist wichtiger, als er aussieht.** Die Verwaltung meldete
* nach jedem Scan „fertig" und liess neu zeichnen; der Neuaufbau startete die
* Verfolgung erneut, die sah wieder „fertig" und meldete wieder — eine
* Endlosschleife mit zwei Abrufen je Runde, die sich als vier dauernd
* drehende Spinner zeigte. Ein neuer Zählwert in der Seitenleiste braucht
* keinen Neuaufbau der Seite. */
export async function zaehlerAuffrischen(): Promise<void> {
mediatheken = await bibliotheken();
leisteBauen();
}
/* ---------- Kopfleiste ---------- */
function kopfleisteBauen(): void {
@@ -166,8 +180,10 @@ function zeichnen(): void {
b.classList.toggle('check', ziel === `#/${teile.slice(0, 2).join('/')}`);
});
// **Aushängen, nicht leeren.** `innerHTML = ''` zerstörte die Ansicht —
// mitsamt Bildlauf, offenem Reiter und gesetzten Filtern.
ablegen();
inhalt.innerHTML = '';
window.scrollTo(0, 0);
const oeffnen = (art: 'filme' | 'serien', id: string) => { location.hash = `#/${art}/${id}`; };
const alleZeigen = (b: Bibliothek) => { location.hash = `#/m/${b.id}`; };
@@ -176,20 +192,34 @@ function zeichnen(): void {
const kennung = teile[1];
if (art && kennung) {
inhalt.appendChild(detailseite(art, kennung, () => history.back()));
// **Die Detailseite wird nicht gemerkt.** Sie ist billig gebaut, und ein
// veralteter Sehfortschritt darauf wäre ärgerlicher als der Neuaufbau.
einhaengen(weg, inhalt, () => detailseite(art, kennung, () => history.back()));
} else if (teile[0] === 'm' && kennung) {
const b = mediatheken.find(x => x.id === kennung);
// Eine Mediathek, die es nicht mehr gibt — etwa, weil sie in einem
// anderen Fenster gelöscht wurde. Zurück auf Start ist hier richtiger
// als eine Fehlermeldung über eine Kennung, die niemand gelesen hat.
if (b) inhalt.appendChild(mediathek(b, serverName, teile[2] ?? 'mediathek', oeffnen));
else location.hash = '#/';
if (b) {
einhaengen(weg, inhalt,
() => mediathek(b, serverName, teile[2] ?? 'mediathek', oeffnen),
{ merken: true });
} else location.hash = '#/';
} else if (teile[0] === 'suche' && kennung) {
inhalt.appendChild(suchseite(decodeURIComponent(kennung), oeffnen));
einhaengen(weg, inhalt, () => suchseite(decodeURIComponent(kennung), oeffnen),
{ merken: true });
} else if (teile[0] === 'verwaltung' && istAdmin()) {
inhalt.appendChild(verwaltung(mediathekenNeuLaden));
// **Die Verwaltung auch nicht** — sie zeigt Laufendes. Ihr Takt hängt am
// Signal und hört auf, sobald man weggeht.
einhaengen(weg, inhalt, signal => verwaltung(() => {
// Gemerkte Ansichten wegwerfen: der Bestand hat sich geändert.
// Die Seitenleiste bekommt neue Zahlen — die Seite selbst bleibt.
vergessen();
void zaehlerAuffrischen();
}, signal));
} else {
inhalt.appendChild(startseite(mediatheken, oeffnen, alleZeigen));
einhaengen(weg, inhalt, () => startseite(mediatheken, oeffnen, alleZeigen),
{ merken: true });
}
inhalt.appendChild(hinweisleiste());
+34 -13
View File
@@ -20,10 +20,13 @@ import { icon } from '../icons';
*
* `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat; die
* Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */
export function verwaltung(geaendert: () => void = () => {}): HTMLElement {
export function verwaltung(
geaendert: () => void = () => {},
signal: AbortSignal = new AbortController().signal,
): HTMLElement {
const wurzel = document.createElement('div');
wurzel.append(dashboard(), vermisstenbereich(geaendert), bibliotheksbereich(geaendert),
benutzerbereich(), serverbereich(geaendert));
wurzel.append(dashboard(signal), vermisstenbereich(geaendert),
bibliotheksbereich(geaendert, signal), benutzerbereich(), serverbereich(geaendert));
return wurzel;
}
@@ -51,7 +54,7 @@ function abschnitt(titel: string, knoepfe: HTMLElement[] = []): HTMLElement {
* **Fragt alle fünf Sekunden nach.** Ein dauerhaft offener Kanal wäre
* sparsamer, aber für eine Handvoll Zeilen auf einer Seite, die man selten
* offen hat, ist er der grössere Aufwand — auf beiden Seiten. */
function dashboard(): HTMLElement {
function dashboard(signal: AbortSignal): HTMLElement {
const bereich = document.createElement('section');
bereich.appendChild(abschnitt('Wird abgespielt'));
@@ -94,6 +97,10 @@ function dashboard(): HTMLElement {
liste.appendChild(fehlerzeile((fehler as Error).message));
return; // Nicht weiter nachfragen, wenn es scheitert.
}
// **Nur weitermachen, solange die Ansicht hängt.** Ohne diese Zeile
// legte jeder Besuch der Verwaltung einen weiteren Fünf-Sekunden-Takt
// an, und nach zehn Wechseln liefen zehn davon nebeneinander.
if (signal.aborted) return;
timer = window.setTimeout(() => void laden(), 5000);
}
@@ -229,7 +236,7 @@ function vermisstenbereich(geaendert: () => void): HTMLElement {
/* ---------- Bibliotheken ---------- */
function bibliotheksbereich(geaendert: () => void): HTMLElement {
function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLElement {
const bereich = document.createElement('section');
const anlegen = knopf('Mediathek hinzufügen', 'btn btn-primary');
@@ -264,7 +271,10 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
void laden();
async function laden(): Promise<void> {
liste.innerHTML = '<div class="spinner"></div>';
// **Der Spinner gehört an den ersten Aufbau, nicht an jedes Auffrischen.**
// Sonst leert sich die Liste sichtbar, bevor dasselbe wieder erscheint —
// und bei einem Takt im Hintergrund blinkt sie dauernd.
if (liste.childElementCount === 0) liste.innerHTML = '<div class="spinner"></div>';
try {
const alle = await bibliotheken();
liste.innerHTML = '';
@@ -302,7 +312,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
const los = knopf('Scannen', 'btn');
los.onclick = async () => {
los.disabled = true;
try { await scanStarten(b.id); await verfolgen(); }
try { await scanStarten(b.id); await verfolgen(true); }
catch (fehler) { stand.textContent = (fehler as Error).message; }
los.disabled = false;
};
@@ -316,7 +326,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
+ 'nicht über eine neue Suche. Zuordnungen von Hand bleiben damit stehen. '
+ 'Bei vielen Titeln dauert es.',
bestaetigen: 'Auffrischen',
tun: async () => { await metadatenAuffrischen(b.id); void verfolgen(); },
tun: async () => { await metadatenAuffrischen(b.id); void verfolgen(true); },
});
const umbenennen = knopf('Umbenennen', 'btn');
@@ -341,8 +351,16 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
});
/** Fragt nach, bis der Lauf durch ist — er läuft im Hintergrund. */
async function verfolgen(): Promise<void> {
/// Verfolgt einen Lauf bis zum Ende.
///
/// **`melden` entscheidet, ob am Schluss `geaendert()` gerufen wird.**
/// Beim Aufbau der Seite ist es `false`: ein längst beendeter Lauf meldete
/// sonst „fertig", liess neu zeichnen, und der Neuaufbau verfolgte wieder
/// denselben beendeten Lauf — eine Endlosschleife, die sich als vier
/// dauernd drehende Spinner zeigte.
async function verfolgen(melden: boolean): Promise<void> {
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…';
@@ -353,9 +371,9 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
if (s.metadata?.skipped) teile.push('Metadaten übersprungen');
else if (s.metadata) teile.push(`${s.metadata.moviesMatched + s.metadata.seriesMatched} zugeordnet`);
if (s.metadata?.error) teile.push(s.metadata.error);
if (s.missing) teile.push(`${s.missing} nicht mehr gefunden`);
stand.textContent = teile.join(' · ');
await laden();
geaendert();
if (melden) { await laden(); geaendert(); }
return;
}
await new Promise(r => setTimeout(r, 1000));
@@ -366,7 +384,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
knoepfe.className = 'knopfreihe';
knoepfe.append(los, auffrischen, umbenennen, entfernen);
d.append(knoepfe, stand);
void verfolgen();
void verfolgen(false);
return d;
}
@@ -404,7 +422,10 @@ function benutzerbereich(): HTMLElement {
void laden();
async function laden(): Promise<void> {
liste.innerHTML = '<div class="spinner"></div>';
// **Der Spinner gehört an den ersten Aufbau, nicht an jedes Auffrischen.**
// Sonst leert sich die Liste sichtbar, bevor dasselbe wieder erscheint —
// und bei einem Takt im Hintergrund blinkt sie dauernd.
if (liste.childElementCount === 0) liste.innerHTML = '<div class="spinner"></div>';
try {
const alle = await benutzer();
liste.innerHTML = '';
+8 -1
View File
@@ -2,6 +2,7 @@ import { inhalt, genresVon, empfehlungen as holeEmpfehlungen, type Bibliothek, t
import { leer, fehlerzeile } from '../util';
import { icons } from '../icons';
import { kachel, alsKachel, reihe } from '../card';
import { beiRueckkehr } from '../ansichten';
type Art = 'filme' | 'serien';
type Reiter = 'empfehlungen' | 'mediathek' | 'kategorien';
@@ -181,8 +182,14 @@ export function mediathek(
void laden();
// Beim Zurückkommen aus einer anderen Ansicht leise nachladen — die alte
// Liste bleibt stehen, bis die neue da ist.
beiRueckkehr(wurzel, () => void laden());
async function laden(): Promise<void> {
gitter.innerHTML = '<div class="spinner"></div>';
// Der Spinner nur beim ersten Mal; beim Nachladen bleibt stehen, was da
// ist, bis es ersetzt wird.
if (gitter.childElementCount === 0) gitter.innerHTML = '<div class="spinner"></div>';
try {
const seite = await inhalt(bibliothek, { search: suche, sort: ordnung, genre, take: 200 });