import { bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen, vermisste, vermisstesEntfernen, type VermissteDatei, protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen, meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen, mediathekFreigeben, reihenfolgeSetzen, type Pruefeintrag, benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen, servernameSetzen, systemauskunft, laufendeWiedergaben, serverzustand, bildAdresse, type Bibliothek, type Wiedergabe, } from '../api'; import { esc, dauer, knopf, fehlerzeile, leer } from '../util'; import { dialog, nachfragen } from '../dialog'; import { icon, icons } from '../icons'; /** Die Verwaltung. * * **Was etwas anlegt oder ändert, steht in einem Dialog**, nicht als Block * in der Seite. Ein Formular, das immer dasteht, nimmt Platz für etwas weg, * das man einmal im Monat braucht — und sieht aus wie eine Eingabe, die * noch aussteht. * * `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 = () => {}, signal: AbortSignal = new AbortController().signal, ): HTMLElement { const wurzel = document.createElement('div'); wurzel.append(dashboard(signal), pruefbereich(geaendert), vermisstenbereich(geaendert), bibliotheksbereich(geaendert, signal), benutzerbereich(), serverbereich(geaendert), protokollbereich()); return wurzel; } /** Überschrift mit einem Knopf rechts daneben. */ function abschnitt(titel: string, knoepfe: HTMLElement[] = []): HTMLElement { const kopf = document.createElement('div'); kopf.className = 'reihe-kopf'; const h = document.createElement('h2'); h.textContent = titel; kopf.appendChild(h); if (knoepfe.length) { const rechts = document.createElement('div'); rechts.className = 'knopfreihe'; rechts.style.marginLeft = 'auto'; knoepfe.forEach(k => rechts.appendChild(k)); kopf.appendChild(rechts); } return kopf; } /* ---------- Dashboard ---------- */ /** Was gerade läuft. * * **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(signal: AbortSignal): HTMLElement { const bereich = document.createElement('section'); bereich.appendChild(abschnitt('Wird abgespielt')); const liste = document.createElement('div'); liste.className = 'einstellungen'; liste.innerHTML = '
'; const zustand = document.createElement('div'); zustand.className = 'hinweis'; bereich.append(liste, zustand); let timer: number | undefined; void laden(); // Aufhören, sobald die Seite weg ist — sonst fragt eine unsichtbare // Verwaltung bis zum Neuladen weiter. const waechter = new MutationObserver(() => { if (!bereich.isConnected) { window.clearTimeout(timer); waechter.disconnect(); } }); waechter.observe(document.body, { childList: true, subtree: true }); async function laden(): Promise { try { const [laufend, stand] = await Promise.all([laufendeWiedergaben(), serverzustand()]); liste.innerHTML = ''; if (laufend.length === 0) { liste.appendChild(leer('Derzeit wird nichts gespielt.')); } else { laufend.forEach(w => liste.appendChild(wiedergabe(w))); } const teile = [`Läuft seit ${laufzeit(stand.uptime)}`]; if (stand.packaging > 0) teile.push(`${stand.packaging} Umwandlung(en) laufen`); if (stand.scanning > 0) teile.push(`${stand.scanning} Scan(s) laufen`); zustand.textContent = teile.join(' · '); } catch (fehler) { liste.innerHTML = ''; 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); } function wiedergabe(w: Wiedergabe): HTMLElement { const d = document.createElement('div'); d.className = 'einstellung laeuft'; const bild = document.createElement('div'); bild.className = 'laeuft-bild'; const adresse = bildAdresse(w.posterImage); if (adresse) { const img = document.createElement('img'); img.src = adresse; img.alt = ''; bild.appendChild(img); } const text = document.createElement('div'); text.className = 'zeile'; text.style.flex = '1'; const wie = w.runtimeSeconds ? `${dauer(w.positionSeconds)} von ${dauer(w.runtimeSeconds)}` : dauer(w.positionSeconds); text.innerHTML = `${esc(w.title)}` + (w.subTitle ? `${esc(w.subTitle)}` : '') + `${esc(w.userName)} · ${esc(w.deviceName)} · ${esc(wie)}`; const innen = document.createElement('div'); innen.className = 'knopfreihe'; innen.append(bild, text); d.appendChild(innen); if (w.runtimeSeconds) { const balken = document.createElement('div'); balken.className = 'fortschritt breit'; const gefuellt = document.createElement('div'); gefuellt.style.width = `${Math.min(100, (w.positionSeconds / w.runtimeSeconds) * 100)}%`; balken.appendChild(gefuellt); d.appendChild(balken); } return d; } return bereich; } /** „3 Std. 12 Min." — aus Sekunden. */ function laufzeit(sekunden: number): string { const tage = Math.floor(sekunden / 86400); const stunden = Math.floor((sekunden % 86400) / 3600); const minuten = Math.floor((sekunden % 3600) / 60); if (tage > 0) return `${tage} Tg. ${stunden} Std.`; if (stunden > 0) return `${stunden} Std. ${minuten} Min.`; return `${minuten} Min.`; } /* ---------- Zu prüfen ---------- */ /** Neue Funde, die noch nicht erscheinen — und geplante mit ihrem Termin. * * **Erscheint nur, wenn etwas wartet.** Ein leerer Abschnitt wäre Rauschen. * * Geplante bleiben in der Liste. Ein Titel mit Termin ist nicht erledigt, er * hat nur schon einen — ihn verschwinden zu lassen hiesse, dass man ihn nur * wiederfindet, wenn man sich den Termin gemerkt hat. */ function pruefbereich(geaendert: () => void): HTMLElement { const bereich = document.createElement('section'); bereich.className = 'hidden'; const liste = document.createElement('div'); liste.className = 'einstellungen'; /** Was gerade angehakt ist. Als Menge und nicht am DOM abgelesen: beim * Neuladen bleibt die Auswahl damit erhalten. */ const gewaehlt = new Set(); let stand: Pruefeintrag[] = []; const alleHaken = document.createElement('label'); alleHaken.className = 'bleiben'; const alleKasten = document.createElement('input'); alleKasten.type = 'checkbox'; alleHaken.append(alleKasten, document.createTextNode(' Alle')); alleKasten.onchange = () => { gewaehlt.clear(); if (alleKasten.checked) stand.forEach(e => gewaehlt.add(e.id)); zeichnen(); }; const freigeben = knopf('Veröffentlichen', 'btn btn-primary'); freigeben.onclick = () => void handeln(undefined); const planen = knopf('Planen…', 'btn'); planen.onclick = () => dialog({ titel: `${betroffene().length} Titel planen`, text: 'Ab diesem Zeitpunkt erscheinen sie für alle. Es läuft dafür nichts ' + 'im Hintergrund — die Sichtbarkeit wird bei jeder Abfrage gerechnet und ' + 'stimmt deshalb auch, wenn der Server gerade nicht lief.', felder: [{ name: 'wann', beschriftung: 'Zeitpunkt', art: 'zeitpunkt', wert: inEingabeform(new Date(Date.now() + 86400000)), min: inEingabeform(new Date()), }], bestaetigen: 'Planen', tun: async wert => { const wann = new Date(wert('wann')); if (Number.isNaN(wann.getTime())) throw new Error('Das ist kein Zeitpunkt.'); await handeln(wann.toISOString()); }, }); bereich.append(abschnitt('Zu prüfen', [alleHaken, freigeben, planen]), liste); void laden(); /** Die Auswahl — oder alles, wenn nichts angehakt ist. */ function betroffene(): Pruefeintrag[] { const angehakt = stand.filter(e => gewaehlt.has(e.id)); return angehakt.length > 0 ? angehakt : stand; } async function handeln(wann: string | undefined): Promise { const ids = betroffene().flatMap(e => e.ids); if (ids.length === 0) return; freigeben.disabled = true; planen.disabled = true; try { await veroeffentlichen(ids, wann); gewaehlt.clear(); await laden(); geaendert(); } catch (fehler) { liste.prepend(fehlerzeile((fehler as Error).message)); } finally { freigeben.disabled = false; planen.disabled = false; } } async function laden(): Promise { try { stand = await zuPruefen(); } catch { bereich.classList.add('hidden'); return; } if (stand.length === 0) { bereich.classList.add('hidden'); return; } bereich.classList.remove('hidden'); // Was es nicht mehr gibt, bleibt nicht angehakt. const da = new Set(stand.map(e => e.id)); [...gewaehlt].forEach(id => { if (!da.has(id)) gewaehlt.delete(id); }); zeichnen(); } function zeichnen(): void { alleKasten.checked = gewaehlt.size > 0 && gewaehlt.size === stand.length; alleKasten.indeterminate = gewaehlt.size > 0 && gewaehlt.size < stand.length; const anzahl = betroffene().reduce((n, e) => n + e.count, 0); const wieViele = gewaehlt.size > 0 ? `${anzahl} gewählt` : `alle ${anzahl}`; freigeben.textContent = `Veröffentlichen (${wieViele})`; liste.innerHTML = ''; stand.forEach(e => liste.appendChild(zeileFuer(e))); } function zeileFuer(e: Pruefeintrag): HTMLElement { const d = document.createElement('div'); d.className = 'einstellung pruefzeile'; const haken = document.createElement('input'); haken.type = 'checkbox'; haken.checked = gewaehlt.has(e.id); haken.setAttribute('aria-label', `„${e.title}“ auswählen`); haken.onchange = () => { if (haken.checked) gewaehlt.add(e.id); else gewaehlt.delete(e.id); zeichnen(); }; const bild = document.createElement('div'); bild.className = 'pruef-bild'; const adresse = bildAdresse(e.posterImage); if (adresse) { const img = document.createElement('img'); img.src = adresse; img.alt = ''; img.loading = 'lazy'; bild.appendChild(img); } const text = document.createElement('div'); text.className = 'zeile'; text.style.flex = '1'; const geplant = e.publishAt ? new Date(e.publishAt) : null; const was = e.kind === 'series' ? `${e.count} ${e.count === 1 ? 'Folge' : 'Folgen'}` : null; const lage = geplant ? `Geplant für ${geplant.toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}` : e.kind === 'series' && e.count > 1 && !e.publishAt ? 'Wartet' : 'Wartet auf Freigabe'; // Ohne Zuordnung fehlen Titel und Bilder — dann ist nicht die Freigabe // das Nächste, sondern die Zuordnung. const hinweis = e.matched ? null : 'noch keinem Titel zugeordnet'; text.innerHTML = `${esc(e.title)}${e.year ? ` (${e.year})` : ''}` + `${[was, lage, hinweis].filter(Boolean).map(esc).join(' · ')}`; const nur = knopf(geplant ? 'Jetzt veröffentlichen' : 'Veröffentlichen', 'btn'); nur.onclick = async () => { nur.disabled = true; try { await veroeffentlichen(e.ids); await laden(); geaendert(); } catch (fehler) { d.appendChild(fehlerzeile((fehler as Error).message)); nur.disabled = false; } }; const oben = document.createElement('div'); oben.className = 'knopfreihe'; oben.append(haken, bild, text, nur); if (geplant) { const zurueck = knopf('Zurückziehen', 'btn'); zurueck.onclick = async () => { zurueck.disabled = true; try { await zurueckziehen(e.ids); await laden(); } catch (fehler) { d.appendChild(fehlerzeile((fehler as Error).message)); zurueck.disabled = false; } }; oben.appendChild(zurueck); } d.appendChild(oben); if (e.path) { const pfad = document.createElement('div'); pfad.className = 'winzig'; pfad.textContent = e.path; d.appendChild(pfad); } return d; } /** Die Form, die `datetime-local` erwartet — Ortszeit ohne Zone. */ function inEingabeform(d: Date): string { const z = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())}` + `T${z(d.getHours())}:${z(d.getMinutes())}`; } return bereich; } /* ---------- 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 = '
'; 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 = `${esc(datum)}` + `${Math.max(1, Math.round(t.bytes / 1024))} KB`; 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 { 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. * * **Erscheint nur, wenn es etwas zu melden gibt.** Ein dauerhaft leerer * Abschnitt wäre Rauschen; ein gefüllter ist eine Nachricht. * * Der Scan löscht nichts mehr von selbst. Eine kurz nicht eingehängte Platte * sah vorher aus wie „die Filme sind weg" und kostete Titel samt * Sehfortschritt aller Benutzer. Beides ist für den Scan nicht zu * unterscheiden — also entscheidet hier ein Mensch. */ function vermisstenbereich(geaendert: () => void): HTMLElement { const bereich = document.createElement('section'); bereich.className = 'hidden'; const liste = document.createElement('div'); liste.className = 'einstellungen'; const alle = knopf('Alle entfernen', 'btn btn-gefahr'); alle.onclick = () => nachfragen({ titel: 'Alle vermissten Einträge entfernen', text: 'Entfernt alle Dateien, die nicht mehr gefunden werden, aus der ' + 'Mediathek — mitsamt den Titeln, von denen keine Datei übrig bleibt. ' + 'Auf der Platte wird nichts gelöscht.', bestaetigen: 'Entfernen', tun: async () => { await vermisstesEntfernen(); await laden(); geaendert(); }, }); bereich.append(abschnitt('Nicht mehr gefunden', [alle]), liste); void laden(); async function laden(): Promise { let fehlend: VermissteDatei[]; try { fehlend = await vermisste(); } catch { bereich.classList.add('hidden'); return; } if (fehlend.length === 0) { bereich.classList.add('hidden'); return; } bereich.classList.remove('hidden'); liste.innerHTML = ''; fehlend.forEach(v => { 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 seit = new Date(v.missingSince).toLocaleDateString('de-CH', { day: '2-digit', month: 'long', year: 'numeric' }); text.innerHTML = `${esc(v.title ?? '(ohne Titel)')}` + `${v.versionLabel ? ` · ${esc(v.versionLabel)}` : ''}` + `${esc(v.libraryName)} · vermisst seit ${esc(seit)}`; oben.appendChild(text); const weg = knopf('Entfernen', 'btn'); weg.onclick = async () => { weg.disabled = true; try { await vermisstesEntfernen(v.fileId); await laden(); geaendert(); } catch (fehler) { d.appendChild(fehlerzeile((fehler as Error).message)); weg.disabled = false; } }; oben.appendChild(weg); d.appendChild(oben); const pfad = document.createElement('div'); pfad.className = 'winzig'; pfad.textContent = v.path; d.appendChild(pfad); liste.appendChild(d); }); } return bereich; } /* ---------- Bibliotheken ---------- */ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLElement { /** Die Liste, wie sie gerade steht — für das Umsortieren. */ let alleBibliotheken: Bibliothek[] = []; /** Schiebt einen Eintrag und schickt die ganze neue Reihenfolge. */ async function schieben(von: number, nach: number): Promise { if (nach < 0 || nach >= alleBibliotheken.length) return; const neu = [...alleBibliotheken]; const [eintrag] = neu.splice(von, 1); neu.splice(nach, 0, eintrag!); await reihenfolgeSetzen(neu.map(b => b.id)); await laden(); geaendert(); } const bereich = document.createElement('section'); const anlegen = knopf('Mediathek hinzufügen', 'btn btn-primary'); anlegen.onclick = () => dialog({ titel: 'Mediathek hinzufügen', felder: [ { name: 'name', beschriftung: 'Name', platzhalter: 'etwa Filme' }, { name: 'art', beschriftung: 'Art', auswahl: [{ wert: 'Movies', text: 'Filme' }, { wert: 'Shows', text: 'Serien' }], }, { name: 'pfad', beschriftung: 'Ordner', art: 'ordner', platzhalter: '/media/Filme', hinweis: 'Der Pfad im Container, nicht der auf dem NAS — ' + 'also das, was in der Compose-Datei hinter dem Doppelpunkt steht.', }, { name: 'pruefen', beschriftung: 'Neue Funde erst prüfen', schalter: true, hinweis: 'Aus heisst: gefundene Titel erscheinen sofort. An heisst: sie ' + 'warten unter „Zu prüfen", bis du sie freigibst — Zeit, die Zuordnung ' + 'und die Bilder zu kontrollieren oder einen Termin zu setzen.', }, ], bestaetigen: 'Anlegen', tun: async wert => { await bibliothekAnlegen(wert('name'), wert('art'), wert('pfad'), wert('pruefen') === 'true'); await laden(); geaendert(); }, }); bereich.appendChild(abschnitt('Mediatheken', [anlegen])); const liste = document.createElement('div'); liste.className = 'einstellungen'; bereich.appendChild(liste); void laden(); async function laden(): Promise { // **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 = '
'; try { const alle = await bibliotheken(); liste.innerHTML = ''; if (alle.length === 0) { liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.')); return; } alleBibliotheken = alle; alle.forEach((b, i) => liste.appendChild(block(b, i))); } catch (fehler) { liste.innerHTML = ''; liste.appendChild(fehlerzeile((fehler as Error).message)); } } function block(b: Bibliothek, platz: number): HTMLElement { 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 = `${esc(b.name)}` + `${esc(b.path)}${esc(anzahl)}${esc(wartet)}`; d.appendChild(kopf); const stand = document.createElement('div'); stand.className = 'hinweis'; const los = knopf('Scannen', 'btn'); los.onclick = async () => { los.disabled = true; try { await scanStarten(b.id); await verfolgen(true); } catch (fehler) { stand.textContent = (fehler as Error).message; } los.disabled = false; }; const auffrischen = knopf('Metadaten auffrischen', 'btn'); auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — ' + 'nötig nach einer Sprachänderung.'; auffrischen.onclick = () => nachfragen({ titel: `Metadaten von „${b.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 () => { await metadatenAuffrischen(b.id); void verfolgen(true); }, }); const umbenennen = knopf('Umbenennen', 'btn'); umbenennen.onclick = () => dialog({ titel: 'Mediathek umbenennen', felder: [{ name: 'name', beschriftung: 'Name', wert: b.name }], bestaetigen: 'Speichern', tun: async wert => { await bibliothekUmbenennen(b.id, wert('name')); await laden(); geaendert(); }, }); const entfernen = knopf('Entfernen', 'btn'); entfernen.onclick = () => nachfragen({ titel: `„${b.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(); }, }); /** Fragt nach, bis der Lauf durch ist — er läuft im Hintergrund. */ /// 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 { for (let i = 0; i < 900; i++) { if (signal.aborted) return; const s = await scanstand(b.id); // **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; } // **Die Zahl mitlaufen lassen, nicht erst am Ende.** Der Scan schreibt // laufend; wer dabei zusieht, soll das auch sehen. Alle fünf Runden, // nicht jede: ein Abruf je Sekunde für eine Zahl wäre Verschwendung. if (melden && i > 0 && i % 5 === 0) await laden(); else if (s.state === 'finished') { const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`]; if (s.unrecognised) teile.push(`${s.unrecognised} ohne Folgennummer`); 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(' · '); if (melden) { await laden(); geaendert(); } return; } await new Promise(r => setTimeout(r, 1000)); } } // 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'); pruefung.onclick = async () => { pruefung.disabled = true; try { await pruefungSetzen(b.id, !b.reviewNewItems); await laden(); } catch (fehler) { d.appendChild(fehlerzeile((fehler as Error).message)); pruefung.disabled = false; } }; // **Umsortieren über zwei Pfeile, nicht über Ziehen.** // // Ziehen und Ablegen ist schöner, aber mit der Tastatur unbedienbar und // auf dem Telefon ein Glücksspiel. Zwei Knöpfe kann jeder bedienen, und // der Server bekommt die ganze Liste statt eines „eins hoch" — zwei // Benutzer, die gleichzeitig schieben, kämen sonst in einen Zustand, den // keiner von beiden wollte. const hoch = knopf('', 'btn rund'); hoch.innerHTML = icons.hoch; hoch.setAttribute('aria-label', `„${b.name}“ nach oben`); hoch.disabled = platz === 0; hoch.onclick = () => void schieben(platz, platz - 1); const runter = knopf('', 'btn rund'); runter.innerHTML = icons.hoch; runter.style.transform = 'rotate(180deg)'; runter.setAttribute('aria-label', `„${b.name}“ nach unten`); runter.disabled = platz === alleBibliotheken.length - 1; runter.onclick = () => void schieben(platz, platz + 1); const knoepfe = document.createElement('div'); knoepfe.className = 'knopfreihe'; knoepfe.append(hoch, runter, los, auffrischen, pruefung, umbenennen, entfernen); // **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((fehler as Error).message)); freigeben.disabled = false; } }; knoepfe.appendChild(freigeben); } d.append(knoepfe, stand); void verfolgen(false); return d; } return bereich; } /* ---------- Benutzer ---------- */ function benutzerbereich(): HTMLElement { const bereich = document.createElement('section'); const anlegen = knopf('Benutzer hinzufügen', 'btn'); anlegen.onclick = () => dialog({ titel: 'Benutzer hinzufügen', felder: [ { name: 'name', beschriftung: 'Benutzername' }, { name: 'passwort', beschriftung: 'Passwort', art: 'password', hinweis: 'Mindestens acht Zeichen.', }, { name: 'admin', beschriftung: 'Administrator', schalter: true }, ], bestaetigen: 'Anlegen', tun: async wert => { await benutzerAnlegen(wert('name'), wert('passwort'), wert('admin') === 'true'); await laden(); }, }); bereich.appendChild(abschnitt('Benutzer', [anlegen])); const liste = document.createElement('div'); liste.className = 'einstellungen'; bereich.appendChild(liste); void laden(); async function laden(): Promise { // **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 = '
'; try { const alle = await benutzer(); liste.innerHTML = ''; alle.forEach(b => { const d = document.createElement('div'); d.className = 'einstellung'; d.innerHTML = `
${esc(b.userName)}` + `${b.isAdministrator ? 'Administrator' : 'Benutzer'}
`; liste.appendChild(d); }); } catch (fehler) { liste.innerHTML = ''; liste.appendChild(fehlerzeile((fehler as Error).message)); } } return bereich; } /* ---------- Server ---------- */ function serverbereich(geaendert: () => void): HTMLElement { const bereich = document.createElement('section'); bereich.appendChild(abschnitt('Server')); const liste = document.createElement('div'); liste.className = 'einstellungen'; bereich.appendChild(liste); void (async () => { try { const [auskunft, stand] = await Promise.all([systemauskunft(), metadatenEinstellung()]); const eigeneSprache = meineSprache(); liste.innerHTML = ''; liste.appendChild(zeile( 'Name', auskunft.serverName ?? '—', 'So nennen ihn die Apps und die Kopfzeile.', () => dialog({ titel: 'Server umbenennen', felder: [{ name: 'name', beschriftung: 'Name', wert: auskunft.serverName ?? '' }], bestaetigen: 'Speichern', tun: async wert => { await servernameSetzen(wert('name')); location.reload(); }, }))); // **Wartung, nicht Alltag.** Nach jedem Scan passiert das von selbst; // dieser Knopf ist für den Fall, dass doch wieder Dubletten auftauchen — // etwa nachdem eine Zuordnung von Hand geändert wurde. const wartung = zeile( 'Doppelte Einträge', 'Zusammenlegen, was denselben Film meint', 'Zwei Dateien mit leicht verschiedenen Namen konnten früher zwei ' + 'Einträge ergeben. Der Sehfortschritt bleibt dabei erhalten — es ' + 'gewinnt die weitere Stelle.'); const legen = knopf('Zusammenlegen', 'btn'); legen.onclick = async () => { legen.disabled = true; const vorher = legen.textContent; legen.textContent = 'Läuft…'; try { const bericht = await doppelteZusammenlegen(); legen.textContent = bericht.gesamt === 0 ? 'Nichts zu tun' : `${bericht.gesamt} zusammengelegt`; if (bericht.gesamt > 0) geaendert(); } catch (fehler) { wartung.appendChild(fehlerzeile((fehler as Error).message)); legen.textContent = vorher; legen.disabled = false; } }; wartung.querySelector('.knopfreihe')?.appendChild(legen); liste.appendChild(wartung); liste.appendChild(zeile( 'Metadaten', // Ohne die Sprache: die steht eine Zeile tiefer und gehört dort hin. stand.usingProjectKey ? 'Mitgelieferter TMDB-Zugang' : stand.hasApiKey ? 'Eigener TMDB-Zugang' : 'Kein Zugang hinterlegt', stand.usingProjectKey ? 'Ein eigener Zugang löst den mitgelieferten ab.' : 'Nach einer Sprachänderung die Metadaten auffrischen — ' + 'vorhandene Titel ändern sich sonst nicht.', () => dialog({ titel: 'Metadaten', felder: [ { name: 'schluessel', beschriftung: 'Eigener TMDB-Zugang', art: 'password', platzhalter: 'Read Access Token oder API Key', hinweis: 'Leer lassen, um den mitgelieferten zu behalten.', }, ], bestaetigen: 'Speichern', tun: async wert => { await metadatenSetzen({ apiKey: wert('schluessel') || undefined }); location.reload(); }, }))); // **Die Sprache des Servers als eigene Zeile.** // // Sie steckte im Dialog „Metadaten" — zusammen mit dem TMDB-Schlüssel, // also dort, wo niemand nach einer Sprache sucht. Wer die Mediathek auf // Englisch vorfand, sah nur „Meine Sprache" und hielt das für die // einzige Möglichkeit. liste.appendChild(zeile( 'Sprache des Servers', sprachen.find(x => x.wert === stand.language)?.text ?? stand.language, 'In dieser Sprache werden Titel und Beschreibungen geholt, und sie ' + 'bestimmt die Altersfreigabe. Gilt für alle. Vorhandene Titel behalten ' + 'ihre alte Sprache — dafür gibt es bei jeder Mediathek „Metadaten ' + 'auffrischen".', () => dialog({ titel: 'Sprache des Servers', text: 'Die Umstellung wirkt auf neue Funde sofort. Für den vorhandenen ' + 'Bestand drückst du danach bei jeder Mediathek „Metadaten auffrischen" ' + '— sonst behalten die Titel ihre alte Sprache.', felder: [{ name: 'sprache', beschriftung: 'Sprache', auswahl: sprachen, wert: stand.language, }], bestaetigen: 'Übernehmen', tun: async wert => { await metadatenSetzen({ language: wert('sprache') }); location.reload(); }, }))); // **Die eigene Sprache, getrennt von der des Servers.** Die obere // bestimmt, was geholt wird; diese nur, was man selbst zu sehen bekommt. liste.appendChild(zeile( 'Meine Sprache', eigeneSprache ? (sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache) : 'Wie der Server', 'Gilt nur für dich. Was nicht übersetzt vorliegt, erscheint weiterhin in ' + 'der Sprache des Servers.', () => dialog({ titel: 'Meine Sprache', text: 'Die Metadaten liegen in der Sprache des Servers vor. Eine andere ' + 'zu wählen heisst: du siehst, was übersetzt vorliegt — und für den ' + 'Rest die Serversprache. Ein Administrator kann die Übersetzungen ' + 'unten für alle Titel auf einmal holen.', felder: [{ name: 'sprache', beschriftung: 'Sprache', auswahl: [{ wert: '', text: 'Wie der Server' }, ...sprachen], wert: eigeneSprache ?? '', }], bestaetigen: 'Übernehmen', tun: async wert => { const neu = wert('sprache'); await spracheSetzen(neu || null); location.reload(); }, }))); liste.appendChild(zeile( 'Übersetzungen holen', 'Füllt eine zweite Sprache für alle Titel auf', 'Kostet je Titel eine Anfrage an TMDB und dauert entsprechend. ' + 'Danach sehen Benutzer mit dieser Sprache die übersetzten Titel.', () => dialog({ titel: 'Übersetzungen holen', felder: [{ name: 'sprache', beschriftung: 'Für welche Sprache', auswahl: sprachen, wert: eigeneSprache ?? 'en-US', }], bestaetigen: 'Holen', tun: async wert => { const bericht = await uebersetzungenHolen(wert('sprache')); alert(`${bericht.gesamt} Übersetzungen geholt, ${bericht.fehlend} ohne Treffer.`); }, }))); liste.appendChild(zeile('Fassung', auskunft.version, null)); } catch (fehler) { liste.innerHTML = ''; liste.appendChild(fehlerzeile((fehler as Error).message)); } })(); function zeile( name: string, wert: string, hinweis: string | null, bearbeiten?: () => void, ): HTMLElement { 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'; text.innerHTML = `${esc(name)}${esc(wert)}`; oben.appendChild(text); if (bearbeiten) { const b = knopf('Ändern', 'btn'); b.prepend(icon('stift')); b.onclick = bearbeiten; oben.appendChild(b); } d.appendChild(oben); if (hinweis) { const h = document.createElement('div'); h.className = 'hinweis'; h.textContent = hinweis; d.appendChild(h); } return d; } liste.innerHTML = '
'; return bereich; }