import { bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, scanStarten, scanstand, metadatenAuffrischen, benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen, servernameSetzen, systemauskunft, laufendeWiedergaben, serverzustand, bildAdresse, type Bibliothek, type Wiedergabe, } from '../api'; import { esc, dauer, knopf, fehlerzeile, leer } from '../util'; import { dialog, nachfragen } from '../dialog'; import { icon } from '../icons'; /** Die Verwaltung. * * **Was etwas anlegt oder ändert, steht in einem Dialog**, nicht als Block * in der Seite. Ein Formular, das immer dasteht, nimmt Platz für etwas weg, * das man einmal im Monat braucht — und sieht aus wie eine Eingabe, die * noch aussteht. * * `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat; die * Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */ export function verwaltung(geaendert: () => void = () => {}): HTMLElement { const wurzel = document.createElement('div'); wurzel.append(dashboard(), bibliotheksbereich(geaendert), benutzerbereich(), serverbereich()); 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(): HTMLElement { const bereich = document.createElement('section'); bereich.appendChild(abschnitt('Wird abgespielt')); const liste = document.createElement('div'); liste.className = 'einstellungen'; liste.innerHTML = '
'; const zustand = document.createElement('div'); zustand.className = 'hinweis'; bereich.append(liste, zustand); let timer: number | undefined; void laden(); // Aufhören, sobald die Seite weg ist — sonst fragt eine unsichtbare // Verwaltung bis zum Neuladen weiter. const waechter = new MutationObserver(() => { if (!bereich.isConnected) { window.clearTimeout(timer); waechter.disconnect(); } }); waechter.observe(document.body, { childList: true, subtree: true }); async function laden(): Promise { try { const [laufend, stand] = await Promise.all([laufendeWiedergaben(), serverzustand()]); liste.innerHTML = ''; if (laufend.length === 0) { liste.appendChild(leer('Derzeit wird nichts gespielt.')); } else { laufend.forEach(w => liste.appendChild(wiedergabe(w))); } const teile = [`Läuft seit ${laufzeit(stand.uptime)}`]; if (stand.packaging > 0) teile.push(`${stand.packaging} Umwandlung(en) laufen`); if (stand.scanning > 0) teile.push(`${stand.scanning} Scan(s) laufen`); zustand.textContent = teile.join(' · '); } catch (fehler) { liste.innerHTML = ''; liste.appendChild(fehlerzeile((fehler as Error).message)); return; // Nicht weiter nachfragen, wenn es scheitert. } 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.`; } /* ---------- Bibliotheken ---------- */ function bibliotheksbereich(geaendert: () => void): 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', platzhalter: '/media/Filme', hinweis: 'Der Pfad im Container, nicht der auf dem NAS — ' + 'also das, was in der Compose-Datei hinter dem Doppelpunkt steht.', }, ], bestaetigen: 'Anlegen', tun: async wert => { await bibliothekAnlegen(wert('name'), wert('art'), wert('pfad')); await laden(); geaendert(); }, }); bereich.appendChild(abschnitt('Mediatheken', [anlegen])); const liste = document.createElement('div'); liste.className = 'einstellungen'; bereich.appendChild(liste); void laden(); async function laden(): Promise { liste.innerHTML = '
'; try { const alle = await bibliotheken(); liste.innerHTML = ''; if (alle.length === 0) { liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.')); return; } alle.forEach(b => liste.appendChild(block(b))); } catch (fehler) { liste.innerHTML = ''; liste.appendChild(fehlerzeile((fehler as Error).message)); } } function block(b: Bibliothek): HTMLElement { const d = document.createElement('div'); d.className = 'einstellung'; const serie = b.kind === 'Shows'; // „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat. const anzahl = b.itemCount == null ? '' : ` · ${b.itemCount} ` + (b.itemCount === 1 ? (serie ? 'Serie' : 'Film') : (serie ? 'Serien' : 'Filme')); const kopf = document.createElement('div'); kopf.className = 'zeile'; kopf.innerHTML = `${esc(b.name)}` + `${esc(b.path)}${esc(anzahl)}`; d.appendChild(kopf); const stand = document.createElement('div'); stand.className = 'hinweis'; const los = knopf('Scannen', 'btn'); los.onclick = async () => { los.disabled = true; try { await scanStarten(b.id); await verfolgen(); } catch (fehler) { stand.textContent = (fehler as Error).message; } los.disabled = false; }; const auffrischen = knopf('Metadaten auffrischen', 'btn'); auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — ' + 'nötig nach einer Sprachänderung.'; auffrischen.onclick = () => nachfragen({ titel: `Metadaten von „${b.name}" auffrischen?`, text: 'Alle Titel werden erneut bei TMDB geholt — über die bekannte Kennung, ' + 'nicht über eine neue Suche. Zuordnungen von Hand bleiben damit stehen. ' + 'Bei vielen Titeln dauert es.', bestaetigen: 'Auffrischen', tun: async () => { await metadatenAuffrischen(b.id); void verfolgen(); }, }); const umbenennen = knopf('Umbenennen', 'btn'); umbenennen.onclick = () => dialog({ titel: 'Mediathek umbenennen', felder: [{ name: 'name', beschriftung: 'Name', wert: b.name }], bestaetigen: 'Speichern', tun: async wert => { await bibliothekUmbenennen(b.id, wert('name')); await laden(); geaendert(); }, }); const entfernen = knopf('Entfernen', 'btn'); entfernen.onclick = () => nachfragen({ titel: `„${b.name}" entfernen?`, text: 'Die Mediendateien auf der Platte bleiben unangetastet. Weg sind die ' + 'Einträge, die Zuordnungen und der Sehfortschritt aller Benutzer dazu.', bestaetigen: 'Entfernen', tun: async () => { await bibliothekLoeschen(b.id); await laden(); geaendert(); }, }); /** Fragt nach, bis der Lauf durch ist — er läuft im Hintergrund. */ async function verfolgen(): Promise { for (let i = 0; i < 900; i++) { const s = await scanstand(b.id); if (s.state === 'running') stand.textContent = 'Wird durchsucht…'; else if (s.state === 'enriching') stand.textContent = 'Metadaten werden geholt…'; else if (s.state === 'failed') { stand.textContent = s.error ?? 'Gescheitert.'; return; } else if (s.state === 'finished') { const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`]; if (s.unrecognised) teile.push(`${s.unrecognised} ohne Folgennummer`); if (s.metadata?.skipped) teile.push('Metadaten übersprungen'); else if (s.metadata) teile.push(`${s.metadata.moviesMatched + s.metadata.seriesMatched} zugeordnet`); if (s.metadata?.error) teile.push(s.metadata.error); stand.textContent = teile.join(' · '); await laden(); geaendert(); return; } await new Promise(r => setTimeout(r, 1000)); } } const knoepfe = document.createElement('div'); knoepfe.className = 'knopfreihe'; knoepfe.append(los, auffrischen, umbenennen, entfernen); d.append(knoepfe, stand); void verfolgen(); return d; } return bereich; } /* ---------- Benutzer ---------- */ function benutzerbereich(): HTMLElement { const bereich = document.createElement('section'); const anlegen = knopf('Benutzer hinzufügen', 'btn'); anlegen.onclick = () => dialog({ titel: 'Benutzer hinzufügen', felder: [ { name: 'name', beschriftung: 'Benutzername' }, { name: 'passwort', beschriftung: 'Passwort', art: 'password', hinweis: 'Mindestens acht Zeichen.', }, { name: 'admin', beschriftung: 'Administrator', schalter: true }, ], bestaetigen: 'Anlegen', tun: async wert => { await benutzerAnlegen(wert('name'), wert('passwort'), wert('admin') === 'true'); await laden(); }, }); bereich.appendChild(abschnitt('Benutzer', [anlegen])); const liste = document.createElement('div'); liste.className = 'einstellungen'; bereich.appendChild(liste); void laden(); async function laden(): Promise { liste.innerHTML = '
'; try { const alle = await benutzer(); liste.innerHTML = ''; alle.forEach(b => { const d = document.createElement('div'); d.className = 'einstellung'; d.innerHTML = `
${esc(b.userName)}` + `${b.isAdministrator ? 'Administrator' : 'Benutzer'}
`; liste.appendChild(d); }); } catch (fehler) { liste.innerHTML = ''; liste.appendChild(fehlerzeile((fehler as Error).message)); } } return bereich; } /* ---------- Server ---------- */ function serverbereich(): 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(); }, }))); liste.appendChild(zeile( 'Metadaten', stand.usingProjectKey ? `Mitgelieferter TMDB-Zugang · ${stand.language}` : stand.hasApiKey ? `Eigener TMDB-Zugang · ${stand.language}` : '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.', }, { name: 'sprache', beschriftung: 'Sprache', wert: stand.language, hinweis: 'Etwa de-DE. Bestimmt auch, welche Altersfreigabe gilt.', }, ], bestaetigen: 'Speichern', tun: async wert => { await metadatenSetzen({ apiKey: wert('schluessel') || undefined, language: wert('sprache') || undefined, }); location.reload(); }, }))); liste.appendChild(zeile('Fassung', auskunft.version, null)); } catch (fehler) { liste.innerHTML = ''; liste.appendChild(fehlerzeile((fehler as Error).message)); } })(); function zeile( name: string, wert: string, hinweis: string | null, bearbeiten?: () => void, ): HTMLElement { const d = document.createElement('div'); d.className = 'einstellung'; const oben = document.createElement('div'); oben.className = 'knopfreihe'; const text = document.createElement('div'); text.className = 'zeile'; text.style.flex = '1'; text.innerHTML = `${esc(name)}${esc(wert)}`; oben.appendChild(text); if (bearbeiten) { const b = knopf('Ändern', 'btn'); b.prepend(icon('stift')); b.onclick = bearbeiten; oben.appendChild(b); } d.appendChild(oben); if (hinweis) { const h = document.createElement('div'); h.className = 'hinweis'; h.textContent = hinweis; d.appendChild(h); } return d; } liste.innerHTML = '
'; return bereich; }