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