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:
co-authored by
Claude Opus 5
parent
a6e9a6e425
commit
960cca3dae
+32
-5
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user