Der Abschnitt verschwand alle drei Sekunden und kam wieder

`laden()` setzte `innerHTML = ''` und baute die Mediathekenliste neu —
während eines Scans alle drei Sekunden. Der ganze Abschnitt flackerte
sichtbar, und wer gerade auf einen Knopf zielte, klickte ins Leere.
Schlimmer: jeder Neuaufbau startete über `verfolgen(false)` einen
weiteren Takt. Nach einer Minute liefen zwanzig davon nebeneinander.

Jetzt wird fortgeschrieben statt neu gezeichnet: eine Zeile je
Mediathek, die bleibt, und nur Kopfzeile, Prüfungsknopf und
Freigabeknopf ändern sich. Belegt im Browser — nach einem Auffrischen
sind es dieselben Knoten, nur die Beschriftung ist eine andere.

Dieselbe Behandlung für die Seitenleiste. Und weil Seitenleiste und
Verwaltung getrennt nachfragten, zeigten sie zwei Momente — 95 hier, 84
dort, während im Hintergrund Doppelte zusammengelegt wurden. Die
Verwaltung reicht ihren Stand jetzt weiter: ein Abruf, zwei Anzeigen.

Dazu zwei Dinge, die beim Prüfen auffielen. Zurücksetzen und Entfernen
prallen jetzt serverseitig mit 409 ab, solange ein Lauf läuft — die
Oberfläche sperrt die Knöpfe, aber darauf darf sich der Server nicht
verlassen; ein Aufruf von Hand während eines Scans liess den Lauf an
einer DbUpdateConcurrencyException sterben. Und in der
Fortschrittszeile steht beim Holen der Folgenmetadaten jetzt der
Serienname: „Folge 2×13" allein sagt bei 3815 Folgen nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 18:21:43 +02:00
co-authored by Claude Opus 5
parent a6e9a6e425
commit 960cca3dae
16 changed files with 311 additions and 85 deletions
+32 -5
View File
@@ -86,9 +86,33 @@ export async function mediathekenNeuLaden(): Promise<void> {
* 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();
export async function zaehlerAuffrischen(bekannt?: Bibliothek[]): Promise<void> {
// **Denselben Stand zeigen, nicht denselben Takt haben.**
//
// Beide Anzeigen fragten getrennt nach — und zeigten damit zwei Momente:
// in der Seitenleiste stand 95, in der Verwaltung 84. Während die
// Metadaten laufen, legt der Server Doppelte zusammen, die Zahl fällt also
// laufend. Wer hier eine Liste mitgibt, hat sie gerade selbst geholt; dann
// wird sie benutzt statt eine zweite zu ziehen.
const frisch = bekannt ?? await bibliotheken();
// **Nur die Zahlen, solange die Liste dieselbe ist.**
//
// `leisteBauen()` ersetzt die ganze Seitenleiste. Im Dreisekundentakt eines
// laufenden Scans heisst das: sie verschwindet sichtbar und kommt wieder,
// der Mauszeiger steht plötzlich über einem anderen Eintrag, und ein
// gerade gezogener Eintrag wird mitten in der Bewegung weggerissen.
const gleich = frisch.length === mediatheken.length
&& frisch.every((b, i) => b.id === mediatheken[i]!.id && b.name === mediatheken[i]!.name);
mediatheken = frisch;
if (!gleich) { leisteBauen(); return; }
frisch.forEach(b => {
const zeile = leiste.querySelector<HTMLElement>(`[data-ziel="#/m/${b.id}"] .zahl`);
if (zeile) zeile.textContent = String(b.itemCount ?? '');
});
}
/* ---------- Kopfleiste ---------- */
@@ -373,11 +397,14 @@ function zeichnen(): void {
} else if (teile[0] === 'verwaltung' && istAdmin()) {
// **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(() => {
einhaengen(weg, inhalt, signal => verwaltung(bekannt => {
// Gemerkte Ansichten wegwerfen: der Bestand hat sich geändert.
// Die Seitenleiste bekommt neue Zahlen — die Seite selbst bleibt.
vergessen();
void zaehlerAuffrischen();
// `bekannt` ist der Stand, den die Verwaltung gerade geholt hat. Ihn
// weiterzureichen heisst: beide zeigen dieselbe Zahl, nicht nur
// ähnliche.
void zaehlerAuffrischen(bekannt);
}, signal));
} else {
einhaengen(weg, inhalt, () => startseite(mediatheken, oeffnen, alleZeigen),
+144 -65
View File
@@ -26,7 +26,7 @@ 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 = () => {},
geaendert: (bekannt?: Bibliothek[]) => void = () => {},
signal: AbortSignal = new AbortController().signal,
): HTMLElement {
const wurzel = document.createElement('div');
@@ -655,7 +655,15 @@ function vermisstenbereich(geaendert: () => void): HTMLElement {
/* ---------- Bibliotheken ---------- */
function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLElement {
/** Eine gezeichnete Mediathekszeile und der Weg, sie fortzuschreiben. */
type Mediathekzeile = {
wurzel: HTMLElement;
aktualisieren: (b: Bibliothek) => void;
};
function bibliotheksbereich(
geaendert: (bekannt?: Bibliothek[]) => void, signal: AbortSignal,
): HTMLElement {
const bereich = document.createElement('section');
@@ -695,57 +703,108 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
const liste = document.createElement('div');
liste.className = 'einstellungen';
bereich.appendChild(liste);
/** Die gezeichneten Zeilen, je Mediathek.
*
* **Vor dem ersten `laden()`.** Stand die Map darunter, lief der erste
* Aufruf in die temporale Totzone von `const` — „Cannot access 'zeilen'
* before initialization", und der ganze Abschnitt blieb leer.
*/
const zeilen = new Map<string, Mediathekzeile>();
void laden();
async function laden(): Promise<void> {
// **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 = '';
if (alle.length === 0) {
liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.'));
return;
}
alle.forEach(b => liste.appendChild(block(b)));
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile(meldung(fehler)));
/** **Auffrischen heisst fortschreiben, nicht neu zeichnen.**
*
* Hier stand `liste.innerHTML = ''` und danach ein Neuaufbau — alle drei
* Sekunden, während ein Scan läuft. Der ganze Abschnitt verschwand dabei
* sichtbar und kam wieder; wer gerade zielte, klickte ins Leere.
*
* Schlimmer noch: jeder Neuaufbau startete über `verfolgen(false)` einen
* weiteren Takt. Nach einer Minute liefen zwanzig davon nebeneinander und
* fragten im Sekundentakt denselben Scanstand ab.
*/
async function laden(): Promise<Bibliothek[] | undefined> {
if (zeilen.size === 0 && liste.childElementCount === 0) {
liste.innerHTML = '<div class="spinner"></div>';
}
let alle: Bibliothek[];
try { alle = await bibliotheken(); }
catch (fehler) {
// Beim Auffrischen bleibt stehen, was da ist: ein Aussetzer darf keine
// Liste wegräumen, die noch stimmt.
if (zeilen.size === 0) { liste.innerHTML = ''; liste.appendChild(fehlerzeile(meldung(fehler))); }
return undefined;
}
if (alle.length === 0) {
zeilen.clear();
liste.innerHTML = '';
liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.'));
return alle;
}
// Spinner oder Hinweis weg — aber nur beim allerersten Mal.
if (zeilen.size === 0) liste.innerHTML = '';
// Was es nicht mehr gibt, verschwindet.
[...zeilen.keys()]
.filter(id => !alle.some(b => b.id === id))
.forEach(id => { zeilen.get(id)!.wurzel.remove(); zeilen.delete(id); });
alle.forEach((b, i) => {
let zeile = zeilen.get(b.id);
if (zeile) zeile.aktualisieren(b);
else { zeile = block(b); zeilen.set(b.id, zeile); }
// Reihenfolge angleichen, ohne alles anzufassen: `insertBefore` mit
// einem Knoten, der schon dort steht, tut nichts.
if (liste.children[i] !== zeile.wurzel) {
liste.insertBefore(zeile.wurzel, liste.children[i] ?? null);
}
});
return alle;
}
function block(b: Bibliothek): HTMLElement {
function block(b: Bibliothek): Mediathekzeile {
// Die Knöpfe lesen den Stand über diese Grösse, nicht über `b` — sonst
// arbeiteten sie nach dem ersten Auffrischen mit veralteten Werten.
let aktuell = b;
const d = document.createElement('div');
d.className = 'einstellung';
const serie = b.kind === 'Shows';
// „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat.
const anzahl = b.itemCount == null ? ''
: ` · ${b.itemCount} ` + (b.itemCount === 1
? (serie ? 'Serie' : 'Film')
: (serie ? 'Serien' : 'Filme'));
// Was wartet, gehört in dieselbe Zeile — sonst erfährt man es nur, wenn
// man bis zur Prüfliste weiterscrollt.
const wartet = b.pendingCount > 0
? ` · ${b.pendingCount} wartet${b.pendingCount === 1 ? '' : 'en'} auf Freigabe`
: '';
const kopf = document.createElement('div');
kopf.className = 'zeile';
kopf.innerHTML = `<strong>${esc(b.name)}</strong>`
+ `<span class="winzig">${esc(b.path)}${esc(anzahl)}${esc(wartet)}</span>`;
d.appendChild(kopf);
function kopfSchreiben(): void {
const serie = aktuell.kind === 'Shows';
// „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat.
const anzahl = aktuell.itemCount == null ? ''
: ` · ${aktuell.itemCount} ` + (aktuell.itemCount === 1
? (serie ? 'Serie' : 'Film')
: (serie ? 'Serien' : 'Filme'));
// Was wartet, gehört in dieselbe Zeile — sonst erfährt man es nur, wenn
// man bis zur Prüfliste weiterscrollt.
const wartet = aktuell.pendingCount > 0
? ` · ${aktuell.pendingCount} wartet${aktuell.pendingCount === 1 ? '' : 'en'} auf Freigabe`
: '';
kopf.innerHTML = `<strong>${esc(aktuell.name)}</strong>`
+ `<span class="winzig">${esc(aktuell.path)}${esc(anzahl)}${esc(wartet)}</span>`;
}
kopfSchreiben();
const stand = document.createElement('div');
stand.className = 'hinweis';
const los = knopf('Scannen', 'btn');
los.onclick = async () => {
laufendSetzen(true);
try { await scanStarten(b.id); await verfolgen(true); }
try { await scanStarten(aktuell.id); await verfolgen(true); }
catch (fehler) { stand.textContent = meldung(fehler); laufendSetzen(false); }
};
@@ -777,14 +836,14 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — '
+ 'nötig nach einer Sprachänderung.';
auffrischen.onclick = () => nachfragen({
titel: `Metadaten von „${b.name}" auffrischen?`,
titel: `Metadaten von „${aktuell.name}" auffrischen?`,
text: 'Alle Titel werden erneut bei TMDB geholt — über die bekannte Kennung, '
+ 'nicht über eine neue Suche. Zuordnungen von Hand bleiben damit stehen. '
+ 'Bei vielen Titeln dauert es.',
bestaetigen: 'Auffrischen',
tun: async () => {
laufendSetzen(true);
await metadatenAuffrischen(b.id);
await metadatenAuffrischen(aktuell.id);
void verfolgen(true);
},
});
@@ -792,10 +851,10 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
const umbenennen = knopf('Umbenennen', 'btn');
umbenennen.onclick = () => dialog({
titel: 'Mediathek umbenennen',
felder: [{ name: 'name', beschriftung: 'Name', wert: b.name }],
felder: [{ name: 'name', beschriftung: 'Name', wert: aktuell.name }],
bestaetigen: 'Speichern',
tun: async wert => {
await bibliothekUmbenennen(b.id, wert('name'));
await bibliothekUmbenennen(aktuell.id, wert('name'));
await laden();
geaendert();
},
@@ -807,14 +866,14 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
// einmal; genau das war bisher der einzige Weg.
const zuruecksetzen = knopf('Zurücksetzen', 'btn');
zuruecksetzen.onclick = () => nachfragen({
titel: `„${b.name}" zurücksetzen?`,
titel: `„${aktuell.name}" zurücksetzen?`,
text: 'Alle erfassten Titel, Zuordnungen und der Sehfortschritt dieser '
+ 'Mediathek werden verworfen; Name, Pfad und Einstellungen bleiben. '
+ 'Die Mediendateien auf der Platte bleiben unangetastet. Danach einmal '
+ 'scannen, und der Bestand entsteht neu.',
bestaetigen: 'Zurücksetzen',
tun: async () => {
await mediathekZuruecksetzen(b.id);
await mediathekZuruecksetzen(aktuell.id);
await laden();
geaendert();
},
@@ -822,11 +881,11 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
const entfernen = knopf('Entfernen', 'btn');
entfernen.onclick = () => nachfragen({
titel: `„${b.name}" entfernen?`,
titel: `„${aktuell.name}" entfernen?`,
text: 'Die Mediendateien auf der Platte bleiben unangetastet. Weg sind die '
+ 'Einträge, die Zuordnungen und der Sehfortschritt aller Benutzer dazu.',
bestaetigen: 'Entfernen',
tun: async () => { await bibliothekLoeschen(b.id); await laden(); geaendert(); },
tun: async () => { await bibliothekLoeschen(aktuell.id); await laden(); geaendert(); },
});
/** Fragt nach, bis der Lauf durch ist — er läuft im Hintergrund. */
@@ -840,7 +899,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
async function verfolgen(melden: boolean): Promise<void> {
for (let i = 0; i < 900; i++) {
if (signal.aborted) return;
const s = await scanstand(b.id);
const s = await scanstand(aktuell.id);
// **Was der Server sagt, nicht was wir raten.** Vorher stand hier
// minutenlang „Wird durchsucht…", ohne Hinweis, ob etwas passiert.
// **Die Sperre richtet sich nach dem Server, nicht nach dem Klick.**
@@ -869,7 +928,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
//
// Alle drei Runden statt alle fünf: nah genug am Zähler in der
// Mediathek, ohne je Sekunde zwei Abrufe zu kosten.
if (melden && i > 0 && i % 3 === 0) { await laden(); geaendert(); }
if (melden && i > 0 && i % 3 === 0) geaendert(await laden());
if (s.state === 'finished') {
const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
@@ -895,11 +954,11 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
// Die Prüfung lässt sich je Mediathek umstellen — was beim Anlegen
// entschieden wurde, soll kein Urteil fürs Leben sein.
const pruefung = knopf(b.reviewNewItems ? 'Prüfung an' : 'Prüfung aus', 'btn');
const pruefung = knopf(aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus', 'btn');
pruefung.onclick = async () => {
pruefung.disabled = true;
try {
await pruefungSetzen(b.id, !b.reviewNewItems);
await pruefungSetzen(aktuell.id, !aktuell.reviewNewItems);
await laden();
} catch (fehler) {
d.appendChild(fehlerzeile(meldung(fehler)));
@@ -919,27 +978,47 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
// **Nur wenn hier wirklich etwas liegt.** Ein Knopf, der nichts zu tun
// hat, ist schlechter als keiner — und die Zahl daneben sagt, worum es
// geht, ohne dass man erst weiterscrollen muss.
if (b.pendingCount > 0) {
const freigeben = knopf(
`${b.pendingCount} ${b.pendingCount === 1 ? 'Titel' : 'Titel'} freigeben`,
'btn btn-primary');
freigeben.onclick = async () => {
freigeben.disabled = true;
try {
const bericht = await mediathekFreigeben(b.id);
stand.textContent = `${bericht.published} freigegeben.`;
await laden();
geaendert();
} catch (fehler) {
d.appendChild(fehlerzeile(meldung(fehler)));
freigeben.disabled = false;
}
};
knoepfe.appendChild(freigeben);
//
// Angelegt wird er trotzdem immer und nur versteckt: ihn beim Auffrischen
// anzulegen und wieder wegzuwerfen wäre genau das Flackern, das hier
// gerade abgestellt wird.
const freigeben = knopf('', 'btn btn-primary');
freigeben.onclick = async () => {
freigeben.disabled = true;
try {
const bericht = await mediathekFreigeben(aktuell.id);
stand.textContent = `${bericht.published} freigegeben.`;
await laden();
geaendert();
} catch (fehler) {
d.appendChild(fehlerzeile(meldung(fehler)));
} finally {
freigeben.disabled = false;
}
};
knoepfe.appendChild(freigeben);
function freigabeSchreiben(): void {
freigeben.hidden = aktuell.pendingCount === 0;
freigeben.textContent = `${aktuell.pendingCount} Titel freigeben`;
}
freigabeSchreiben();
d.append(knoepfe, stand);
// **Genau ein Takt je Mediathek.** Er entsteht hier, beim Anlegen der
// Zeile — und weil die Zeile nicht mehr neu gebaut wird, bleibt es dabei.
void verfolgen(false);
return d;
return {
wurzel: d,
aktualisieren: (neu: Bibliothek) => {
aktuell = neu;
kopfSchreiben();
pruefung.textContent = aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus';
freigabeSchreiben();
},
};
}
return bereich;