diff --git a/web/app/src/icons.ts b/web/app/src/icons.ts
index 7626462..a35b2fd 100644
--- a/web/app/src/icons.ts
+++ b/web/app/src/icons.ts
@@ -79,6 +79,15 @@ export const icons = {
/** Und wieder aufs ganze Bild. */
vergroessern: rahmen(''),
+ /** Ein Mensch — Konten, Profil. */
+ person: rahmen(''
+ + ''),
+ /** Ein beschriebenes Blatt — die Tagesprotokolle. */
+ protokoll: rahmen(''
+ + ''),
+ /** Schieberegler — Einstellungen, die eine Stufe haben. */
+ stufen: rahmen(''
+ + ''),
} as const;
export type IconName = keyof typeof icons;
diff --git a/web/app/src/main.ts b/web/app/src/main.ts
index c899c5c..b3165f4 100644
--- a/web/app/src/main.ts
+++ b/web/app/src/main.ts
@@ -11,7 +11,7 @@ import { anmeldung } from './views/login';
import { startseite } from './views/home';
import { mediathek } from './views/library';
import { detailseite } from './views/detail';
-import { verwaltung } from './views/admin';
+import { verwaltung, alsBereich } from './views/verwaltung';
import { suchseite } from './views/search';
import { esc, meldung } from './util';
@@ -361,9 +361,15 @@ function zeichnen(): void {
const weg = location.hash.replace(/^#\/?/, '');
const teile = weg.split('/').filter(Boolean);
+ // **Zwei Segmente, sonst eines.** Eine Mediathek heisst `#/m/{id}` und
+ // braucht beide; die Verwaltung heisst `#/verwaltung` und hat seit der
+ // zweiten Leiste einen Bereich dahinter. Verglich man stur zwei Segmente,
+ // verlor „Verwaltung" seine Markierung, sobald man darin etwas anklickte.
+ const zweiTeilig = `#/${teile.slice(0, 2).join('/')}`;
+ const einTeilig = `#/${teile.slice(0, 1).join('/')}`;
leiste.querySelectorAll('button[data-ziel]').forEach(b => {
const ziel = (b as HTMLElement).dataset.ziel!;
- b.classList.toggle('check', ziel === `#/${teile.slice(0, 2).join('/')}`);
+ b.classList.toggle('check', ziel === zweiTeilig || ziel === einTeilig);
});
// **Aushängen, nicht leeren.** `innerHTML = ''` zerstörte die Ansicht —
@@ -397,7 +403,7 @@ 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(bekannt => {
+ einhaengen(weg, inhalt, signal => verwaltung(alsBereich(teile[1]), bekannt => {
// Gemerkte Ansichten wegwerfen: der Bestand hat sich geändert.
// Die Seitenleiste bekommt neue Zahlen — die Seite selbst bleibt.
vergessen();
diff --git a/web/app/src/style.css b/web/app/src/style.css
index 2501034..b31239a 100644
--- a/web/app/src/style.css
+++ b/web/app/src/style.css
@@ -1226,6 +1226,56 @@ body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; }
.hero-bald { font-size: 12px; padding: 0 var(--s-snug); }
}
+/* --- Verwaltung ----------------------------------------------------------
+ *
+ * **Eine zweite Leiste, keine zweite Seite.** Die Verwaltung war elf
+ * Abschnitte in einer Spalte; wer das Protokoll sehen wollte, scrollte an
+ * allem anderen vorbei. Die Leiste sieht aus wie die Hauptleiste daneben —
+ * derselbe Balken links, dieselbe Farbe —, damit sie als Navigation gelesen
+ * wird und nicht als Knopfreihe. */
+
+.verwaltung { display: flex; align-items: flex-start; gap: var(--s-section); }
+
+.verw-leiste {
+ position: sticky; top: calc(var(--topbar-h) + var(--s-section));
+ flex: none; width: 196px;
+ display: flex; flex-direction: column; gap: 2px;
+}
+.verw-eintrag {
+ display: flex; align-items: center; gap: var(--s-regular);
+ background: none; border: 0; text-align: left;
+ padding: 9px var(--s-regular); color: var(--text-dim);
+ border-left: 3px solid transparent; border-radius: 0 var(--r-control) var(--r-control) 0;
+ font-size: var(--t-normal);
+}
+.verw-eintrag .icon { flex: none; color: var(--text-muted); }
+.verw-eintrag:hover { color: var(--text); background: rgba(255, 255, 255, .04); }
+.verw-eintrag:hover .icon { color: var(--text-dim); }
+.verw-eintrag.check {
+ color: var(--accent); border-left-color: var(--accent);
+ background: linear-gradient(90deg, var(--accent-soft), transparent 70%);
+}
+.verw-eintrag.check .icon { color: var(--accent); }
+
+.verw-buehne { flex: 1; min-width: 0; }
+
+@media (max-width: 860px) {
+ /* Wie die Hauptleiste: aus der Spalte wird ein Streifen zum Schieben. */
+ .verwaltung { flex-direction: column; gap: var(--s-loose); }
+ .verw-leiste {
+ position: static; width: auto;
+ flex-direction: row; overflow-x: auto; gap: var(--s-tight);
+ scrollbar-width: none; padding-bottom: var(--s-tight);
+ }
+ .verw-leiste::-webkit-scrollbar { display: none; }
+ .verw-eintrag {
+ flex: none; white-space: nowrap;
+ border-left: 0; border-bottom: 2px solid transparent; border-radius: var(--r-control);
+ }
+ .verw-eintrag.check { border-bottom-color: var(--accent); background: var(--accent-soft); }
+ .verw-buehne { width: 100%; }
+}
+
/* --- Kontextmenü ---------------------------------------------------------
*
* Gesehen, Favorit und Weglegen gab es nur auf der Detailseite. In einer
@@ -1678,13 +1728,6 @@ body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 104px; }
.fassungsdatei .oz-dazu { flex: 1 1 auto; }
.btn.klein { padding: 4px 10px; font-size: 12px; flex: 0 0 auto; }
-/* Kurz hervorheben, wohin gesprungen wurde — sonst sucht man oben weiter. */
-.hervor { animation: hervor 1.6s ease-out; }
-@keyframes hervor {
- 0%, 60% { background: var(--accent-soft); border-radius: var(--r-karte); }
- 100% { background: transparent; }
-}
-@media (prefers-reduced-motion: reduce) { .hervor { animation: none; } }
/* Der Ordner in den Medienangaben — schmaler gesetzt, er ist ein Pfad. */
.medientabelle dd.pfad {
diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts
deleted file mode 100644
index 11a99a3..0000000
--- a/web/app/src/views/admin.ts
+++ /dev/null
@@ -1,1786 +0,0 @@
-import {
- bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen,
- scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen,
- vermisste, vermisstesEntfernen, type VermissteDatei,
- protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen,
- meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen, zurKenntnis,
- mediathekZuruecksetzen,
- type Pruefeintrag,
- benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
- servernameSetzen, systemauskunft,
- laufendeWiedergaben, serverzustand, bildAdresse,
- speicherorte, bildspeicherLeeren, unzugeordnet, ohneZuordnung, mehrfachfassungen,
- dateiHerausloesen, type Mehrfachtitel,
- sprachverteilung, abweichendeAuffrischen,
- type Bibliothek, type Wiedergabe, ApiFehler,
-} from '../api';
-import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung, hinweisZeigen } from '../util';
-import { dialog, nachfragen } from '../dialog';
-import { icon, icons } from '../icons';
-import { kontextmenue, menueAnZiel, type Menuepunkt } from '../menue';
-import { bilddialog } from '../bilder';
-import { zuordnungsdialog } from '../zuordnung';
-import { titeldialog } from '../titel';
-import { bearbeiten } from '../bearbeiten';
-
-/** 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: (bekannt?: Bibliothek[]) => void = () => {},
- signal: AbortSignal = new AbortController().signal,
-): HTMLElement {
- const wurzel = document.createElement('div');
-
- // **Die Prüfliste hört mit.** Ändert sich der Bestand — ein Scan ist durch,
- // etwas wurde freigegeben —, holt sie sich neu. Ohne das stand die Zahl in
- // der Mediathekenzeile und die Liste darüber war leer.
- const pruefung = pruefbereich(() => { geaendert(); pruefung.auffrischen(); });
- const mitPruefung = (bekannt?: Bibliothek[]): void => {
- geaendert(bekannt);
- pruefung.auffrischen();
- };
-
- wurzel.append(dashboard(signal), sprachbereich(), pruefung.wurzel,
- vermisstenbereich(mitPruefung), bibliotheksbereich(mitPruefung, signal), benutzerbereich(),
- serverbereich(mitPruefung), speicherbereich(), ohneZuordnungBereich(),
- unzugeordnetbereich(),
- 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(meldung(fehler)));
- 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): {
- wurzel: HTMLElement; auffrischen: () => void;
-} {
- const bereich = document.createElement('section');
- bereich.className = 'hidden';
- bereich.id = 'zu-pruefen';
-
- 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(meldung(fehler)));
- } 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 = '';
-
- // **Zwei Gruppen, zwei Bedeutungen.** Ein neuer Fund wartet auf Freigabe
- // und ist bis dahin unsichtbar; ein geänderter Titel steht längst in der
- // Mediathek und soll nur nicht unbemerkt anders heissen. Beides in einer
- // Reihe hiesse, „Veröffentlichen" auf etwas anzubieten, das veröffentlicht
- // ist.
- const neue = stand.filter(e => e.grund !== 'geaendert');
- const geaenderte = stand.filter(e => e.grund === 'geaendert');
-
- neue.forEach(e => liste.appendChild(zeileFuer(e)));
-
- if (geaenderte.length > 0) {
- const kopf = document.createElement('div');
- kopf.className = 'pruef-gruppe';
- kopf.innerHTML = 'Geändert'
- + 'Diese Titel sind schon sichtbar. Ein Auffrischen '
- + 'hat Titel, Jahr oder Plakat geändert — hier nur zur Kenntnis.';
-
- const abhaken = knopf('Alle zur Kenntnis', 'btn');
- abhaken.onclick = async () => {
- abhaken.disabled = true;
- try { await zurKenntnis(); await laden(); }
- catch (fehler) { liste.prepend(fehlerzeile(meldung(fehler))); abhaken.disabled = false; }
- };
- kopf.appendChild(abhaken);
-
- liste.append(kopf, ...geaenderte.map(zeileFuer));
- }
- }
-
- function zeileFuer(e: Pruefeintrag): HTMLElement {
- const d = document.createElement('div');
- d.className = 'einstellung pruefzeile';
-
- const geaendertes = e.grund === 'geaendert';
-
- const haken = document.createElement('input');
- haken.type = 'checkbox';
- haken.checked = gewaehlt.has(e.id);
- haken.setAttribute('aria-label', `„${e.title}“ auswählen`);
- // Die Sammelaktionen oben veröffentlichen — auf Geändertes passt das nicht.
- haken.classList.toggle('hidden', geaendertes);
- 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 = geaendertes
- ? 'Sichtbar — ein Auffrischen hat etwas geändert'
- : 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(' · ')}`;
-
- // **Geändertes wird abgehakt, nicht veröffentlicht** — es ist schon
- // sichtbar. „Veröffentlichen" wäre dort ein Knopf, der nichts tut.
- const nur = knopf(
- geaendertes ? 'Zur Kenntnis' : geplant ? 'Jetzt veröffentlichen' : 'Veröffentlichen',
- 'btn');
- nur.onclick = async () => {
- nur.disabled = true;
- try {
- if (geaendertes) await zurKenntnis([e.id]);
- else await veroeffentlichen(e.ids);
- await laden();
- geaendert();
- } catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); nur.disabled = false; }
- };
-
- // **Dasselbe Dreipunktemenü wie auf einer Kachel.**
- //
- // Hier stand bisher nur „Veröffentlichen" — ausgerechnet bei dem Titel,
- // den man vor der Freigabe geradeziehen will. Bilder, Zuordnung und Titel
- // waren an einem wartenden Eintrag überhaupt nicht erreichbar.
- const art = e.kind === 'series' ? 'series' : 'movies';
- const mehr = document.createElement('button');
- mehr.className = 'btn rund';
- mehr.innerHTML = icons.punkte;
- mehr.setAttribute('aria-label', `Mehr zu „${e.title}“`);
- const punkte = (): Menuepunkt[] => [
- // Auch hier: ein Ort für alles, die Einzelnen bleiben als Abkürzung.
- { text: 'Bearbeiten', icon: 'stift',
- tun: () => bearbeiten(art, e.id, () => void laden()) },
- { text: 'Titel ändern', icon: 'stift',
- tun: () => titeldialog(art, e.id, e.title, () => void laden()) },
- { text: 'Bilder bearbeiten', icon: 'stift',
- tun: () => bilddialog(art, e.id, e.title, () => void laden()) },
- { text: 'Zuordnung korrigieren', icon: 'suche',
- // Ohne Jahr — siehe card.ts: das der falschen Zuordnung grenzt falsch ein.
- tun: () => zuordnungsdialog(art, e.id, e.title, null, () => void laden()) },
- ];
- mehr.onclick = () => menueAnZiel(mehr, punkte());
- kontextmenue(mehr, punkte);
-
- const oben = document.createElement('div');
- oben.className = 'knopfreihe';
- oben.append(haken, bild, text, mehr, 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(meldung(fehler))); 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())}`;
- }
-
- // **Nachladbar, und das fehlte.** Die Liste wurde einmal beim Öffnen
- // geholt. Fand ein laufender Scan danach etwas Neues, zählte die Zeile der
- // Mediathek es mit — „4 warten auf Freigabe" —, die Liste darüber blieb
- // aber leer und sogar versteckt. Da stand eine Zahl ohne das, worauf sie
- // sich bezog.
- return { wurzel: bereich, auffrischen: () => void laden() };
-}
-
-/* ---------- 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. */
-/** Dateien, die zu keinem Titel gehören.
- *
- * **Warum das eine eigene Ansicht verdient.** Der Scan erfindet keine Serie
- * mehr, wenn weder Ordner noch Dateiname eine nennen — richtig so, aber die
- * Dateien verschwanden damit lautlos. Bei 3559 von 5884 war das die halbe
- * Mediathek, und in der Übersicht stand eine Zahl, die niemand erklären
- * konnte.
- *
- * **Mit Grund je Datei**, weil die beiden Gründe Verschiedenes verlangen:
- * fehlt die Folgennummer, muss umbenannt werden; fehlt der Serienname, genügt
- * ein Ordner darum herum.
- */
-/** Titel, zu denen die Metadatenquelle nichts hergab.
- *
- * **Weil die Zahl allein nichts erklärt.** In der Übersicht standen 448
- * Filme, in Plex 451 — und nirgends, welche drei fehlen oder warum. Ein Film
- * ohne Zuordnung steht zwar in der Mediathek, aber unter seinem Dateinamen
- * und ohne Plakat; wer ihn sucht, findet ihn nicht, weil er anders heisst.
- *
- * Daneben: Titel mit mehr als einer Datei. Zwei Dateien an einem Film sind
- * meist zwei Fassungen und in Ordnung — manchmal aber zwei verschiedene
- * Filme, die bei der Quelle auf denselben Treffer liefen. An den Dateinamen
- * sieht man das in zwei Sekunden.
- */
-function ohneZuordnungBereich(): HTMLElement {
- const bereich = document.createElement('section');
- bereich.classList.add('hidden');
- bereich.appendChild(abschnitt('Ohne Zuordnung'));
-
- const liste = document.createElement('div');
- liste.className = 'einstellungen';
- bereich.appendChild(liste);
-
- void laden();
-
- async function laden(): Promise {
- let alle;
- try { alle = await bibliotheken(); }
- catch { return; }
-
- const ergebnisse = await Promise.all(alle.map(async b => {
- try {
- const [offen, mehrfach] = await Promise.all([
- ohneZuordnung(b.id), mehrfachfassungen(b.id),
- ]);
- return { b, offen, mehrfach };
- } catch { return null; }
- }));
-
- const mitFunden = ergebnisse
- .filter(e => e !== null)
- .filter(e => e!.offen.length > 0 || e!.mehrfach.gesamt > 0);
-
- if (mitFunden.length === 0) { bereich.classList.add('hidden'); return; }
- bereich.classList.remove('hidden');
-
- liste.innerHTML = '';
-
- mitFunden.forEach(e => {
- const { b, offen, mehrfach } = e!;
-
- if (offen.length > 0) {
- liste.appendChild(gruppe(
- `${b.name} — ${anzahl(offen.length, 'Titel')} ohne Zuordnung`,
- 'Diese stehen unter ihrem Dateinamen in der Mediathek. '
- + 'Über „Zuordnung korrigieren" lassen sie sich geradeziehen.',
- offen.map(t => ({
- titel: t.titel + (t.jahr ? ` (${t.jahr})` : ''),
- dazu: t.beispiel ?? `${anzahl(t.dateien, 'Datei')}`,
- // **Hier bearbeiten, nicht woandershin springen.** Wer eine
- // Liste durchgeht, will nach dem Geradeziehen beim nächsten
- // weitermachen — nicht jedes Mal zurücknavigieren.
- tun: () => bearbeiten(t.art === 'series' ? 'series' : 'movies', t.id, () => void laden()),
- }))));
- }
-
- if (mehrfach.gesamt > 0) {
- liste.appendChild(fassungsgruppe(
- `${b.name} — ${anzahl(mehrfach.gesamt, 'Titel')} mit mehreren Dateien`,
- 'Meist zwei Fassungen desselben Films — dann ist alles in Ordnung. '
- + 'Stehen hier zwei verschiedene Filme, war einer davon nicht mehr zu '
- + 'sehen: „Eigener Film" löst ihn wieder heraus.',
- mehrfach.titel));
- }
- });
- }
-
- function anzahl(n: number, wort: string): string {
- return `${n} ${n === 1 ? wort : (wort === 'Datei' ? 'Dateien' : wort)}`;
- }
-
- /** Dieselbe Gruppe, aber jede Datei einzeln mit einem Weg heraus. */
- function fassungsgruppe(
- ueberschrift: string, hinweis: string, titel: Mehrfachtitel[],
- ): HTMLElement {
- const d = document.createElement('div');
- d.className = 'einstellung';
-
- const kopf = document.createElement('div');
- kopf.className = 'zeile';
- kopf.innerHTML = `${esc(ueberschrift)}`
- + `${esc(hinweis)}`;
- d.appendChild(kopf);
-
- const innen = document.createElement('div');
- innen.className = 'ohnezuordnung';
-
- titel.forEach(t => {
- const zeile = document.createElement('div');
- zeile.className = 'fassungstitel';
-
- const name = document.createElement('button');
- name.className = 'ohnezuordnung-zeile';
- name.innerHTML = `${esc(t.titel + (t.jahr ? ` (${t.jahr})` : ''))}`;
- name.onclick = () => bearbeiten(
- t.art === 'series' ? 'series' : 'movies', t.id, () => void laden());
- zeile.appendChild(name);
-
- t.dateien.forEach(f => {
- const d2 = document.createElement('div');
- d2.className = 'fassungsdatei';
-
- const n = document.createElement('span');
- n.className = 'oz-dazu';
- n.textContent = f.name;
-
- // **Nur bei mehr als einer Datei sinnvoll** — und mehr als eine ist
- // hier die Bedingung, überhaupt in der Liste zu stehen.
- const raus = knopf('Eigener Film', 'btn klein');
- raus.onclick = async () => {
- raus.disabled = true;
- try {
- const neu = await dateiHerausloesen(f.id);
- hinweisZeigen(`„${neu.title}" ist jetzt ein eigener Film — ohne Zuordnung.`);
- await laden();
- } catch (fehler) {
- hinweisZeigen(meldung(fehler), 'fehler');
- raus.disabled = false;
- }
- };
-
- d2.append(n, raus);
- zeile.appendChild(d2);
- });
-
- innen.appendChild(zeile);
- });
-
- d.appendChild(innen);
- return d;
- }
-
- function gruppe(
- ueberschrift: string, hinweis: string,
- zeilen: { titel: string; dazu: string; tun: () => void }[],
- ): HTMLElement {
- const d = document.createElement('div');
- d.className = 'einstellung';
-
- const kopf = document.createElement('div');
- kopf.className = 'zeile';
- kopf.innerHTML = `${esc(ueberschrift)}`
- + `${esc(hinweis)}`;
- d.appendChild(kopf);
-
- const innen = document.createElement('div');
- innen.className = 'ohnezuordnung';
- zeilen.forEach(z => {
- const b = document.createElement('button');
- b.className = 'ohnezuordnung-zeile';
- b.innerHTML = `${esc(z.titel)}`
- + `${esc(z.dazu)}`;
- b.onclick = z.tun;
- innen.appendChild(b);
- });
- d.appendChild(innen);
-
- return d;
- }
-
- return bereich;
-}
-
-function unzugeordnetbereich(): HTMLElement {
- const bereich = document.createElement('section');
- const kopf = abschnitt('Nicht zugeordnet');
- bereich.appendChild(kopf);
-
- const liste = document.createElement('div');
- liste.className = 'einstellungen';
- bereich.appendChild(liste);
-
- void laden();
-
- async function laden(): Promise {
- let alle;
- try { alle = await bibliotheken(); }
- catch { bereich.classList.add('hidden'); return; }
-
- const ergebnisse = await Promise.all(alle.map(async b => {
- try { return { b, offen: await unzugeordnet(b.id) }; }
- catch { return null; }
- }));
-
- const mitFunden = ergebnisse.filter(e => e !== null).filter(e => e!.offen.gesamt > 0);
-
- // Nichts offen heisst: der Abschnitt hat hier nichts verloren.
- if (mitFunden.length === 0) { bereich.classList.add('hidden'); return; }
- bereich.classList.remove('hidden');
-
- liste.innerHTML = '';
-
- mitFunden.forEach(eintrag => {
- const { b, offen } = eintrag!;
-
- 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(b.name)}`
- + `${offen.gesamt} ${offen.gesamt === 1 ? 'Datei' : 'Dateien'} `
- + 'ohne Zuordnung';
- oben.appendChild(text);
-
- // **Zum Verschicken.** Eine Liste, die man markieren und kopieren muss,
- // schickt niemand — und ohne die Pfade lässt sich nicht sagen, woran
- // die Erkennung scheitert.
- const holen = knopf('Liste herunterladen', 'btn');
- holen.onclick = () => {
- const zeilen = offen.dateien.map(f => `${f.pfad}\t${f.grund}`).join('\n');
- const blob = new Blob(
- [`# ${b.name} — ${offen.gesamt} ohne Zuordnung, davon ${offen.dateien.length} aufgeführt\n`
- + zeilen + '\n'],
- { type: 'text/plain;charset=utf-8' });
- const a = document.createElement('a');
- a.href = URL.createObjectURL(blob);
- a.download = `nicht-zugeordnet-${b.name}.txt`;
- a.click();
- URL.revokeObjectURL(a.href);
- };
- oben.appendChild(holen);
- d.appendChild(oben);
-
- // **Nach Grund gebündelt, nicht als Liste von dreitausend Zeilen.** So
- // sieht man in zwei Zeilen, ob umzubenennen oder einzuordnen ist.
- const gruende = new Map();
- offen.dateien.forEach(f => {
- if (!gruende.has(f.grund)) gruende.set(f.grund, []);
- gruende.get(f.grund)!.push(f.pfad);
- });
-
- [...gruende.entries()]
- .sort((a, c) => c[1].length - a[1].length)
- .forEach(([grund, pfade]) => {
- const g = document.createElement('details');
- g.className = 'grundblock';
-
- const z = document.createElement('summary');
- z.innerHTML = `${pfade.length}× ${esc(grund)}`;
- g.appendChild(z);
-
- const pre = document.createElement('pre');
- pre.className = 'protokoll';
- pre.textContent = pfade.slice(0, 50).join('\n')
- + (pfade.length > 50 ? `\n… und ${pfade.length - 50} weitere` : '');
- g.appendChild(pre);
-
- d.appendChild(g);
- });
-
- liste.appendChild(d);
- });
- }
-
- return bereich;
-}
-
-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);
-
- // **Blättern statt alles auf einmal.** Die ersten 800 Zeilen beantworten
- // fast jede Frage; wer weiter zurück muss, verdoppelt mit diesem Knopf.
- const mehr = knopf('Mehr laden', 'btn hidden');
- bereich.appendChild(mehr);
-
- 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);
- });
- })();
-
- /** Wie viele Zeilen zuletzt geholt wurden — der Knopf „Mehr" verdoppelt. */
- let gezeigt = 0;
-
- async function zeigen(tag: string, nurFehler: boolean, zeilen = 0): Promise {
- // **Nur das Ende holen, und davon anfangs wenig.** Vorher kam die ganze
- // Datei, und davon wurden 95 % weggeworfen — bei zwei Megabyte über eine
- // wackelige Verbindung endete das in „Failed to fetch". Bei der
- // Fehlersuche mehr, weil davon die meisten weggefiltert werden.
- gezeigt = zeilen > 0 ? zeilen : (nurFehler ? 5000 : 800);
-
- anzeige.classList.remove('hidden');
- anzeige.textContent = 'Wird geladen…';
- mehr.classList.add('hidden');
-
- try {
- const antwort = await fetch(protokollAdresse(tag, gezeigt));
- if (!antwort.ok) throw new ApiFehler(antwort.status, `${antwort.status}`);
-
- const roh = (await antwort.text()).split('\n');
- const gefiltert = nurFehler
- ? roh.filter(z => z.includes('FEHLER') || z.includes('WARNUNG'))
- : roh;
-
- anzeige.textContent = gefiltert.join('\n').trim().length === 0
- ? (nurFehler ? 'Keine Fehler in den letzten Zeilen.' : 'Leer.')
- : gefiltert.join('\n');
- anzeige.scrollTop = anzeige.scrollHeight;
-
- // **„Mehr" nur, solange es mehr geben kann.** Kamen weniger Zeilen
- // zurück als verlangt, ist die Datei zu Ende — ein Knopf, der dann
- // nichts tut, ist schlechter als keiner.
- if (roh.length >= gezeigt && gezeigt < 20000) {
- mehr.classList.remove('hidden');
- mehr.textContent = `Weitere ${Math.min(gezeigt, 20000 - gezeigt)} Zeilen laden`;
- mehr.onclick = () => void zeigen(tag, nurFehler, Math.min(gezeigt * 2, 20000));
- }
- } catch (fehler) {
- anzeige.textContent = `Konnte nicht gelesen werden: ${meldung(fehler)}`;
- }
- }
-
- 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(meldung(fehler))); 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 ---------- */
-
-/** 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');
-
- 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);
-
- /** 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();
-
- void laden();
-
- /** **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 {
- if (zeilen.size === 0 && liste.childElementCount === 0) {
- liste.innerHTML = '';
- }
-
- 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): 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 kopf = document.createElement('div');
- kopf.className = 'zeile';
- d.appendChild(kopf);
-
- function kopfSchreiben(): void {
- const serie = aktuell.kind === 'Shows';
-
- // **Alle Zahlen nebeneinander, weil sie Verschiedenes meinen.**
- //
- // Plex zählte 451 Filme, Flaro 449, und der Scan lief über 461 Dateien.
- // Keine davon war falsch — es sind Titel, Dateien und zweite Fassungen.
- // Einzeln gezeigt bleibt die Frage offen, nebeneinander beantwortet sie
- // sich.
- const teile: string[] = [aktuell.path];
-
- if (aktuell.itemCount != null) {
- teile.push(`${aktuell.itemCount} ` + (aktuell.itemCount === 1
- ? (serie ? 'Serie' : 'Film')
- : (serie ? 'Serien' : 'Filme')));
- }
- if (serie && aktuell.episodeCount != null) {
- teile.push(`${aktuell.episodeCount} ${aktuell.episodeCount === 1 ? 'Folge' : 'Folgen'}`);
- }
- teile.push(`${aktuell.fileCount} ${aktuell.fileCount === 1 ? 'Datei' : 'Dateien'}`);
- if (aktuell.extraVersionCount > 0) {
- teile.push(`${aktuell.extraVersionCount} zweite Fassung`
- + (aktuell.extraVersionCount === 1 ? '' : 'en'));
- }
- if (aktuell.unassignedCount > 0) teile.push(`${aktuell.unassignedCount} nicht zugeordnet`);
- if (aktuell.pendingCount > 0) {
- teile.push(`${aktuell.pendingCount} wartet${aktuell.pendingCount === 1 ? '' : 'en'} auf Freigabe`);
- }
-
- kopf.innerHTML = `${esc(aktuell.name)}`
- + `${esc(teile.join(' · '))}`;
- }
- kopfSchreiben();
-
- const stand = document.createElement('div');
- stand.className = 'hinweis';
-
- const los = knopf('Scannen', 'btn');
- los.onclick = async () => {
- laufendSetzen(true);
- try { await scanStarten(aktuell.id); await verfolgen(true); }
- catch (fehler) { stand.textContent = meldung(fehler); laufendSetzen(false); }
- };
-
- /** Sperrt alles, was einen laufenden Lauf stören würde.
- *
- * **Nicht nur „Scannen".** Ein zweiter Scan prallt am Server ab und ist
- * nur verwirrend — „Zurücksetzen" und „Entfernen" dagegen löschen Zeilen,
- * während der Lauf welche schreibt, und das endet in einem Bestand, den
- * niemand mehr erklären kann.
- *
- * **Umbenennen, Prüfung und die Pfeile bleiben bedienbar.** Sie rühren
- * den Inhalt nicht an, und ein Knopf, der ohne Not gesperrt ist, ist
- * genauso ärgerlich wie einer, der ohne Not etwas kaputtmacht.
- */
- function laufendSetzen(an: boolean): void {
- const grund = 'Läuft gerade — bitte warten, bis der Lauf durch ist.';
- [los, auffrischen, zuruecksetzen, entfernen].forEach(k => {
- k.disabled = an;
- if (an) k.title = grund;
- else if (k.title === grund) k.title = '';
- });
- if (!an) {
- auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — '
- + 'nötig nach einer Sprachänderung.';
- }
- }
-
- 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 „${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(aktuell.id);
- void verfolgen(true);
- },
- });
-
- const umbenennen = knopf('Umbenennen', 'btn');
- umbenennen.onclick = () => dialog({
- titel: 'Mediathek umbenennen',
- felder: [{ name: 'name', beschriftung: 'Name', wert: aktuell.name }],
- bestaetigen: 'Speichern',
- tun: async wert => {
- await bibliothekUmbenennen(aktuell.id, wert('name'));
- await laden();
- geaendert();
- },
- });
-
- // **Zurücksetzen steht neben Entfernen, weil es fast dasselbe tut** — nur
- // ohne Namen, Pfad und Einstellungen mitzunehmen. Wer eine Mediathek
- // löscht und neu anlegt, um sie sauber zu bekommen, tippt alles noch
- // einmal; genau das war bisher der einzige Weg.
- const zuruecksetzen = knopf('Zurücksetzen', 'btn');
- zuruecksetzen.onclick = () => nachfragen({
- 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(aktuell.id);
- await laden();
- geaendert();
- },
- });
-
- const entfernen = knopf('Entfernen', 'btn');
- entfernen.onclick = () => nachfragen({
- 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(aktuell.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(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.**
- // Beim Aufbau der Seite läuft vielleicht schon einer — dann müssen die
- // Knöpfe gesperrt sein, ohne dass hier jemand etwas gedrückt hat.
- if (s.state === 'running') {
- laufendSetzen(true);
- stand.textContent = s.step ?? 'Wird durchsucht…';
- } else if (s.state === 'enriching') {
- laufendSetzen(true);
- stand.textContent = s.step ?? 'Metadaten werden geholt…';
- } else if (s.state === 'failed') {
- laufendSetzen(false);
- stand.textContent = s.error ?? 'Gescheitert.';
- return;
- }
-
- // **Alle Zähler auf demselben Takt.**
- //
- // Vorher frischte diese Schleife nur die Zeile in der Verwaltung auf,
- // und die Seitenleiste erst am Ende des Laufs. In der Mediathek stand
- // dann 20, in der Verwaltung 14 und in der Seitenleiste 0 — drei
- // Zahlen für dieselbe Sache, und keine davon offensichtlich falsch.
- // `geaendert()` baut nur die Seitenleiste neu, nicht die Ansicht;
- // es hier zu rufen ist deshalb gefahrlos.
- //
- // 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) geaendert(await laden());
-
- if (s.state === 'finished') {
- const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
- // „ohne Folgennummer" stimmte nicht mehr: gezählt wird jetzt auch,
- // was eine Nummer trägt, aber zu keiner Serie gehört.
- if (s.unrecognised) teile.push(`${s.unrecognised} nicht zugeordnet`);
- 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(' · ');
- laufendSetzen(false);
- if (melden) { await laden(); geaendert(); }
- return;
- }
- await new Promise(r => setTimeout(r, 1000));
- }
-
- // Nach fünfzehn Minuten ohne Ende: die Sperre fällt, sonst bliebe die
- // Mediathek bis zum Neuladen der Seite unbedienbar.
- laufendSetzen(false);
- }
-
- // Die Prüfung lässt sich je Mediathek umstellen — was beim Anlegen
- // entschieden wurde, soll kein Urteil fürs Leben sein.
- const pruefung = knopf(aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus', 'btn');
- pruefung.onclick = async () => {
- pruefung.disabled = true;
- try {
- await pruefungSetzen(aktuell.id, !aktuell.reviewNewItems);
- await laden();
- } catch (fehler) {
- d.appendChild(fehlerzeile(meldung(fehler)));
- pruefung.disabled = false;
- }
- };
-
- // **Umsortiert wird in der Seitenleiste, nicht hier.**
- //
- // Die Reihenfolge wirkt dort, also gehört der Griff dorthin: man sieht
- // beim Schieben, was man verändert, statt in den Einstellungen zu raten
- // und danach nachzusehen. Der Knopf heisst „Seitenleiste bearbeiten".
- const knoepfe = document.createElement('div');
- knoepfe.className = 'knopfreihe';
- knoepfe.append(los, auffrischen, pruefung, umbenennen, zuruecksetzen, 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.
- //
- // 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');
- // **Zeigen statt blind freigeben.**
- //
- // Der Knopf veröffentlichte bisher auf einen Klick alles, was wartete —
- // ohne dass man gesehen hätte, was. Auf die Frage „wo sehe ich die vier
- // Filme?" gab es damit keine Antwort: die Liste stand weiter oben, aber
- // niemand wusste das, und sie war obendrein nicht nachgeladen worden.
- //
- // Jetzt führt er dorthin. Veröffentlicht wird dort, wo die Titel mit
- // Plakat und Dateiname stehen und sich einzeln anhaken lassen.
- freigeben.onclick = () => {
- const liste = document.getElementById('zu-pruefen');
- if (!liste) return;
-
- liste.scrollIntoView({ behavior: 'smooth', block: 'start' });
- // Kurz hervorheben — sonst sucht man oben wieder, was man sucht.
- liste.classList.add('hervor');
- window.setTimeout(() => liste.classList.remove('hervor'), 1600);
- };
- knoepfe.appendChild(freigeben);
-
- function freigabeSchreiben(): void {
- // **`hidden` reicht hier nicht.** Das Attribut wirkt über
- // `display: none` mit der Spezifität von Null — `.btn` setzt
- // `display: inline-flex` und gewinnt. Der Knopf stand deshalb als
- // „0 Titel freigeben" da, obwohl nichts wartet. `.hidden` trägt ein
- // `!important` und setzt sich durch.
- freigeben.classList.toggle('hidden', aktuell.pendingCount === 0);
- freigeben.textContent = aktuell.pendingCount === 1
- ? 'Ein Titel wartet — ansehen'
- : `${aktuell.pendingCount} Titel warten — ansehen`;
- }
- 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 {
- wurzel: d,
- aktualisieren: (neu: Bibliothek) => {
- aktuell = neu;
- kopfSchreiben();
- pruefung.textContent = aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus';
- freigabeSchreiben();
- },
- };
- }
-
- 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(meldung(fehler)));
- }
- }
-
- return bereich;
-}
-
-/* ---------- Server ---------- */
-
-/** Sprache — eigener Abschnitt, ganz oben.
- *
- * **Warum nicht in der Serverliste.** Dort stand sie, zwischen „Name",
- * „Metadaten", „Doppelte Einträge" und „Fassung". Gesucht wurde sie
- * trotzdem, gefunden nicht — und das ist die Antwort: eine Einstellung, die
- * man sucht und nicht findet, steht am falschen Platz, egal wie gut sie
- * beschriftet ist.
- *
- * **Oben, weil sie alles andere färbt.** In dieser Sprache werden Metadaten
- * geholt und gespeichert; wer sie spät umstellt, frischt den ganzen Bestand
- * noch einmal auf.
- */
-function sprachbereich(): HTMLElement {
- const bereich = document.createElement('section');
- bereich.appendChild(abschnitt('Sprache'));
-
- const liste = document.createElement('div');
- liste.className = 'einstellungen';
- bereich.appendChild(liste);
-
- void (async () => {
- try {
- const stand = await metadatenEinstellung();
- const eigeneSprache = meineSprache();
- liste.innerHTML = '';
- // **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.
- // **Was tatsächlich vorliegt — nicht was eingestellt ist.**
- //
- // Der eigentliche Fehler war nicht, dass Titel englisch blieben, sondern
- // dass sich nicht feststellen liess, ob sie es sind: nirgends stand, in
- // welcher Sprache ein Titel geholt wurde. Hier steht es, als Zahl.
- void sprachverteilung().then(v => {
- const benennen = (k: string | null) => k === null
- ? 'unbekannt (vor dieser Fassung geholt)'
- : sprachen.find(x => x.wert === k)?.text ?? k;
-
- const zeileBestand = zeile(
- 'Bestand',
- v.anteile.length === 0
- ? 'Noch nichts zugeordnet'
- : v.anteile.map(a => `${a.anzahl} × ${benennen(a.sprache)}`).join(' · '),
- v.abweichend === 0
- ? 'Alles liegt in der Serversprache vor.'
- : `${v.abweichend} Titel liegen in einer anderen Sprache vor. Das Auffrischen `
- + 'holt genau diese neu — nicht den ganzen Bestand.');
-
- if (v.abweichend > 0) {
- const los = knopf(`${v.abweichend} auffrischen`, 'btn btn-primary');
- los.onclick = async () => {
- los.disabled = true;
- try {
- await abweichendeAuffrischen();
- los.textContent = 'Läuft…';
- } catch (fehler) {
- zeileBestand.appendChild(fehlerzeile(meldung(fehler)));
- los.disabled = false;
- }
- };
- zeileBestand.querySelector('.knopfreihe')?.appendChild(los);
- }
-
- // Direkt unter die Serversprache, denn dort wird die Frage gestellt.
- liste.insertBefore(zeileBestand, liste.children[1] ?? null);
- }).catch(() => { /* Ohne Verteilung fehlt nur die Zeile. */ });
-
- // **Weicht sie ab, steht das deutlich dabei.**
- //
- // Eine eigene Sprache ändert nicht, was der Server holt — nur was man
- // selbst sieht. Steht sie auf Englisch, während der Server auf Deutsch
- // holt, sieht die ganze Mediathek englisch aus, und jedes „Metadaten
- // auffrischen" scheint wirkungslos. Genau in diese Falle läuft man,
- // ohne je zu erfahren, warum.
- const weichtAb = eigeneSprache !== null && eigeneSprache !== stand.language;
- const serversprache = sprachen.find(x => x.wert === stand.language)?.text ?? stand.language;
-
- liste.appendChild(zeile(
- weichtAb ? 'Meine Sprache — weicht ab' : 'Meine Sprache',
- eigeneSprache ? (sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache)
- : 'Wie der Server',
- weichtAb
- ? `Du siehst alles auf ${sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache}, `
- + `obwohl der Server auf ${serversprache} holt. Titel ohne Übersetzung bleiben `
- + `auf ${serversprache}. Auf „Wie der Server" stellen, um zu sehen, was wirklich `
- + 'gespeichert ist.'
- : '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 neben ${serversprache} auf`,
- `Der Bestand liegt auf ${serversprache} vor. Das hier legt zusätzlich `
- + 'eine zweite Fassung an, für Benutzer, die eine andere Sprache gewählt '
- + 'haben — am Bestand selbst ändert es nichts. Kostet je Titel eine '
- + 'Anfrage an TMDB.',
- () => dialog({
- titel: 'Übersetzungen holen',
- text: `Die Titel des Servers bleiben auf ${serversprache}. Geholt wird `
- + 'eine zusätzliche Fassung, die nur Benutzer mit genau dieser Sprache '
- + 'zu sehen bekommen.',
- felder: [{
- name: 'sprache', beschriftung: 'Für welche Sprache',
- // **Nicht stur en-US.** Stand dort „Englisch", während der Server
- // auf Deutsch steht, las sich das wie eine Aussage über den
- // Server — und führte zu der Vermutung, die Spracheinstellung
- // wirke nicht. Vorgewählt wird die eigene Sprache, sonst die
- // erste, die nicht schon die des Servers ist.
- auswahl: sprachen,
- wert: eigeneSprache
- ?? sprachen.find(x => x.wert !== stand.language)?.wert
- ?? stand.language,
- }],
- bestaetigen: 'Holen',
- tun: async wert => {
- const bericht = await uebersetzungenHolen(wert('sprache'));
- alert(`${bericht.gesamt} Übersetzungen geholt, ${bericht.fehlend} ohne Treffer.`);
- },
- })));
- } catch (fehler) {
- liste.innerHTML = '';
- liste.appendChild(fehlerzeile(meldung(fehler)));
- }
- })();
-
- return bereich;
-
- 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;
- }
-}
-
-/** Speicher — wo die Dateien des Servers liegen.
- *
- * **Warum das in die Oberfläche gehört.** Die drei Pfade standen nur in der
- * Compose-Datei. Wer wissen wollte, wohin die Bilder wandern oder warum das
- * `cache`-Volume vollläuft, musste sich auf das NAS verbinden — für eine
- * Auskunft, die der Server längst hat.
- *
- * **Die Pfade bleiben Umgebungsvariablen.** Sie im Betrieb umzubiegen, während
- * Dateien darin liegen, hiesse: die Datenbank zeigt auf den alten Ort, der
- * Server schreibt in den neuen, und niemand merkt es, bis ein Bild fehlt.
- * Angezeigt wird deshalb der Variablenname — damit klar ist, wo man schraubt.
- */
-function speicherbereich(): HTMLElement {
- const bereich = document.createElement('section');
- bereich.appendChild(abschnitt('Speicher'));
-
- const liste = document.createElement('div');
- liste.className = 'einstellungen';
- liste.innerHTML = '';
- bereich.appendChild(liste);
-
- void laden();
-
- async function laden(): Promise {
- let orte;
- try { orte = await speicherorte(); }
- catch (fehler) {
- liste.innerHTML = '';
- liste.appendChild(fehlerzeile(meldung(fehler)));
- return;
- }
-
- liste.innerHTML = '';
-
- orte.forEach(o => {
- 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 frei = o.frei === null ? '' : ` · ${groesse(o.frei)} frei`;
- text.innerHTML = `${esc(o.name)}`
- + `${esc(o.pfad)} — ${groesse(o.belegt)} belegt${esc(frei)}`
- + `Wird gesetzt über ${esc(o.variable)}.`;
- oben.appendChild(text);
-
- // Leeren gibt es nur dort, wo es gefahrlos ist.
- if (o.bilder !== null) {
- const leeren = knopf('Bildspeicher leeren', 'btn');
- leeren.onclick = async () => {
- leeren.disabled = true;
- try {
- const { freed } = await bildspeicherLeeren();
- await laden();
- // Erst nach dem Neuladen melden, sonst steht die alte Zahl daneben.
- const gemeldet = document.createElement('p');
- gemeldet.className = 'hinweis';
- gemeldet.textContent = `${groesse(freed)} freigegeben. `
- + 'Die Bilder werden beim nächsten Aufruf neu geholt.';
- liste.prepend(gemeldet);
- } catch (fehler) {
- d.appendChild(fehlerzeile(meldung(fehler)));
- leeren.disabled = false;
- }
- };
- oben.appendChild(leeren);
- }
-
- d.appendChild(oben);
- liste.appendChild(d);
- });
- }
-
- return bereich;
-}
-
-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()]);
- 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(meldung(fehler)));
- 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();
- },
- })));
-
- // **Fassung und Build nebeneinander.**
- //
- // Die Fassung sagt, welcher Stand gemeint ist; der Build sagt, welches
- // Abbild wirklich läuft. Ohne den zweiten Wert war nicht feststellbar,
- // ob das NAS den Bau von eben zieht — und eine Fehlersuche, die diese
- // Frage nicht beantworten kann, dreht sich im Kreis.
- liste.appendChild(zeile(
- 'Fassung',
- `${auskunft.version} · Build ${auskunft.build}`,
- auskunft.build === 'DEV'
- ? 'Läuft aus dem Quellbaum, nicht aus einem Abbild.'
- : 'Diese Kennung druckt das Veröffentlichungsskript am Ende. '
- + 'Stimmt sie nicht überein, läuft hier ein älteres Abbild.'));
- } catch (fehler) {
- liste.innerHTML = '';
- liste.appendChild(fehlerzeile(meldung(fehler)));
- }
- })();
-
- 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;
-}
diff --git a/web/app/src/views/verwaltung/benutzer.ts b/web/app/src/views/verwaltung/benutzer.ts
new file mode 100644
index 0000000..9bb43cc
--- /dev/null
+++ b/web/app/src/views/verwaltung/benutzer.ts
@@ -0,0 +1,62 @@
+import { benutzer, benutzerAnlegen } from '../../api';
+import { esc, knopf, fehlerzeile, meldung } from '../../util';
+import { dialog } from '../../dialog';
+
+import { abschnitt } from './teile';
+
+/** Konten auf diesem Server. */
+
+/* ---------- Benutzer ---------- */
+
+export 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(meldung(fehler)));
+ }
+ }
+
+ return bereich;
+}
+
diff --git a/web/app/src/views/verwaltung/index.ts b/web/app/src/views/verwaltung/index.ts
new file mode 100644
index 0000000..50b203d
--- /dev/null
+++ b/web/app/src/views/verwaltung/index.ts
@@ -0,0 +1,102 @@
+import { type Bibliothek } from '../../api';
+import { icon, type IconName } from '../../icons';
+import { dashboard, serverbereich, speicherbereich } from './uebersicht';
+import { bibliotheksbereich, vermisstenbereich } from './mediatheken';
+import { pruefbereich } from './pruefung';
+import { ohneZuordnungBereich, unzugeordnetbereich } from './zuordnung';
+import { sprachbereich } from './metadaten';
+import { benutzerbereich } from './benutzer';
+import { protokollbereich } from './protokolle';
+
+/** Welcher Teil der Verwaltung offen ist. */
+export type Bereich =
+ | 'uebersicht' | 'mediatheken' | 'pruefung' | 'zuordnung'
+ | 'metadaten' | 'benutzer' | 'protokolle';
+
+const BEREICHE: { wert: Bereich; text: string; bild: IconName }[] = [
+ { wert: 'uebersicht', text: 'Übersicht', bild: 'verwaltung' },
+ { wert: 'mediatheken', text: 'Mediatheken', bild: 'filme' },
+ { wert: 'pruefung', text: 'Zu prüfen', bild: 'hakenKreis' },
+ { wert: 'zuordnung', text: 'Zuordnung', bild: 'suche' },
+ { wert: 'metadaten', text: 'Metadaten', bild: 'stift' },
+ { wert: 'benutzer', text: 'Benutzer', bild: 'person' },
+ { wert: 'protokolle', text: 'Protokolle', bild: 'protokoll' },
+];
+
+/** Macht aus einem Adressteil einen Bereich — Unbekanntes wird zur Übersicht. */
+export function alsBereich(wert: string | undefined): Bereich {
+ return BEREICHE.some(b => b.wert === wert) ? wert as Bereich : 'uebersicht';
+}
+
+/** 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.
+ *
+ * **Warum eine zweite Leiste.** Vorher hingen elf Abschnitte untereinander in
+ * einer Seite — 1786 Zeilen, und jede neue Einstellung machte sie länger. Wer
+ * das Protokoll sehen wollte, scrollte an den Mediatheken, der Prüfliste und
+ * dem Speicher vorbei. Jetzt hat jeder Bereich seine eigene Adresse: man kann
+ * ihn als Lesezeichen ablegen, und ein Neuladen bleibt dort stehen.
+ *
+ * **Nur der offene Bereich wird gebaut.** Das ist nicht nur sparsamer — das
+ * Abspielfenster der Übersicht fragt im Takt von fünf Sekunden nach, und die
+ * Mediathekenliste beobachtet laufende Scans. Was nicht dasteht, fragt auch
+ * nicht.
+ *
+ * `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat; die
+ * Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */
+export function verwaltung(
+ welcher: Bereich = 'uebersicht',
+ geaendert: (bekannt?: Bibliothek[]) => void = () => {},
+ signal: AbortSignal = new AbortController().signal,
+): HTMLElement {
+ const wurzel = document.createElement('div');
+ wurzel.className = 'verwaltung';
+
+ const leiste = document.createElement('nav');
+ leiste.className = 'verw-leiste';
+ leiste.setAttribute('aria-label', 'Bereiche der Verwaltung');
+ BEREICHE.forEach(b => {
+ const k = document.createElement('button');
+ k.type = 'button';
+ k.className = 'verw-eintrag' + (b.wert === welcher ? ' check' : '');
+ k.appendChild(icon(b.bild));
+ k.append(document.createTextNode(b.text));
+ if (b.wert === welcher) k.setAttribute('aria-current', 'page');
+ k.onclick = () => { location.hash = `#/verwaltung/${b.wert}`; };
+ leiste.appendChild(k);
+ });
+
+ const buehne = document.createElement('div');
+ buehne.className = 'verw-buehne';
+ inhalt().forEach(a => buehne.appendChild(a));
+
+ wurzel.append(leiste, buehne);
+ return wurzel;
+
+ function inhalt(): HTMLElement[] {
+ switch (welcher) {
+ case 'mediatheken':
+ return [bibliotheksbereich(geaendert, signal), vermisstenbereich(geaendert)];
+ case 'pruefung': {
+ // **Die Prüfliste hört auf sich selbst.** Wird etwas freigegeben,
+ // ändert sich der Bestand — und die Liste darüber muss nachziehen.
+ const p = pruefbereich(() => { geaendert(); p.auffrischen(); });
+ return [p.wurzel];
+ }
+ case 'zuordnung':
+ return [ohneZuordnungBereich(), unzugeordnetbereich()];
+ case 'metadaten':
+ return [sprachbereich()];
+ case 'benutzer':
+ return [benutzerbereich()];
+ case 'protokolle':
+ return [protokollbereich()];
+ default:
+ return [dashboard(signal), serverbereich(geaendert), speicherbereich()];
+ }
+ }
+}
diff --git a/web/app/src/views/verwaltung/mediatheken.ts b/web/app/src/views/verwaltung/mediatheken.ts
new file mode 100644
index 0000000..b882f75
--- /dev/null
+++ b/web/app/src/views/verwaltung/mediatheken.ts
@@ -0,0 +1,482 @@
+import {
+ bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, scanStarten,
+ scanstand, metadatenAuffrischen, vermisste, vermisstesEntfernen, type VermissteDatei,
+ pruefungSetzen, mediathekZuruecksetzen, type Bibliothek,
+} from '../../api';
+import { esc, knopf, fehlerzeile, leer, meldung } from '../../util';
+import { dialog, nachfragen } from '../../dialog';
+
+import { abschnitt } from './teile';
+
+/** Mediatheken anlegen, einlesen, aufräumen. */
+
+export function bibliotheksbereich(
+ geaendert: (bekannt?: Bibliothek[]) => void, signal: AbortSignal,
+): HTMLElement {
+
+ 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);
+
+ /** 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();
+
+ void laden();
+
+ /** **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 {
+ if (zeilen.size === 0 && liste.childElementCount === 0) {
+ liste.innerHTML = '';
+ }
+
+ 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): 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 kopf = document.createElement('div');
+ kopf.className = 'zeile';
+ d.appendChild(kopf);
+
+ function kopfSchreiben(): void {
+ const serie = aktuell.kind === 'Shows';
+
+ // **Alle Zahlen nebeneinander, weil sie Verschiedenes meinen.**
+ //
+ // Plex zählte 451 Filme, Flaro 449, und der Scan lief über 461 Dateien.
+ // Keine davon war falsch — es sind Titel, Dateien und zweite Fassungen.
+ // Einzeln gezeigt bleibt die Frage offen, nebeneinander beantwortet sie
+ // sich.
+ const teile: string[] = [aktuell.path];
+
+ if (aktuell.itemCount != null) {
+ teile.push(`${aktuell.itemCount} ` + (aktuell.itemCount === 1
+ ? (serie ? 'Serie' : 'Film')
+ : (serie ? 'Serien' : 'Filme')));
+ }
+ if (serie && aktuell.episodeCount != null) {
+ teile.push(`${aktuell.episodeCount} ${aktuell.episodeCount === 1 ? 'Folge' : 'Folgen'}`);
+ }
+ teile.push(`${aktuell.fileCount} ${aktuell.fileCount === 1 ? 'Datei' : 'Dateien'}`);
+ if (aktuell.extraVersionCount > 0) {
+ teile.push(`${aktuell.extraVersionCount} zweite Fassung`
+ + (aktuell.extraVersionCount === 1 ? '' : 'en'));
+ }
+ if (aktuell.unassignedCount > 0) teile.push(`${aktuell.unassignedCount} nicht zugeordnet`);
+ if (aktuell.pendingCount > 0) {
+ teile.push(`${aktuell.pendingCount} wartet${aktuell.pendingCount === 1 ? '' : 'en'} auf Freigabe`);
+ }
+
+ kopf.innerHTML = `${esc(aktuell.name)}`
+ + `${esc(teile.join(' · '))}`;
+ }
+ kopfSchreiben();
+
+ const stand = document.createElement('div');
+ stand.className = 'hinweis';
+
+ const los = knopf('Scannen', 'btn');
+ los.onclick = async () => {
+ laufendSetzen(true);
+ try { await scanStarten(aktuell.id); await verfolgen(true); }
+ catch (fehler) { stand.textContent = meldung(fehler); laufendSetzen(false); }
+ };
+
+ /** Sperrt alles, was einen laufenden Lauf stören würde.
+ *
+ * **Nicht nur „Scannen".** Ein zweiter Scan prallt am Server ab und ist
+ * nur verwirrend — „Zurücksetzen" und „Entfernen" dagegen löschen Zeilen,
+ * während der Lauf welche schreibt, und das endet in einem Bestand, den
+ * niemand mehr erklären kann.
+ *
+ * **Umbenennen, Prüfung und die Pfeile bleiben bedienbar.** Sie rühren
+ * den Inhalt nicht an, und ein Knopf, der ohne Not gesperrt ist, ist
+ * genauso ärgerlich wie einer, der ohne Not etwas kaputtmacht.
+ */
+ function laufendSetzen(an: boolean): void {
+ const grund = 'Läuft gerade — bitte warten, bis der Lauf durch ist.';
+ [los, auffrischen, zuruecksetzen, entfernen].forEach(k => {
+ k.disabled = an;
+ if (an) k.title = grund;
+ else if (k.title === grund) k.title = '';
+ });
+ if (!an) {
+ auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — '
+ + 'nötig nach einer Sprachänderung.';
+ }
+ }
+
+ 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 „${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(aktuell.id);
+ void verfolgen(true);
+ },
+ });
+
+ const umbenennen = knopf('Umbenennen', 'btn');
+ umbenennen.onclick = () => dialog({
+ titel: 'Mediathek umbenennen',
+ felder: [{ name: 'name', beschriftung: 'Name', wert: aktuell.name }],
+ bestaetigen: 'Speichern',
+ tun: async wert => {
+ await bibliothekUmbenennen(aktuell.id, wert('name'));
+ await laden();
+ geaendert();
+ },
+ });
+
+ // **Zurücksetzen steht neben Entfernen, weil es fast dasselbe tut** — nur
+ // ohne Namen, Pfad und Einstellungen mitzunehmen. Wer eine Mediathek
+ // löscht und neu anlegt, um sie sauber zu bekommen, tippt alles noch
+ // einmal; genau das war bisher der einzige Weg.
+ const zuruecksetzen = knopf('Zurücksetzen', 'btn');
+ zuruecksetzen.onclick = () => nachfragen({
+ 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(aktuell.id);
+ await laden();
+ geaendert();
+ },
+ });
+
+ const entfernen = knopf('Entfernen', 'btn');
+ entfernen.onclick = () => nachfragen({
+ 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(aktuell.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(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.**
+ // Beim Aufbau der Seite läuft vielleicht schon einer — dann müssen die
+ // Knöpfe gesperrt sein, ohne dass hier jemand etwas gedrückt hat.
+ if (s.state === 'running') {
+ laufendSetzen(true);
+ stand.textContent = s.step ?? 'Wird durchsucht…';
+ } else if (s.state === 'enriching') {
+ laufendSetzen(true);
+ stand.textContent = s.step ?? 'Metadaten werden geholt…';
+ } else if (s.state === 'failed') {
+ laufendSetzen(false);
+ stand.textContent = s.error ?? 'Gescheitert.';
+ return;
+ }
+
+ // **Alle Zähler auf demselben Takt.**
+ //
+ // Vorher frischte diese Schleife nur die Zeile in der Verwaltung auf,
+ // und die Seitenleiste erst am Ende des Laufs. In der Mediathek stand
+ // dann 20, in der Verwaltung 14 und in der Seitenleiste 0 — drei
+ // Zahlen für dieselbe Sache, und keine davon offensichtlich falsch.
+ // `geaendert()` baut nur die Seitenleiste neu, nicht die Ansicht;
+ // es hier zu rufen ist deshalb gefahrlos.
+ //
+ // 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) geaendert(await laden());
+
+ if (s.state === 'finished') {
+ const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
+ // „ohne Folgennummer" stimmte nicht mehr: gezählt wird jetzt auch,
+ // was eine Nummer trägt, aber zu keiner Serie gehört.
+ if (s.unrecognised) teile.push(`${s.unrecognised} nicht zugeordnet`);
+ 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(' · ');
+ laufendSetzen(false);
+ if (melden) { await laden(); geaendert(); }
+ return;
+ }
+ await new Promise(r => setTimeout(r, 1000));
+ }
+
+ // Nach fünfzehn Minuten ohne Ende: die Sperre fällt, sonst bliebe die
+ // Mediathek bis zum Neuladen der Seite unbedienbar.
+ laufendSetzen(false);
+ }
+
+ // Die Prüfung lässt sich je Mediathek umstellen — was beim Anlegen
+ // entschieden wurde, soll kein Urteil fürs Leben sein.
+ const pruefung = knopf(aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus', 'btn');
+ pruefung.onclick = async () => {
+ pruefung.disabled = true;
+ try {
+ await pruefungSetzen(aktuell.id, !aktuell.reviewNewItems);
+ await laden();
+ } catch (fehler) {
+ d.appendChild(fehlerzeile(meldung(fehler)));
+ pruefung.disabled = false;
+ }
+ };
+
+ // **Umsortiert wird in der Seitenleiste, nicht hier.**
+ //
+ // Die Reihenfolge wirkt dort, also gehört der Griff dorthin: man sieht
+ // beim Schieben, was man verändert, statt in den Einstellungen zu raten
+ // und danach nachzusehen. Der Knopf heisst „Seitenleiste bearbeiten".
+ const knoepfe = document.createElement('div');
+ knoepfe.className = 'knopfreihe';
+ knoepfe.append(los, auffrischen, pruefung, umbenennen, zuruecksetzen, 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.
+ //
+ // 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');
+ // **Zeigen statt blind freigeben.**
+ //
+ // Der Knopf veröffentlichte bisher auf einen Klick alles, was wartete —
+ // ohne dass man gesehen hätte, was. Auf die Frage „wo sehe ich die vier
+ // Filme?" gab es damit keine Antwort: die Liste stand weiter oben, aber
+ // niemand wusste das, und sie war obendrein nicht nachgeladen worden.
+ //
+ // Jetzt führt er dorthin. Veröffentlicht wird dort, wo die Titel mit
+ // Plakat und Dateiname stehen und sich einzeln anhaken lassen.
+ //
+ // **Ein Bereichswechsel, kein Sprung in der Seite.** Solange alles in
+ // einer Spalte stand, genügte `scrollIntoView`; seit „Zu prüfen" ein
+ // eigener Bereich ist, steht die Liste gar nicht mehr im selben Dokument.
+ freigeben.onclick = () => { location.hash = '#/verwaltung/pruefung'; };
+ knoepfe.appendChild(freigeben);
+
+ function freigabeSchreiben(): void {
+ // **`hidden` reicht hier nicht.** Das Attribut wirkt über
+ // `display: none` mit der Spezifität von Null — `.btn` setzt
+ // `display: inline-flex` und gewinnt. Der Knopf stand deshalb als
+ // „0 Titel freigeben" da, obwohl nichts wartet. `.hidden` trägt ein
+ // `!important` und setzt sich durch.
+ freigeben.classList.toggle('hidden', aktuell.pendingCount === 0);
+ freigeben.textContent = aktuell.pendingCount === 1
+ ? 'Ein Titel wartet — ansehen'
+ : `${aktuell.pendingCount} Titel warten — ansehen`;
+ }
+ 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 {
+ wurzel: d,
+ aktualisieren: (neu: Bibliothek) => {
+ aktuell = neu;
+ kopfSchreiben();
+ pruefung.textContent = aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus';
+ freigabeSchreiben();
+ },
+ };
+ }
+
+ 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. */
+export 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(meldung(fehler))); 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 ---------- */
+
+/** Eine gezeichnete Mediathekszeile und der Weg, sie fortzuschreiben. */
+type Mediathekzeile = {
+ wurzel: HTMLElement;
+ aktualisieren: (b: Bibliothek) => void;
+};
+
diff --git a/web/app/src/views/verwaltung/metadaten.ts b/web/app/src/views/verwaltung/metadaten.ts
new file mode 100644
index 0000000..2f14a4d
--- /dev/null
+++ b/web/app/src/views/verwaltung/metadaten.ts
@@ -0,0 +1,218 @@
+import {
+ sprachen, spracheSetzen, uebersetzungenHolen, meineSprache, metadatenEinstellung,
+ metadatenSetzen, sprachverteilung, abweichendeAuffrischen,
+} from '../../api';
+import { esc, knopf, fehlerzeile, meldung } from '../../util';
+import { dialog } from '../../dialog';
+import { icon } from '../../icons';
+
+import { abschnitt } from './teile';
+
+/** Sprache und Herkunft der Metadaten. */
+
+/* ---------- Server ---------- */
+
+/** Sprache — eigener Abschnitt, ganz oben.
+ *
+ * **Warum nicht in der Serverliste.** Dort stand sie, zwischen „Name",
+ * „Metadaten", „Doppelte Einträge" und „Fassung". Gesucht wurde sie
+ * trotzdem, gefunden nicht — und das ist die Antwort: eine Einstellung, die
+ * man sucht und nicht findet, steht am falschen Platz, egal wie gut sie
+ * beschriftet ist.
+ *
+ * **Oben, weil sie alles andere färbt.** In dieser Sprache werden Metadaten
+ * geholt und gespeichert; wer sie spät umstellt, frischt den ganzen Bestand
+ * noch einmal auf.
+ */
+export function sprachbereich(): HTMLElement {
+ const bereich = document.createElement('section');
+ bereich.appendChild(abschnitt('Sprache'));
+
+ const liste = document.createElement('div');
+ liste.className = 'einstellungen';
+ bereich.appendChild(liste);
+
+ void (async () => {
+ try {
+ const stand = await metadatenEinstellung();
+ const eigeneSprache = meineSprache();
+ liste.innerHTML = '';
+ // **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.
+ // **Was tatsächlich vorliegt — nicht was eingestellt ist.**
+ //
+ // Der eigentliche Fehler war nicht, dass Titel englisch blieben, sondern
+ // dass sich nicht feststellen liess, ob sie es sind: nirgends stand, in
+ // welcher Sprache ein Titel geholt wurde. Hier steht es, als Zahl.
+ void sprachverteilung().then(v => {
+ const benennen = (k: string | null) => k === null
+ ? 'unbekannt (vor dieser Fassung geholt)'
+ : sprachen.find(x => x.wert === k)?.text ?? k;
+
+ const zeileBestand = zeile(
+ 'Bestand',
+ v.anteile.length === 0
+ ? 'Noch nichts zugeordnet'
+ : v.anteile.map(a => `${a.anzahl} × ${benennen(a.sprache)}`).join(' · '),
+ v.abweichend === 0
+ ? 'Alles liegt in der Serversprache vor.'
+ : `${v.abweichend} Titel liegen in einer anderen Sprache vor. Das Auffrischen `
+ + 'holt genau diese neu — nicht den ganzen Bestand.');
+
+ if (v.abweichend > 0) {
+ const los = knopf(`${v.abweichend} auffrischen`, 'btn btn-primary');
+ los.onclick = async () => {
+ los.disabled = true;
+ try {
+ await abweichendeAuffrischen();
+ los.textContent = 'Läuft…';
+ } catch (fehler) {
+ zeileBestand.appendChild(fehlerzeile(meldung(fehler)));
+ los.disabled = false;
+ }
+ };
+ zeileBestand.querySelector('.knopfreihe')?.appendChild(los);
+ }
+
+ // Direkt unter die Serversprache, denn dort wird die Frage gestellt.
+ liste.insertBefore(zeileBestand, liste.children[1] ?? null);
+ }).catch(() => { /* Ohne Verteilung fehlt nur die Zeile. */ });
+
+ // **Weicht sie ab, steht das deutlich dabei.**
+ //
+ // Eine eigene Sprache ändert nicht, was der Server holt — nur was man
+ // selbst sieht. Steht sie auf Englisch, während der Server auf Deutsch
+ // holt, sieht die ganze Mediathek englisch aus, und jedes „Metadaten
+ // auffrischen" scheint wirkungslos. Genau in diese Falle läuft man,
+ // ohne je zu erfahren, warum.
+ const weichtAb = eigeneSprache !== null && eigeneSprache !== stand.language;
+ const serversprache = sprachen.find(x => x.wert === stand.language)?.text ?? stand.language;
+
+ liste.appendChild(zeile(
+ weichtAb ? 'Meine Sprache — weicht ab' : 'Meine Sprache',
+ eigeneSprache ? (sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache)
+ : 'Wie der Server',
+ weichtAb
+ ? `Du siehst alles auf ${sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache}, `
+ + `obwohl der Server auf ${serversprache} holt. Titel ohne Übersetzung bleiben `
+ + `auf ${serversprache}. Auf „Wie der Server" stellen, um zu sehen, was wirklich `
+ + 'gespeichert ist.'
+ : '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 neben ${serversprache} auf`,
+ `Der Bestand liegt auf ${serversprache} vor. Das hier legt zusätzlich `
+ + 'eine zweite Fassung an, für Benutzer, die eine andere Sprache gewählt '
+ + 'haben — am Bestand selbst ändert es nichts. Kostet je Titel eine '
+ + 'Anfrage an TMDB.',
+ () => dialog({
+ titel: 'Übersetzungen holen',
+ text: `Die Titel des Servers bleiben auf ${serversprache}. Geholt wird `
+ + 'eine zusätzliche Fassung, die nur Benutzer mit genau dieser Sprache '
+ + 'zu sehen bekommen.',
+ felder: [{
+ name: 'sprache', beschriftung: 'Für welche Sprache',
+ // **Nicht stur en-US.** Stand dort „Englisch", während der Server
+ // auf Deutsch steht, las sich das wie eine Aussage über den
+ // Server — und führte zu der Vermutung, die Spracheinstellung
+ // wirke nicht. Vorgewählt wird die eigene Sprache, sonst die
+ // erste, die nicht schon die des Servers ist.
+ auswahl: sprachen,
+ wert: eigeneSprache
+ ?? sprachen.find(x => x.wert !== stand.language)?.wert
+ ?? stand.language,
+ }],
+ bestaetigen: 'Holen',
+ tun: async wert => {
+ const bericht = await uebersetzungenHolen(wert('sprache'));
+ alert(`${bericht.gesamt} Übersetzungen geholt, ${bericht.fehlend} ohne Treffer.`);
+ },
+ })));
+ } catch (fehler) {
+ liste.innerHTML = '';
+ liste.appendChild(fehlerzeile(meldung(fehler)));
+ }
+ })();
+
+ return bereich;
+
+ 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;
+ }
+}
+
diff --git a/web/app/src/views/verwaltung/protokolle.ts b/web/app/src/views/verwaltung/protokolle.ts
new file mode 100644
index 0000000..41fd525
--- /dev/null
+++ b/web/app/src/views/verwaltung/protokolle.ts
@@ -0,0 +1,110 @@
+import { protokolltage, protokollAdresse, ApiFehler } from '../../api';
+import { esc, knopf, leer, meldung } from '../../util';
+
+import { abschnitt } from './teile';
+
+/** Die Tagesprotokolle des Servers. */
+
+export 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);
+
+ // **Blättern statt alles auf einmal.** Die ersten 800 Zeilen beantworten
+ // fast jede Frage; wer weiter zurück muss, verdoppelt mit diesem Knopf.
+ const mehr = knopf('Mehr laden', 'btn hidden');
+ bereich.appendChild(mehr);
+
+ 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);
+ });
+ })();
+
+ /** Wie viele Zeilen zuletzt geholt wurden — der Knopf „Mehr" verdoppelt. */
+ let gezeigt = 0;
+
+ async function zeigen(tag: string, nurFehler: boolean, zeilen = 0): Promise {
+ // **Nur das Ende holen, und davon anfangs wenig.** Vorher kam die ganze
+ // Datei, und davon wurden 95 % weggeworfen — bei zwei Megabyte über eine
+ // wackelige Verbindung endete das in „Failed to fetch". Bei der
+ // Fehlersuche mehr, weil davon die meisten weggefiltert werden.
+ gezeigt = zeilen > 0 ? zeilen : (nurFehler ? 5000 : 800);
+
+ anzeige.classList.remove('hidden');
+ anzeige.textContent = 'Wird geladen…';
+ mehr.classList.add('hidden');
+
+ try {
+ const antwort = await fetch(protokollAdresse(tag, gezeigt));
+ if (!antwort.ok) throw new ApiFehler(antwort.status, `${antwort.status}`);
+
+ const roh = (await antwort.text()).split('\n');
+ const gefiltert = nurFehler
+ ? roh.filter(z => z.includes('FEHLER') || z.includes('WARNUNG'))
+ : roh;
+
+ anzeige.textContent = gefiltert.join('\n').trim().length === 0
+ ? (nurFehler ? 'Keine Fehler in den letzten Zeilen.' : 'Leer.')
+ : gefiltert.join('\n');
+ anzeige.scrollTop = anzeige.scrollHeight;
+
+ // **„Mehr" nur, solange es mehr geben kann.** Kamen weniger Zeilen
+ // zurück als verlangt, ist die Datei zu Ende — ein Knopf, der dann
+ // nichts tut, ist schlechter als keiner.
+ if (roh.length >= gezeigt && gezeigt < 20000) {
+ mehr.classList.remove('hidden');
+ mehr.textContent = `Weitere ${Math.min(gezeigt, 20000 - gezeigt)} Zeilen laden`;
+ mehr.onclick = () => void zeigen(tag, nurFehler, Math.min(gezeigt * 2, 20000));
+ }
+ } catch (fehler) {
+ anzeige.textContent = `Konnte nicht gelesen werden: ${meldung(fehler)}`;
+ }
+ }
+
+ return bereich;
+}
+
diff --git a/web/app/src/views/verwaltung/pruefung.ts b/web/app/src/views/verwaltung/pruefung.ts
new file mode 100644
index 0000000..65af5ad
--- /dev/null
+++ b/web/app/src/views/verwaltung/pruefung.ts
@@ -0,0 +1,302 @@
+import {
+ zuPruefen, veroeffentlichen, zurueckziehen, zurKenntnis, type Pruefeintrag, bildAdresse,
+} from '../../api';
+import { esc, knopf, fehlerzeile, meldung } from '../../util';
+import { dialog } from '../../dialog';
+import { icons } from '../../icons';
+import { kontextmenue, menueAnZiel, type Menuepunkt } from '../../menue';
+import { bilddialog } from '../../bilder';
+import { zuordnungsdialog } from '../../zuordnung';
+import { titeldialog } from '../../titel';
+import { bearbeiten } from '../../bearbeiten';
+
+import { abschnitt } from './teile';
+
+/** Zu prüfen: was der Scan gefunden hat und noch nicht freigegeben ist. */
+
+/* ---------- 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. */
+export function pruefbereich(geaendert: () => void): {
+ wurzel: HTMLElement; auffrischen: () => void;
+} {
+ const bereich = document.createElement('section');
+ bereich.className = 'hidden';
+ bereich.id = 'zu-pruefen';
+
+ 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(meldung(fehler)));
+ } 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 = '';
+
+ // **Zwei Gruppen, zwei Bedeutungen.** Ein neuer Fund wartet auf Freigabe
+ // und ist bis dahin unsichtbar; ein geänderter Titel steht längst in der
+ // Mediathek und soll nur nicht unbemerkt anders heissen. Beides in einer
+ // Reihe hiesse, „Veröffentlichen" auf etwas anzubieten, das veröffentlicht
+ // ist.
+ const neue = stand.filter(e => e.grund !== 'geaendert');
+ const geaenderte = stand.filter(e => e.grund === 'geaendert');
+
+ neue.forEach(e => liste.appendChild(zeileFuer(e)));
+
+ if (geaenderte.length > 0) {
+ const kopf = document.createElement('div');
+ kopf.className = 'pruef-gruppe';
+ kopf.innerHTML = 'Geändert'
+ + 'Diese Titel sind schon sichtbar. Ein Auffrischen '
+ + 'hat Titel, Jahr oder Plakat geändert — hier nur zur Kenntnis.';
+
+ const abhaken = knopf('Alle zur Kenntnis', 'btn');
+ abhaken.onclick = async () => {
+ abhaken.disabled = true;
+ try { await zurKenntnis(); await laden(); }
+ catch (fehler) { liste.prepend(fehlerzeile(meldung(fehler))); abhaken.disabled = false; }
+ };
+ kopf.appendChild(abhaken);
+
+ liste.append(kopf, ...geaenderte.map(zeileFuer));
+ }
+ }
+
+ function zeileFuer(e: Pruefeintrag): HTMLElement {
+ const d = document.createElement('div');
+ d.className = 'einstellung pruefzeile';
+
+ const geaendertes = e.grund === 'geaendert';
+
+ const haken = document.createElement('input');
+ haken.type = 'checkbox';
+ haken.checked = gewaehlt.has(e.id);
+ haken.setAttribute('aria-label', `„${e.title}“ auswählen`);
+ // Die Sammelaktionen oben veröffentlichen — auf Geändertes passt das nicht.
+ haken.classList.toggle('hidden', geaendertes);
+ 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 = geaendertes
+ ? 'Sichtbar — ein Auffrischen hat etwas geändert'
+ : 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(' · ')}`;
+
+ // **Geändertes wird abgehakt, nicht veröffentlicht** — es ist schon
+ // sichtbar. „Veröffentlichen" wäre dort ein Knopf, der nichts tut.
+ const nur = knopf(
+ geaendertes ? 'Zur Kenntnis' : geplant ? 'Jetzt veröffentlichen' : 'Veröffentlichen',
+ 'btn');
+ nur.onclick = async () => {
+ nur.disabled = true;
+ try {
+ if (geaendertes) await zurKenntnis([e.id]);
+ else await veroeffentlichen(e.ids);
+ await laden();
+ geaendert();
+ } catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); nur.disabled = false; }
+ };
+
+ // **Dasselbe Dreipunktemenü wie auf einer Kachel.**
+ //
+ // Hier stand bisher nur „Veröffentlichen" — ausgerechnet bei dem Titel,
+ // den man vor der Freigabe geradeziehen will. Bilder, Zuordnung und Titel
+ // waren an einem wartenden Eintrag überhaupt nicht erreichbar.
+ const art = e.kind === 'series' ? 'series' : 'movies';
+ const mehr = document.createElement('button');
+ mehr.className = 'btn rund';
+ mehr.innerHTML = icons.punkte;
+ mehr.setAttribute('aria-label', `Mehr zu „${e.title}“`);
+ const punkte = (): Menuepunkt[] => [
+ // Auch hier: ein Ort für alles, die Einzelnen bleiben als Abkürzung.
+ { text: 'Bearbeiten', icon: 'stift',
+ tun: () => bearbeiten(art, e.id, () => void laden()) },
+ { text: 'Titel ändern', icon: 'stift',
+ tun: () => titeldialog(art, e.id, e.title, () => void laden()) },
+ { text: 'Bilder bearbeiten', icon: 'stift',
+ tun: () => bilddialog(art, e.id, e.title, () => void laden()) },
+ { text: 'Zuordnung korrigieren', icon: 'suche',
+ // Ohne Jahr — siehe card.ts: das der falschen Zuordnung grenzt falsch ein.
+ tun: () => zuordnungsdialog(art, e.id, e.title, null, () => void laden()) },
+ ];
+ mehr.onclick = () => menueAnZiel(mehr, punkte());
+ kontextmenue(mehr, punkte);
+
+ const oben = document.createElement('div');
+ oben.className = 'knopfreihe';
+ oben.append(haken, bild, text, mehr, 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(meldung(fehler))); 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())}`;
+ }
+
+ // **Nachladbar, und das fehlte.** Die Liste wurde einmal beim Öffnen
+ // geholt. Fand ein laufender Scan danach etwas Neues, zählte die Zeile der
+ // Mediathek es mit — „4 warten auf Freigabe" —, die Liste darüber blieb
+ // aber leer und sogar versteckt. Da stand eine Zahl ohne das, worauf sie
+ // sich bezog.
+ return { wurzel: bereich, auffrischen: () => void laden() };
+}
+
+/* ---------- 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. */
+/** Dateien, die zu keinem Titel gehören.
+ *
+ * **Warum das eine eigene Ansicht verdient.** Der Scan erfindet keine Serie
+ * mehr, wenn weder Ordner noch Dateiname eine nennen — richtig so, aber die
+ * Dateien verschwanden damit lautlos. Bei 3559 von 5884 war das die halbe
+ * Mediathek, und in der Übersicht stand eine Zahl, die niemand erklären
+ * konnte.
+ *
+ * **Mit Grund je Datei**, weil die beiden Gründe Verschiedenes verlangen:
+ * fehlt die Folgennummer, muss umbenannt werden; fehlt der Serienname, genügt
+ * ein Ordner darum herum.
+ */
+
diff --git a/web/app/src/views/verwaltung/teile.ts b/web/app/src/views/verwaltung/teile.ts
new file mode 100644
index 0000000..6dc0db4
--- /dev/null
+++ b/web/app/src/views/verwaltung/teile.ts
@@ -0,0 +1,20 @@
+
+/** Gemeinsame Bausteine der Verwaltung. */
+
+/** Überschrift mit einem Knopf rechts daneben. */
+export 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;
+}
+
diff --git a/web/app/src/views/verwaltung/uebersicht.ts b/web/app/src/views/verwaltung/uebersicht.ts
new file mode 100644
index 0000000..0e61331
--- /dev/null
+++ b/web/app/src/views/verwaltung/uebersicht.ts
@@ -0,0 +1,335 @@
+import {
+ doppelteZusammenlegen, metadatenEinstellung, metadatenSetzen, servernameSetzen,
+ systemauskunft, laufendeWiedergaben, serverzustand, bildAdresse, speicherorte,
+ bildspeicherLeeren, type Wiedergabe,
+} from '../../api';
+import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../../util';
+import { dialog } from '../../dialog';
+import { icon } from '../../icons';
+
+import { abschnitt } from './teile';
+
+/** Übersicht: was läuft, was der Server meldet, was er belegt. */
+
+/* ---------- 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. */
+export 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(meldung(fehler)));
+ 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. */
+export 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.`;
+}
+
+export 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()]);
+ 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(meldung(fehler)));
+ 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();
+ },
+ })));
+
+ // **Fassung und Build nebeneinander.**
+ //
+ // Die Fassung sagt, welcher Stand gemeint ist; der Build sagt, welches
+ // Abbild wirklich läuft. Ohne den zweiten Wert war nicht feststellbar,
+ // ob das NAS den Bau von eben zieht — und eine Fehlersuche, die diese
+ // Frage nicht beantworten kann, dreht sich im Kreis.
+ liste.appendChild(zeile(
+ 'Fassung',
+ `${auskunft.version} · Build ${auskunft.build}`,
+ auskunft.build === 'DEV'
+ ? 'Läuft aus dem Quellbaum, nicht aus einem Abbild.'
+ : 'Diese Kennung druckt das Veröffentlichungsskript am Ende. '
+ + 'Stimmt sie nicht überein, läuft hier ein älteres Abbild.'));
+ } catch (fehler) {
+ liste.innerHTML = '';
+ liste.appendChild(fehlerzeile(meldung(fehler)));
+ }
+ })();
+
+ 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;
+}
+
+/** Speicher — wo die Dateien des Servers liegen.
+ *
+ * **Warum das in die Oberfläche gehört.** Die drei Pfade standen nur in der
+ * Compose-Datei. Wer wissen wollte, wohin die Bilder wandern oder warum das
+ * `cache`-Volume vollläuft, musste sich auf das NAS verbinden — für eine
+ * Auskunft, die der Server längst hat.
+ *
+ * **Die Pfade bleiben Umgebungsvariablen.** Sie im Betrieb umzubiegen, während
+ * Dateien darin liegen, hiesse: die Datenbank zeigt auf den alten Ort, der
+ * Server schreibt in den neuen, und niemand merkt es, bis ein Bild fehlt.
+ * Angezeigt wird deshalb der Variablenname — damit klar ist, wo man schraubt.
+ */
+export function speicherbereich(): HTMLElement {
+ const bereich = document.createElement('section');
+ bereich.appendChild(abschnitt('Speicher'));
+
+ const liste = document.createElement('div');
+ liste.className = 'einstellungen';
+ liste.innerHTML = '';
+ bereich.appendChild(liste);
+
+ void laden();
+
+ async function laden(): Promise {
+ let orte;
+ try { orte = await speicherorte(); }
+ catch (fehler) {
+ liste.innerHTML = '';
+ liste.appendChild(fehlerzeile(meldung(fehler)));
+ return;
+ }
+
+ liste.innerHTML = '';
+
+ orte.forEach(o => {
+ 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 frei = o.frei === null ? '' : ` · ${groesse(o.frei)} frei`;
+ text.innerHTML = `${esc(o.name)}`
+ + `${esc(o.pfad)} — ${groesse(o.belegt)} belegt${esc(frei)}`
+ + `Wird gesetzt über ${esc(o.variable)}.`;
+ oben.appendChild(text);
+
+ // Leeren gibt es nur dort, wo es gefahrlos ist.
+ if (o.bilder !== null) {
+ const leeren = knopf('Bildspeicher leeren', 'btn');
+ leeren.onclick = async () => {
+ leeren.disabled = true;
+ try {
+ const { freed } = await bildspeicherLeeren();
+ await laden();
+ // Erst nach dem Neuladen melden, sonst steht die alte Zahl daneben.
+ const gemeldet = document.createElement('p');
+ gemeldet.className = 'hinweis';
+ gemeldet.textContent = `${groesse(freed)} freigegeben. `
+ + 'Die Bilder werden beim nächsten Aufruf neu geholt.';
+ liste.prepend(gemeldet);
+ } catch (fehler) {
+ d.appendChild(fehlerzeile(meldung(fehler)));
+ leeren.disabled = false;
+ }
+ };
+ oben.appendChild(leeren);
+ }
+
+ d.appendChild(oben);
+ liste.appendChild(d);
+ });
+ }
+
+ return bereich;
+}
+
diff --git a/web/app/src/views/verwaltung/zuordnung.ts b/web/app/src/views/verwaltung/zuordnung.ts
new file mode 100644
index 0000000..e3c7217
--- /dev/null
+++ b/web/app/src/views/verwaltung/zuordnung.ts
@@ -0,0 +1,280 @@
+import {
+ bibliotheken, unzugeordnet, ohneZuordnung, mehrfachfassungen, dateiHerausloesen,
+ type Mehrfachtitel,
+} from '../../api';
+import { esc, knopf, meldung, hinweisZeigen } from '../../util';
+import { bearbeiten } from '../../bearbeiten';
+
+import { abschnitt } from './teile';
+
+/** Was sich nicht zuordnen liess. */
+
+/** Titel, zu denen die Metadatenquelle nichts hergab.
+ *
+ * **Weil die Zahl allein nichts erklärt.** In der Übersicht standen 448
+ * Filme, in Plex 451 — und nirgends, welche drei fehlen oder warum. Ein Film
+ * ohne Zuordnung steht zwar in der Mediathek, aber unter seinem Dateinamen
+ * und ohne Plakat; wer ihn sucht, findet ihn nicht, weil er anders heisst.
+ *
+ * Daneben: Titel mit mehr als einer Datei. Zwei Dateien an einem Film sind
+ * meist zwei Fassungen und in Ordnung — manchmal aber zwei verschiedene
+ * Filme, die bei der Quelle auf denselben Treffer liefen. An den Dateinamen
+ * sieht man das in zwei Sekunden.
+ */
+export function ohneZuordnungBereich(): HTMLElement {
+ const bereich = document.createElement('section');
+ bereich.classList.add('hidden');
+ bereich.appendChild(abschnitt('Ohne Zuordnung'));
+
+ const liste = document.createElement('div');
+ liste.className = 'einstellungen';
+ bereich.appendChild(liste);
+
+ void laden();
+
+ async function laden(): Promise {
+ let alle;
+ try { alle = await bibliotheken(); }
+ catch { return; }
+
+ const ergebnisse = await Promise.all(alle.map(async b => {
+ try {
+ const [offen, mehrfach] = await Promise.all([
+ ohneZuordnung(b.id), mehrfachfassungen(b.id),
+ ]);
+ return { b, offen, mehrfach };
+ } catch { return null; }
+ }));
+
+ const mitFunden = ergebnisse
+ .filter(e => e !== null)
+ .filter(e => e!.offen.length > 0 || e!.mehrfach.gesamt > 0);
+
+ if (mitFunden.length === 0) { bereich.classList.add('hidden'); return; }
+ bereich.classList.remove('hidden');
+
+ liste.innerHTML = '';
+
+ mitFunden.forEach(e => {
+ const { b, offen, mehrfach } = e!;
+
+ if (offen.length > 0) {
+ liste.appendChild(gruppe(
+ `${b.name} — ${anzahl(offen.length, 'Titel')} ohne Zuordnung`,
+ 'Diese stehen unter ihrem Dateinamen in der Mediathek. '
+ + 'Über „Zuordnung korrigieren" lassen sie sich geradeziehen.',
+ offen.map(t => ({
+ titel: t.titel + (t.jahr ? ` (${t.jahr})` : ''),
+ dazu: t.beispiel ?? `${anzahl(t.dateien, 'Datei')}`,
+ // **Hier bearbeiten, nicht woandershin springen.** Wer eine
+ // Liste durchgeht, will nach dem Geradeziehen beim nächsten
+ // weitermachen — nicht jedes Mal zurücknavigieren.
+ tun: () => bearbeiten(t.art === 'series' ? 'series' : 'movies', t.id, () => void laden()),
+ }))));
+ }
+
+ if (mehrfach.gesamt > 0) {
+ liste.appendChild(fassungsgruppe(
+ `${b.name} — ${anzahl(mehrfach.gesamt, 'Titel')} mit mehreren Dateien`,
+ 'Meist zwei Fassungen desselben Films — dann ist alles in Ordnung. '
+ + 'Stehen hier zwei verschiedene Filme, war einer davon nicht mehr zu '
+ + 'sehen: „Eigener Film" löst ihn wieder heraus.',
+ mehrfach.titel));
+ }
+ });
+ }
+
+ function anzahl(n: number, wort: string): string {
+ return `${n} ${n === 1 ? wort : (wort === 'Datei' ? 'Dateien' : wort)}`;
+ }
+
+ /** Dieselbe Gruppe, aber jede Datei einzeln mit einem Weg heraus. */
+ function fassungsgruppe(
+ ueberschrift: string, hinweis: string, titel: Mehrfachtitel[],
+ ): HTMLElement {
+ const d = document.createElement('div');
+ d.className = 'einstellung';
+
+ const kopf = document.createElement('div');
+ kopf.className = 'zeile';
+ kopf.innerHTML = `${esc(ueberschrift)}`
+ + `${esc(hinweis)}`;
+ d.appendChild(kopf);
+
+ const innen = document.createElement('div');
+ innen.className = 'ohnezuordnung';
+
+ titel.forEach(t => {
+ const zeile = document.createElement('div');
+ zeile.className = 'fassungstitel';
+
+ const name = document.createElement('button');
+ name.className = 'ohnezuordnung-zeile';
+ name.innerHTML = `${esc(t.titel + (t.jahr ? ` (${t.jahr})` : ''))}`;
+ name.onclick = () => bearbeiten(
+ t.art === 'series' ? 'series' : 'movies', t.id, () => void laden());
+ zeile.appendChild(name);
+
+ t.dateien.forEach(f => {
+ const d2 = document.createElement('div');
+ d2.className = 'fassungsdatei';
+
+ const n = document.createElement('span');
+ n.className = 'oz-dazu';
+ n.textContent = f.name;
+
+ // **Nur bei mehr als einer Datei sinnvoll** — und mehr als eine ist
+ // hier die Bedingung, überhaupt in der Liste zu stehen.
+ const raus = knopf('Eigener Film', 'btn klein');
+ raus.onclick = async () => {
+ raus.disabled = true;
+ try {
+ const neu = await dateiHerausloesen(f.id);
+ hinweisZeigen(`„${neu.title}" ist jetzt ein eigener Film — ohne Zuordnung.`);
+ await laden();
+ } catch (fehler) {
+ hinweisZeigen(meldung(fehler), 'fehler');
+ raus.disabled = false;
+ }
+ };
+
+ d2.append(n, raus);
+ zeile.appendChild(d2);
+ });
+
+ innen.appendChild(zeile);
+ });
+
+ d.appendChild(innen);
+ return d;
+ }
+
+ function gruppe(
+ ueberschrift: string, hinweis: string,
+ zeilen: { titel: string; dazu: string; tun: () => void }[],
+ ): HTMLElement {
+ const d = document.createElement('div');
+ d.className = 'einstellung';
+
+ const kopf = document.createElement('div');
+ kopf.className = 'zeile';
+ kopf.innerHTML = `${esc(ueberschrift)}`
+ + `${esc(hinweis)}`;
+ d.appendChild(kopf);
+
+ const innen = document.createElement('div');
+ innen.className = 'ohnezuordnung';
+ zeilen.forEach(z => {
+ const b = document.createElement('button');
+ b.className = 'ohnezuordnung-zeile';
+ b.innerHTML = `${esc(z.titel)}`
+ + `${esc(z.dazu)}`;
+ b.onclick = z.tun;
+ innen.appendChild(b);
+ });
+ d.appendChild(innen);
+
+ return d;
+ }
+
+ return bereich;
+}
+
+export function unzugeordnetbereich(): HTMLElement {
+ const bereich = document.createElement('section');
+ const kopf = abschnitt('Nicht zugeordnet');
+ bereich.appendChild(kopf);
+
+ const liste = document.createElement('div');
+ liste.className = 'einstellungen';
+ bereich.appendChild(liste);
+
+ void laden();
+
+ async function laden(): Promise {
+ let alle;
+ try { alle = await bibliotheken(); }
+ catch { bereich.classList.add('hidden'); return; }
+
+ const ergebnisse = await Promise.all(alle.map(async b => {
+ try { return { b, offen: await unzugeordnet(b.id) }; }
+ catch { return null; }
+ }));
+
+ const mitFunden = ergebnisse.filter(e => e !== null).filter(e => e!.offen.gesamt > 0);
+
+ // Nichts offen heisst: der Abschnitt hat hier nichts verloren.
+ if (mitFunden.length === 0) { bereich.classList.add('hidden'); return; }
+ bereich.classList.remove('hidden');
+
+ liste.innerHTML = '';
+
+ mitFunden.forEach(eintrag => {
+ const { b, offen } = eintrag!;
+
+ 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(b.name)}`
+ + `${offen.gesamt} ${offen.gesamt === 1 ? 'Datei' : 'Dateien'} `
+ + 'ohne Zuordnung';
+ oben.appendChild(text);
+
+ // **Zum Verschicken.** Eine Liste, die man markieren und kopieren muss,
+ // schickt niemand — und ohne die Pfade lässt sich nicht sagen, woran
+ // die Erkennung scheitert.
+ const holen = knopf('Liste herunterladen', 'btn');
+ holen.onclick = () => {
+ const zeilen = offen.dateien.map(f => `${f.pfad}\t${f.grund}`).join('\n');
+ const blob = new Blob(
+ [`# ${b.name} — ${offen.gesamt} ohne Zuordnung, davon ${offen.dateien.length} aufgeführt\n`
+ + zeilen + '\n'],
+ { type: 'text/plain;charset=utf-8' });
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = `nicht-zugeordnet-${b.name}.txt`;
+ a.click();
+ URL.revokeObjectURL(a.href);
+ };
+ oben.appendChild(holen);
+ d.appendChild(oben);
+
+ // **Nach Grund gebündelt, nicht als Liste von dreitausend Zeilen.** So
+ // sieht man in zwei Zeilen, ob umzubenennen oder einzuordnen ist.
+ const gruende = new Map();
+ offen.dateien.forEach(f => {
+ if (!gruende.has(f.grund)) gruende.set(f.grund, []);
+ gruende.get(f.grund)!.push(f.pfad);
+ });
+
+ [...gruende.entries()]
+ .sort((a, c) => c[1].length - a[1].length)
+ .forEach(([grund, pfade]) => {
+ const g = document.createElement('details');
+ g.className = 'grundblock';
+
+ const z = document.createElement('summary');
+ z.innerHTML = `${pfade.length}× ${esc(grund)}`;
+ g.appendChild(z);
+
+ const pre = document.createElement('pre');
+ pre.className = 'protokoll';
+ pre.textContent = pfade.slice(0, 50).join('\n')
+ + (pfade.length > 50 ? `\n… und ${pfade.length - 50} weitere` : '');
+ g.appendChild(pre);
+
+ d.appendChild(g);
+ });
+
+ liste.appendChild(d);
+ });
+ }
+
+ return bereich;
+}
+