import {
bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen,
scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen,
vermisste, vermisstesEntfernen, type VermissteDatei,
protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen,
meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen,
mediathekFreigeben, mediathekZuruecksetzen,
type Pruefeintrag,
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
servernameSetzen, systemauskunft,
laufendeWiedergaben, serverzustand, bildAdresse,
speicherorte, bildspeicherLeeren, unzugeordnet,
type Bibliothek, type Wiedergabe, ApiFehler,
} from '../api';
import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } 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 = () => {},
signal: AbortSignal = new AbortController().signal,
): HTMLElement {
const wurzel = document.createElement('div');
wurzel.append(dashboard(signal), sprachbereich(), pruefbereich(geaendert),
vermisstenbereich(geaendert), bibliotheksbereich(geaendert, signal), benutzerbereich(),
serverbereich(geaendert), speicherbereich(), 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): HTMLElement {
const bereich = document.createElement('section');
bereich.className = 'hidden';
const liste = document.createElement('div');
liste.className = 'einstellungen';
/** Was gerade angehakt ist. Als Menge und nicht am DOM abgelesen: beim
* Neuladen bleibt die Auswahl damit erhalten. */
const gewaehlt = new Set();
let stand: Pruefeintrag[] = [];
const alleHaken = document.createElement('label');
alleHaken.className = 'bleiben';
const alleKasten = document.createElement('input');
alleKasten.type = 'checkbox';
alleHaken.append(alleKasten, document.createTextNode(' Alle'));
alleKasten.onchange = () => {
gewaehlt.clear();
if (alleKasten.checked) stand.forEach(e => gewaehlt.add(e.id));
zeichnen();
};
const freigeben = knopf('Veröffentlichen', 'btn btn-primary');
freigeben.onclick = () => void handeln(undefined);
const planen = knopf('Planen…', 'btn');
planen.onclick = () => dialog({
titel: `${betroffene().length} Titel planen`,
text: 'Ab diesem Zeitpunkt erscheinen sie für alle. Es läuft dafür nichts '
+ 'im Hintergrund — die Sichtbarkeit wird bei jeder Abfrage gerechnet und '
+ 'stimmt deshalb auch, wenn der Server gerade nicht lief.',
felder: [{
name: 'wann', beschriftung: 'Zeitpunkt', art: 'zeitpunkt',
wert: inEingabeform(new Date(Date.now() + 86400000)),
min: inEingabeform(new Date()),
}],
bestaetigen: 'Planen',
tun: async wert => {
const wann = new Date(wert('wann'));
if (Number.isNaN(wann.getTime())) throw new Error('Das ist kein Zeitpunkt.');
await handeln(wann.toISOString());
},
});
bereich.append(abschnitt('Zu prüfen', [alleHaken, freigeben, planen]), liste);
void laden();
/** Die Auswahl — oder alles, wenn nichts angehakt ist. */
function betroffene(): Pruefeintrag[] {
const angehakt = stand.filter(e => gewaehlt.has(e.id));
return angehakt.length > 0 ? angehakt : stand;
}
async function handeln(wann: string | undefined): Promise {
const ids = betroffene().flatMap(e => e.ids);
if (ids.length === 0) return;
freigeben.disabled = true;
planen.disabled = true;
try {
await veroeffentlichen(ids, wann);
gewaehlt.clear();
await laden();
geaendert();
} catch (fehler) {
liste.prepend(fehlerzeile(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 = '';
stand.forEach(e => liste.appendChild(zeileFuer(e)));
}
function zeileFuer(e: Pruefeintrag): HTMLElement {
const d = document.createElement('div');
d.className = 'einstellung pruefzeile';
const haken = document.createElement('input');
haken.type = 'checkbox';
haken.checked = gewaehlt.has(e.id);
haken.setAttribute('aria-label', `„${e.title}“ auswählen`);
haken.onchange = () => {
if (haken.checked) gewaehlt.add(e.id); else gewaehlt.delete(e.id);
zeichnen();
};
const bild = document.createElement('div');
bild.className = 'pruef-bild';
const adresse = bildAdresse(e.posterImage);
if (adresse) {
const img = document.createElement('img');
img.src = adresse; img.alt = ''; img.loading = 'lazy';
bild.appendChild(img);
}
const text = document.createElement('div');
text.className = 'zeile';
text.style.flex = '1';
const geplant = e.publishAt ? new Date(e.publishAt) : null;
const was = e.kind === 'series'
? `${e.count} ${e.count === 1 ? 'Folge' : 'Folgen'}`
: null;
const lage = geplant
? `Geplant für ${geplant.toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}`
: e.kind === 'series' && e.count > 1 && !e.publishAt
? 'Wartet'
: 'Wartet auf Freigabe';
// Ohne Zuordnung fehlen Titel und Bilder — dann ist nicht die Freigabe
// das Nächste, sondern die Zuordnung.
const hinweis = e.matched ? null : 'noch keinem Titel zugeordnet';
text.innerHTML = `${esc(e.title)}${e.year ? ` (${e.year})` : ''}`
+ `${[was, lage, hinweis].filter(Boolean).map(esc).join(' · ')}`;
const nur = knopf(geplant ? 'Jetzt veröffentlichen' : 'Veröffentlichen', 'btn');
nur.onclick = async () => {
nur.disabled = true;
try { await veroeffentlichen(e.ids); await laden(); geaendert(); }
catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); nur.disabled = false; }
};
const oben = document.createElement('div');
oben.className = 'knopfreihe';
oben.append(haken, bild, text, nur);
if (geplant) {
const zurueck = knopf('Zurückziehen', 'btn');
zurueck.onclick = async () => {
zurueck.disabled = true;
try { await zurueckziehen(e.ids); await laden(); }
catch (fehler) { d.appendChild(fehlerzeile(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())}`;
}
return bereich;
}
/* ---------- Protokolle ---------- */
/** Eine Datei je Tag, lesbar ohne Verbindung auf das NAS.
*
* Das Containerprotokoll ist nach einem Neustart weg und nur über SSH
* erreichbar — die Frage „warum wurde der Film gestern Nacht nicht erkannt?"
* lässt sich damit nicht beantworten. */
/** 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.
*/
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 ---------- */
function bibliotheksbereich(geaendert: () => 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);
void laden();
async function laden(): Promise {
// **Der Spinner gehört an den ersten Aufbau, nicht an jedes Auffrischen.**
// Sonst leert sich die Liste sichtbar, bevor dasselbe wieder erscheint —
// und bei einem Takt im Hintergrund blinkt sie dauernd.
if (liste.childElementCount === 0) liste.innerHTML = '';
try {
const alle = await bibliotheken();
liste.innerHTML = '';
if (alle.length === 0) {
liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.'));
return;
}
alle.forEach(b => liste.appendChild(block(b)));
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile(meldung(fehler)));
}
}
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'));
// Was wartet, gehört in dieselbe Zeile — sonst erfährt man es nur, wenn
// man bis zur Prüfliste weiterscrollt.
const wartet = b.pendingCount > 0
? ` · ${b.pendingCount} wartet${b.pendingCount === 1 ? '' : 'en'} auf Freigabe`
: '';
const kopf = document.createElement('div');
kopf.className = 'zeile';
kopf.innerHTML = `${esc(b.name)}`
+ `${esc(b.path)}${esc(anzahl)}${esc(wartet)}`;
d.appendChild(kopf);
const stand = document.createElement('div');
stand.className = 'hinweis';
const los = knopf('Scannen', 'btn');
los.onclick = async () => {
laufendSetzen(true);
try { await scanStarten(b.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 „${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 () => {
laufendSetzen(true);
await metadatenAuffrischen(b.id);
void verfolgen(true);
},
});
const umbenennen = knopf('Umbenennen', 'btn');
umbenennen.onclick = () => dialog({
titel: 'Mediathek umbenennen',
felder: [{ name: 'name', beschriftung: 'Name', wert: b.name }],
bestaetigen: 'Speichern',
tun: async wert => {
await bibliothekUmbenennen(b.id, wert('name'));
await laden();
geaendert();
},
});
// **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: `„${b.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(b.id);
await laden();
geaendert();
},
});
const entfernen = knopf('Entfernen', 'btn');
entfernen.onclick = () => nachfragen({
titel: `„${b.name}" entfernen?`,
text: 'Die Mediendateien auf der Platte bleiben unangetastet. Weg sind die '
+ 'Einträge, die Zuordnungen und der Sehfortschritt aller Benutzer dazu.',
bestaetigen: 'Entfernen',
tun: async () => { await bibliothekLoeschen(b.id); await laden(); geaendert(); },
});
/** Fragt nach, bis der Lauf durch ist — er läuft im Hintergrund. */
/// Verfolgt einen Lauf bis zum Ende.
///
/// **`melden` entscheidet, ob am Schluss `geaendert()` gerufen wird.**
/// Beim Aufbau der Seite ist es `false`: ein längst beendeter Lauf meldete
/// sonst „fertig", liess neu zeichnen, und der Neuaufbau verfolgte wieder
/// denselben beendeten Lauf — eine Endlosschleife, die sich als vier
/// dauernd drehende Spinner zeigte.
async function verfolgen(melden: boolean): Promise {
for (let i = 0; i < 900; i++) {
if (signal.aborted) return;
const s = await scanstand(b.id);
// **Was der Server sagt, nicht was wir raten.** Vorher stand hier
// minutenlang „Wird durchsucht…", ohne Hinweis, ob etwas passiert.
// **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) { await laden(); geaendert(); }
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(b.reviewNewItems ? 'Prüfung an' : 'Prüfung aus', 'btn');
pruefung.onclick = async () => {
pruefung.disabled = true;
try {
await pruefungSetzen(b.id, !b.reviewNewItems);
await laden();
} catch (fehler) {
d.appendChild(fehlerzeile(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.
if (b.pendingCount > 0) {
const freigeben = knopf(
`${b.pendingCount} ${b.pendingCount === 1 ? 'Titel' : 'Titel'} freigeben`,
'btn btn-primary');
freigeben.onclick = async () => {
freigeben.disabled = true;
try {
const bericht = await mediathekFreigeben(b.id);
stand.textContent = `${bericht.published} freigegeben.`;
await laden();
geaendert();
} catch (fehler) {
d.appendChild(fehlerzeile(meldung(fehler)));
freigeben.disabled = false;
}
};
knoepfe.appendChild(freigeben);
}
d.append(knoepfe, stand);
void verfolgen(false);
return d;
}
return bereich;
}
/* ---------- Benutzer ---------- */
function benutzerbereich(): HTMLElement {
const bereich = document.createElement('section');
const anlegen = knopf('Benutzer hinzufügen', 'btn');
anlegen.onclick = () => dialog({
titel: 'Benutzer hinzufügen',
felder: [
{ name: 'name', beschriftung: 'Benutzername' },
{
name: 'passwort', beschriftung: 'Passwort', art: 'password',
hinweis: 'Mindestens acht Zeichen.',
},
{ name: 'admin', beschriftung: 'Administrator', schalter: true },
],
bestaetigen: 'Anlegen',
tun: async wert => {
await benutzerAnlegen(wert('name'), wert('passwort'), wert('admin') === 'true');
await laden();
},
});
bereich.appendChild(abschnitt('Benutzer', [anlegen]));
const liste = document.createElement('div');
liste.className = 'einstellungen';
bereich.appendChild(liste);
void laden();
async function laden(): Promise {
// **Der Spinner gehört an den ersten Aufbau, nicht an jedes Auffrischen.**
// Sonst leert sich die Liste sichtbar, bevor dasselbe wieder erscheint —
// und bei einem Takt im Hintergrund blinkt sie dauernd.
if (liste.childElementCount === 0) liste.innerHTML = '';
try {
const alle = await benutzer();
liste.innerHTML = '';
alle.forEach(b => {
const d = document.createElement('div');
d.className = 'einstellung';
d.innerHTML = `${esc(b.userName)}`
+ `${b.isAdministrator ? 'Administrator' : 'Benutzer'}
`;
liste.appendChild(d);
});
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile(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.
liste.appendChild(zeile(
'Meine Sprache',
eigeneSprache ? (sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache)
: 'Wie der Server',
'Gilt nur für dich. Was nicht übersetzt vorliegt, erscheint weiterhin in '
+ 'der Sprache des Servers.',
() => dialog({
titel: 'Meine Sprache',
text: 'Die Metadaten liegen in der Sprache des Servers vor. Eine andere '
+ 'zu wählen heisst: du siehst, was übersetzt vorliegt — und für den '
+ 'Rest die Serversprache. Ein Administrator kann die Übersetzungen '
+ 'unten für alle Titel auf einmal holen.',
felder: [{
name: 'sprache', beschriftung: 'Sprache',
auswahl: [{ wert: '', text: 'Wie der Server' }, ...sprachen],
wert: eigeneSprache ?? '',
}],
bestaetigen: 'Übernehmen',
tun: async wert => {
const neu = wert('sprache');
await spracheSetzen(neu || null);
location.reload();
},
})));
liste.appendChild(zeile(
'Übersetzungen holen',
'Füllt eine zweite Sprache für alle Titel auf',
'Kostet je Titel eine Anfrage an TMDB und dauert entsprechend. '
+ 'Danach sehen Benutzer mit dieser Sprache die übersetzten Titel.',
() => dialog({
titel: 'Übersetzungen holen',
felder: [{
name: 'sprache', beschriftung: 'Für welche Sprache',
auswahl: sprachen, wert: eigeneSprache ?? 'en-US',
}],
bestaetigen: 'Holen',
tun: async wert => {
const bericht = await uebersetzungenHolen(wert('sprache'));
alert(`${bericht.gesamt} Übersetzungen geholt, ${bericht.fehlend} ohne Treffer.`);
},
})));
} 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;
}