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; +} +