import {
bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen,
scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen,
vermisste, vermisstesEntfernen, type VermissteDatei,
protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen,
meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen,
mediathekFreigeben, reihenfolgeSetzen,
type Pruefeintrag,
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, icons } 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), pruefbereich(geaendert), vermisstenbereich(geaendert),
bibliotheksbereich(geaendert, signal), benutzerbereich(), serverbereich(geaendert),
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((fehler as Error).message));
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((fehler as Error).message));
} 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((fehler as Error).message)); 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((fehler as Error).message)); 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. */
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);
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);
});
})();
async function zeigen(tag: string, nurFehler: boolean): Promise {
anzeige.classList.remove('hidden');
anzeige.textContent = 'Wird geladen…';
try {
const antwort = await fetch(protokollAdresse(tag));
if (!antwort.ok) throw new Error(`${antwort.status}`);
const text = await antwort.text();
const zeilen = text.split('\n');
// **Das Ende ist interessanter als der Anfang.** Ein Protokolltag kann
// zehntausend Zeilen haben; wer etwas sucht, sucht meist das Letzte.
const gefiltert = nurFehler
? zeilen.filter(z => z.includes('FEHLER') || z.includes('WARNUNG'))
: zeilen;
anzeige.textContent = gefiltert.length === 0
? (nurFehler ? 'Keine Fehler an diesem Tag.' : 'Leer.')
: gefiltert.slice(-800).join('\n');
anzeige.scrollTop = anzeige.scrollHeight;
} catch (fehler) {
anzeige.textContent = `Konnte nicht gelesen werden: ${(fehler as Error).message}`;
}
}
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((fehler as Error).message)); 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 {
/** Die Liste, wie sie gerade steht — für das Umsortieren. */
let alleBibliotheken: Bibliothek[] = [];
/** Schiebt einen Eintrag und schickt die ganze neue Reihenfolge. */
async function schieben(von: number, nach: number): Promise {
if (nach < 0 || nach >= alleBibliotheken.length) return;
const neu = [...alleBibliotheken];
const [eintrag] = neu.splice(von, 1);
neu.splice(nach, 0, eintrag!);
await reihenfolgeSetzen(neu.map(b => b.id));
await laden();
geaendert();
}
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;
}
alleBibliotheken = alle;
alle.forEach((b, i) => liste.appendChild(block(b, i)));
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile((fehler as Error).message));
}
}
function block(b: Bibliothek, platz: number): 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 () => {
los.disabled = true;
try { await scanStarten(b.id); await verfolgen(true); }
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(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();
},
});
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.
if (s.state === 'running') stand.textContent = s.step ?? 'Wird durchsucht…';
else if (s.state === 'enriching') stand.textContent = s.step ?? 'Metadaten werden geholt…';
else if (s.state === 'failed') { stand.textContent = s.error ?? 'Gescheitert.'; return; }
// **Die Zahl mitlaufen lassen, nicht erst am Ende.** Der Scan schreibt
// laufend; wer dabei zusieht, soll das auch sehen. Alle fünf Runden,
// nicht jede: ein Abruf je Sekunde für eine Zahl wäre Verschwendung.
if (melden && i > 0 && i % 5 === 0) await laden();
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);
if (s.missing) teile.push(`${s.missing} nicht mehr gefunden`);
stand.textContent = teile.join(' · ');
if (melden) { await laden(); geaendert(); }
return;
}
await new Promise(r => setTimeout(r, 1000));
}
}
// 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((fehler as Error).message));
pruefung.disabled = false;
}
};
// **Umsortieren über zwei Pfeile, nicht über Ziehen.**
//
// Ziehen und Ablegen ist schöner, aber mit der Tastatur unbedienbar und
// auf dem Telefon ein Glücksspiel. Zwei Knöpfe kann jeder bedienen, und
// der Server bekommt die ganze Liste statt eines „eins hoch" — zwei
// Benutzer, die gleichzeitig schieben, kämen sonst in einen Zustand, den
// keiner von beiden wollte.
const hoch = knopf('', 'btn rund');
hoch.innerHTML = icons.hoch;
hoch.setAttribute('aria-label', `„${b.name}“ nach oben`);
hoch.disabled = platz === 0;
hoch.onclick = () => void schieben(platz, platz - 1);
const runter = knopf('', 'btn rund');
runter.innerHTML = icons.hoch;
runter.style.transform = 'rotate(180deg)';
runter.setAttribute('aria-label', `„${b.name}“ nach unten`);
runter.disabled = platz === alleBibliotheken.length - 1;
runter.onclick = () => void schieben(platz, platz + 1);
const knoepfe = document.createElement('div');
knoepfe.className = 'knopfreihe';
knoepfe.append(hoch, runter, los, auffrischen, pruefung, umbenennen, 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((fehler as Error).message));
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((fehler as Error).message));
}
}
return bereich;
}
/* ---------- Server ---------- */
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()]);
const eigeneSprache = meineSprache();
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((fehler as Error).message));
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();
},
})));
// **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.`);
},
})));
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;
}