Dialoge statt Blöcke, bessere Filter, ein Dashboard — und eine NAS-Anleitung
**Was etwas anlegt oder ändert, steht jetzt in einem Dialog.** Das Formular zum Anlegen einer Mediathek nahm als Block in der Seite Platz für etwas weg, das man einmal im Monat braucht — und sah aus wie eine Eingabe, die noch aussteht. Dasselbe für Benutzer, Umbenennen, den Servernamen und die Metadaten-Einstellungen. Gebaut mit dem `<dialog>` des Browsers, nicht mit einem nachgebauten: Escape schliesst, der Hintergrund wird unbedienbar, der Tastaturfokus bleibt gefangen, Vorlesesoftware kündigt ihn als Dialog an. Jede Zeile, die das selbst baut, ist eine Zeile, die es schlechter kann. Scheitert das Speichern, bleibt der Dialog offen — ihn zu schliessen hiesse, die Eingabe wegzuwerfen und die Meldung dorthin zu schreiben, wo gerade niemand hinsieht. **Die Filter sehen nicht mehr nach Eingabe aus.** Bei Plex stehen dort Wörter mit einem kleinen Pfeil; ein umrandetes Auswahlfeld sagt „hier fehlt noch etwas", und das stimmt nicht. Gesetzte Filter werden farbig, daneben steht die Anzahl, und „Filter zurücksetzen" erscheint nur, wenn es etwas zurückzusetzen gibt. Dazu neu: gesehen / noch nicht gesehen. Das siebt vorerst in der Oberfläche, nicht am Server — er kann es noch nicht. Der Preis steht in der Zahl daneben: sie zeigt dann „3 von 7". --- **„Wird abgespielt" — wer gerade was schaut.** Abgeleitet aus den Fortschrittsmeldungen, nicht aus einer eigenen Anmeldung am Spieler: jeder Client meldet beim Abspielen ohnehin alle zehn Sekunden, wo er steht. Wer das als Lebenszeichen liest, braucht kein zweites Verfahren, das ein Client vergessen könnte — bleibt die Meldung aus, läuft der Eintrag nach einer Minute von selbst ab. Im Arbeitsspeicher, nicht in der Datenbank: „wer schaut gerade was" ist eine Frage über den Augenblick. Nach einem Neustart lautet die Antwort „niemand", und das zu speichern hiesse, Wiedergaben anzuzeigen, die es nicht gibt. Zwei Geräte desselben Menschen sind zwei Zeilen — derselbe Mensch kann auf dem Fernseher und auf dem Telefon gleichzeitig etwas laufen lassen. Geschlüsselt wird deshalb über das Gerätetoken, nicht über den Benutzer und nicht über den Gerätenamen: zwei iPhones heissen beide „iPhone". **Nur für Administratoren.** Wer wann welchen Film anschaut, ist eine Auskunft über Menschen, nicht über den Server. In einem Haushalt mag das harmlos sein; die Vorgabe eines offenen Projekts darf es trotzdem nicht sein. Der Spieler meldet beim Schliessen ausdrücklich „beendet". Ohne das stünde ein geschlossener Film noch eine Minute als „läuft" — und wer gerade den Spieler zugemacht hat, sähe sich selbst beim Schauen zu. --- **`docs/NAS-EINRICHTEN.md`** beantwortet die Frage, die offen war: **es ist ein einziges Image.** Die Weboberfläche wird beim Bauen hineingelegt und vom Server selbst ausgeliefert — ein Container, drei Volumes, kein Proxy, keine Datenbank daneben. Drei Wege aufs NAS: über die Container-Registry deines Gitea (die läuft — nachgeprüft), als Datei über `docker save`, oder dort bauen. Dazu der Schritt, den man am leichtesten überspringt und am teuersten bezahlt: die Architektur. Ein Mac baut ohne Angabe arm64, ein Intel-NAS braucht amd64, und `exec format error` sagt das nicht deutlich. Eine Zahl darin war geraten und ist korrigiert: `docker save | gzip` ergibt nicht 90 MB, sondern 230 — die Ebenen eines Images sind bereits gepackt. 378 Tests grün, sechs davon neu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a7ec3a73ba
commit
f2b5ee633b
+319
-167
@@ -1,29 +1,188 @@
|
||||
import {
|
||||
bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen,
|
||||
scanStarten, scanstand,
|
||||
scanStarten, scanstand, metadatenAuffrischen,
|
||||
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
|
||||
type Bibliothek,
|
||||
servernameSetzen, systemauskunft,
|
||||
laufendeWiedergaben, serverzustand, bildAdresse,
|
||||
type Bibliothek, type Wiedergabe,
|
||||
} from '../api';
|
||||
import { esc, knopf, feld, fehlerzeile, leer } from '../util';
|
||||
import { esc, dauer, knopf, fehlerzeile, leer } from '../util';
|
||||
import { dialog, nachfragen } from '../dialog';
|
||||
import { icon } from '../icons';
|
||||
|
||||
/// `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat —
|
||||
/// die Seitenleiste steht auf ihr und muss dann neu gezeichnet werden.
|
||||
/** 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(bibliotheksbereich(geaendert), benutzerbereich(), metadatenbereich());
|
||||
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 = '<div class="spinner"></div>';
|
||||
|
||||
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<void> {
|
||||
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 = `<strong>${esc(w.title)}</strong>`
|
||||
+ (w.subTitle ? `<span class="winzig">${esc(w.subTitle)}</span>` : '')
|
||||
+ `<span class="winzig">${esc(w.userName)} · ${esc(w.deviceName)} · ${esc(wie)}</span>`;
|
||||
|
||||
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 h = document.createElement('h2');
|
||||
h.textContent = 'Bibliotheken';
|
||||
|
||||
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.append(h, liste, anlegeform());
|
||||
bereich.appendChild(liste);
|
||||
void laden();
|
||||
|
||||
async function laden(): Promise<void> {
|
||||
@@ -32,8 +191,7 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
|
||||
const alle = await bibliotheken();
|
||||
liste.innerHTML = '';
|
||||
if (alle.length === 0) {
|
||||
liste.appendChild(leer('Noch keine Bibliothek. Lege unten eine an — der Pfad ist '
|
||||
+ 'der **im Container**, also etwa /media/Filme, nicht der auf dem NAS.'));
|
||||
liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.'));
|
||||
return;
|
||||
}
|
||||
alle.forEach(b => liste.appendChild(block(b)));
|
||||
@@ -47,79 +205,64 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'einstellung';
|
||||
|
||||
const kopf = document.createElement('div');
|
||||
kopf.className = 'zeile';
|
||||
// „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat.
|
||||
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 = `<strong>${esc(b.name)}</strong>`
|
||||
+ `<span class="winzig">${esc(b.path)}${esc(anzahl)}</span>`;
|
||||
d.appendChild(kopf);
|
||||
|
||||
const stand = document.createElement('div');
|
||||
stand.className = 'hinweis';
|
||||
|
||||
const los = knopf('Scannen', 'btn');
|
||||
const umbenennen = knopf('Umbenennen', 'btn');
|
||||
const entfernen = knopf('Entfernen', 'btn');
|
||||
|
||||
umbenennen.onclick = () => {
|
||||
umbenennen.remove();
|
||||
const eingabe = feld('Neuer Name');
|
||||
eingabe.value = b.name;
|
||||
const fertig = knopf('Speichern', 'btn btn-primary');
|
||||
const reihe = document.createElement('div');
|
||||
reihe.className = 'knopfreihe';
|
||||
reihe.append(eingabe, fertig);
|
||||
d.appendChild(reihe);
|
||||
eingabe.focus();
|
||||
|
||||
fertig.onclick = async () => {
|
||||
fertig.disabled = true;
|
||||
try {
|
||||
await bibliothekUmbenennen(b.id, eingabe.value.trim());
|
||||
await laden();
|
||||
geaendert();
|
||||
} catch (fehler) {
|
||||
d.appendChild(fehlerzeile((fehler as Error).message));
|
||||
fertig.disabled = false;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
entfernen.onclick = async () => {
|
||||
// **Einmal nachfragen.** Was hier verschwindet, ist der Eintrag und
|
||||
// der Sehfortschritt aller Benutzer dazu — die Mediendateien bleiben
|
||||
// liegen, aber das weiss nicht jeder, der auf den Knopf sieht.
|
||||
const sicher = window.confirm(
|
||||
`„${b.name}" aus Flaro entfernen?\n\n`
|
||||
+ 'Die Mediendateien auf der Platte bleiben unangetastet. Weg sind die '
|
||||
+ 'Einträge, die Zuordnungen und der Sehfortschritt aller Benutzer dazu.');
|
||||
if (!sicher) return;
|
||||
|
||||
entfernen.disabled = true;
|
||||
try {
|
||||
await bibliothekLoeschen(b.id);
|
||||
await laden();
|
||||
geaendert();
|
||||
} catch (fehler) {
|
||||
d.appendChild(fehlerzeile((fehler as Error).message));
|
||||
entfernen.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
los.onclick = async () => {
|
||||
los.disabled = true;
|
||||
try {
|
||||
await scanStarten(b.id);
|
||||
await verfolgen();
|
||||
} catch (fehler) { stand.textContent = (fehler as Error).message; }
|
||||
try { await scanStarten(b.id); await verfolgen(); }
|
||||
catch (fehler) { stand.textContent = (fehler as Error).message; }
|
||||
los.disabled = false;
|
||||
};
|
||||
|
||||
/** Fragt nach, bis der Lauf durch ist — der Scan läuft im Hintergrund. */
|
||||
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<void> {
|
||||
for (let i = 0; i < 900; i++) {
|
||||
const s = await scanstand(b.id);
|
||||
@@ -133,6 +276,8 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
|
||||
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));
|
||||
@@ -141,42 +286,12 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
|
||||
|
||||
const knoepfe = document.createElement('div');
|
||||
knoepfe.className = 'knopfreihe';
|
||||
knoepfe.append(los, umbenennen, entfernen);
|
||||
knoepfe.append(los, auffrischen, umbenennen, entfernen);
|
||||
d.append(knoepfe, stand);
|
||||
void verfolgen();
|
||||
return d;
|
||||
}
|
||||
|
||||
function anlegeform(): HTMLElement {
|
||||
const form = document.createElement('div');
|
||||
form.className = 'einstellung';
|
||||
|
||||
const name = feld('Name, etwa Filme');
|
||||
const pfad = feld('Pfad im Container, etwa /media/Filme');
|
||||
const art = document.createElement('select');
|
||||
art.className = 'field';
|
||||
art.innerHTML = '<option value="Movies">Filme</option><option value="Shows">Serien</option>';
|
||||
const los = knopf('Anlegen', 'btn btn-primary');
|
||||
|
||||
const reihe = document.createElement('div');
|
||||
reihe.className = 'knopfreihe';
|
||||
reihe.append(name, art, pfad, los);
|
||||
form.append(reihe);
|
||||
|
||||
los.onclick = async () => {
|
||||
form.querySelector('.error')?.remove();
|
||||
los.disabled = true;
|
||||
try {
|
||||
await bibliothekAnlegen(name.value.trim(), art.value, pfad.value.trim());
|
||||
name.value = ''; pfad.value = '';
|
||||
await laden();
|
||||
geaendert();
|
||||
} catch (fehler) { form.appendChild(fehlerzeile((fehler as Error).message)); }
|
||||
los.disabled = false;
|
||||
};
|
||||
return form;
|
||||
}
|
||||
|
||||
return bereich;
|
||||
}
|
||||
|
||||
@@ -184,28 +299,30 @@ function bibliotheksbereich(geaendert: () => void): HTMLElement {
|
||||
|
||||
function benutzerbereich(): HTMLElement {
|
||||
const bereich = document.createElement('section');
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = 'Benutzer';
|
||||
|
||||
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';
|
||||
|
||||
const name = feld('Benutzername');
|
||||
const passwort = feld('Passwort, mindestens 8 Zeichen', 'password');
|
||||
const admin = document.createElement('label');
|
||||
admin.className = 'hinweis';
|
||||
const haken = document.createElement('input');
|
||||
haken.type = 'checkbox';
|
||||
admin.append(haken, document.createTextNode(' Administrator'));
|
||||
const los = knopf('Anlegen', 'btn');
|
||||
|
||||
const form = document.createElement('div');
|
||||
form.className = 'einstellung';
|
||||
const reihe = document.createElement('div');
|
||||
reihe.className = 'knopfreihe';
|
||||
reihe.append(name, passwort, admin, los);
|
||||
form.append(reihe);
|
||||
|
||||
bereich.append(h, liste, form);
|
||||
bereich.appendChild(liste);
|
||||
void laden();
|
||||
|
||||
async function laden(): Promise<void> {
|
||||
@@ -226,69 +343,104 @@ function benutzerbereich(): HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
los.onclick = async () => {
|
||||
form.querySelector('.error')?.remove();
|
||||
los.disabled = true;
|
||||
try {
|
||||
await benutzerAnlegen(name.value.trim(), passwort.value, haken.checked);
|
||||
name.value = ''; passwort.value = ''; haken.checked = false;
|
||||
await laden();
|
||||
} catch (fehler) { form.appendChild(fehlerzeile((fehler as Error).message)); }
|
||||
los.disabled = false;
|
||||
};
|
||||
|
||||
return bereich;
|
||||
}
|
||||
|
||||
/* ---------- Metadaten ---------- */
|
||||
/* ---------- Server ---------- */
|
||||
|
||||
function metadatenbereich(): HTMLElement {
|
||||
function serverbereich(): HTMLElement {
|
||||
const bereich = document.createElement('section');
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = 'Metadaten';
|
||||
const block = document.createElement('div');
|
||||
block.className = 'einstellung';
|
||||
bereich.append(h, block);
|
||||
bereich.appendChild(abschnitt('Server'));
|
||||
|
||||
const liste = document.createElement('div');
|
||||
liste.className = 'einstellungen';
|
||||
bereich.appendChild(liste);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const stand = await metadatenEinstellung();
|
||||
const [auskunft, stand] = await Promise.all([systemauskunft(), metadatenEinstellung()]);
|
||||
liste.innerHTML = '';
|
||||
|
||||
const auskunft = document.createElement('div');
|
||||
auskunft.className = 'hinweis';
|
||||
auskunft.textContent = stand.usingProjectKey
|
||||
? 'Es wird der mitgelieferte TMDB-Zugang benutzt. Ein eigener löst ihn ab.'
|
||||
: stand.hasApiKey ? 'Ein eigener TMDB-Zugang ist hinterlegt.'
|
||||
: 'Kein Zugang hinterlegt — die Titel bleiben bei den Dateinamen.';
|
||||
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(); },
|
||||
})));
|
||||
|
||||
const schluessel = feld('Eigener TMDB-Zugang (Read Access Token oder API Key)', 'password');
|
||||
const sprache = feld('Sprache, etwa de-DE');
|
||||
sprache.value = stand.language;
|
||||
const los = knopf('Speichern', 'btn');
|
||||
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();
|
||||
},
|
||||
})));
|
||||
|
||||
const reihe = document.createElement('div');
|
||||
reihe.className = 'knopfreihe';
|
||||
reihe.append(schluessel, sprache, los);
|
||||
|
||||
los.onclick = async () => {
|
||||
block.querySelector('.error')?.remove();
|
||||
los.disabled = true;
|
||||
try {
|
||||
await metadatenSetzen({
|
||||
apiKey: schluessel.value.trim() || undefined,
|
||||
language: sprache.value.trim() || undefined,
|
||||
});
|
||||
schluessel.value = '';
|
||||
auskunft.textContent = 'Gespeichert. Der nächste Scan benutzt die neuen Angaben.';
|
||||
} catch (fehler) { block.appendChild(fehlerzeile((fehler as Error).message)); }
|
||||
los.disabled = false;
|
||||
};
|
||||
|
||||
block.append(auskunft, reihe);
|
||||
liste.appendChild(zeile('Fassung', auskunft.version, null));
|
||||
} catch (fehler) {
|
||||
block.appendChild(fehlerzeile((fehler as Error).message));
|
||||
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 = `<strong>${esc(name)}</strong><span class="winzig">${esc(wert)}</span>`;
|
||||
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 = '<div class="spinner"></div>';
|
||||
return bereich;
|
||||
}
|
||||
|
||||
@@ -540,7 +540,7 @@ function spieler(adresse: string, titel: string, ab: number, danach: () => void)
|
||||
// viermal je Sekunde; daraus Anfragen zu machen hiesse, den Server mit
|
||||
// Meldungen zu bewerfen, die sich um 250 ms unterscheiden.
|
||||
let zuletzt = 0;
|
||||
const melden = (erzwingen = false) => {
|
||||
const melden = (erzwingen = false, beendet = false) => {
|
||||
const jetzt = Date.now();
|
||||
if (!erzwingen && jetzt - zuletzt < MELDEABSTAND) return;
|
||||
zuletzt = jetzt;
|
||||
@@ -548,18 +548,21 @@ function spieler(adresse: string, titel: string, ab: number, danach: () => void)
|
||||
// Eine verlorene Meldung kostet ein paar Sekunden Fortschritt. Dafür
|
||||
// mitten im Film eine Fehlermeldung zu zeigen wäre die schlechtere
|
||||
// Hälfte des Tauschs.
|
||||
void fortschritt(titel, video.currentTime, laenge).catch(() => {});
|
||||
void fortschritt(titel, video.currentTime, laenge, beendet).catch(() => {});
|
||||
};
|
||||
|
||||
video.ontimeupdate = () => melden();
|
||||
video.onpause = () => melden(true);
|
||||
video.onended = () => melden(true);
|
||||
video.onended = () => melden(true, true);
|
||||
|
||||
const schliessen = knopf('Schliessen', 'btn');
|
||||
schliessen.style.cssText = 'position:absolute;top:16px;right:16px;z-index:2';
|
||||
schliessen.onclick = () => {
|
||||
video.pause();
|
||||
melden(true);
|
||||
// **Beendet melden, nicht nur den Stand.** Sonst steht der Film im
|
||||
// Dashboard noch eine Minute als „läuft", und wer gerade geschlossen
|
||||
// hat, sieht sich selbst beim Schauen zu.
|
||||
melden(true, true);
|
||||
schicht.remove();
|
||||
danach();
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { inhalt, genresVon, empfehlungen as holeEmpfehlungen, type Bibliothek, type Ordnung } from '../api';
|
||||
import { leer, fehlerzeile } from '../util';
|
||||
import { icons } from '../icons';
|
||||
import { kachel, alsKachel, reihe } from '../card';
|
||||
|
||||
type Art = 'filme' | 'serien';
|
||||
@@ -71,6 +72,8 @@ export function mediathek(
|
||||
let genre = '';
|
||||
let tippt: number | undefined;
|
||||
|
||||
let gesehen: '' | 'offen' | 'gesehen' = '';
|
||||
|
||||
const zeile = document.createElement('div');
|
||||
zeile.className = 'filterzeile';
|
||||
|
||||
@@ -78,10 +81,42 @@ export function mediathek(
|
||||
anzahl.className = 'anzahl';
|
||||
anzahl.textContent = '…';
|
||||
|
||||
const genreWahl = document.createElement('select');
|
||||
genreWahl.innerHTML = '<option value="">Alle Genres</option>';
|
||||
genreWahl.onchange = () => { genre = genreWahl.value; void laden(); };
|
||||
/** Ein Filter als Pille — ohne Rahmen, gesetzt wird er farbig. */
|
||||
function pille(
|
||||
vorgabe: string,
|
||||
werte: [string, string][],
|
||||
gewaehlt: () => string,
|
||||
setzen: (wert: string) => void,
|
||||
): { huelle: HTMLElement; wahl: HTMLSelectElement } {
|
||||
const huelle = document.createElement('div');
|
||||
huelle.className = 'pille';
|
||||
const wahl = document.createElement('select');
|
||||
[[ '', vorgabe ] as [string, string], ...werte].forEach(([w, t]) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = w; o.textContent = t;
|
||||
wahl.appendChild(o);
|
||||
});
|
||||
wahl.onchange = () => {
|
||||
setzen(wahl.value);
|
||||
huelle.classList.toggle('gesetzt', wahl.value !== '');
|
||||
aufraeumen();
|
||||
void laden();
|
||||
};
|
||||
huelle.classList.toggle('gesetzt', gewaehlt() !== '');
|
||||
huelle.appendChild(wahl);
|
||||
return { huelle, wahl };
|
||||
}
|
||||
|
||||
const genrePille = pille('Alle Genres', [], () => genre, w => { genre = w; });
|
||||
|
||||
const standPille = pille('Alle', [
|
||||
['offen', 'Noch nicht gesehen'],
|
||||
['gesehen', 'Gesehen'],
|
||||
], () => gesehen, w => { gesehen = w as typeof gesehen; });
|
||||
|
||||
// Die Sortierung ist kein Filter: sie ist immer gesetzt und bleibt grau.
|
||||
const sortierHuelle = document.createElement('div');
|
||||
sortierHuelle.className = 'pille';
|
||||
const sortierung = document.createElement('select');
|
||||
([
|
||||
['title', 'Nach Titel'],
|
||||
@@ -93,9 +128,30 @@ export function mediathek(
|
||||
sortierung.appendChild(o);
|
||||
});
|
||||
sortierung.onchange = () => { ordnung = sortierung.value as Ordnung; void laden(); };
|
||||
sortierHuelle.appendChild(sortierung);
|
||||
|
||||
// Erscheint nur, wenn es etwas zurückzusetzen gibt.
|
||||
const zuruecksetzen = document.createElement('button');
|
||||
zuruecksetzen.className = 'zuruecksetzen';
|
||||
zuruecksetzen.textContent = 'Filter zurücksetzen';
|
||||
zuruecksetzen.hidden = true;
|
||||
zuruecksetzen.onclick = () => {
|
||||
genre = ''; gesehen = '';
|
||||
genrePille.wahl.value = ''; standPille.wahl.value = '';
|
||||
genrePille.huelle.classList.remove('gesetzt');
|
||||
standPille.huelle.classList.remove('gesetzt');
|
||||
aufraeumen();
|
||||
void laden();
|
||||
};
|
||||
|
||||
function aufraeumen(): void {
|
||||
zuruecksetzen.hidden = genre === '' && gesehen === '';
|
||||
}
|
||||
|
||||
const suchhuelle = document.createElement('div');
|
||||
suchhuelle.className = 'suchhuelle';
|
||||
suchhuelle.innerHTML = icons.suche;
|
||||
const suchfeld = document.createElement('input');
|
||||
suchfeld.className = 'suche';
|
||||
suchfeld.type = 'search';
|
||||
suchfeld.placeholder = `In ${bibliothek.name} suchen`;
|
||||
suchfeld.oninput = () => {
|
||||
@@ -104,8 +160,10 @@ export function mediathek(
|
||||
window.clearTimeout(tippt);
|
||||
tippt = window.setTimeout(() => { suche = suchfeld.value.trim(); void laden(); }, 250);
|
||||
};
|
||||
suchhuelle.appendChild(suchfeld);
|
||||
|
||||
zeile.append(genreWahl, sortierung, anzahl, suchfeld);
|
||||
zeile.append(genrePille.huelle, standPille.huelle, sortierHuelle,
|
||||
anzahl, zuruecksetzen, suchhuelle);
|
||||
|
||||
const gitter = document.createElement('div');
|
||||
gitter.className = 'raster';
|
||||
@@ -117,7 +175,7 @@ export function mediathek(
|
||||
namen.forEach(n => {
|
||||
const o = document.createElement('option');
|
||||
o.value = n; o.textContent = n;
|
||||
genreWahl.appendChild(o);
|
||||
genrePille.wahl.appendChild(o);
|
||||
});
|
||||
}).catch(() => {});
|
||||
|
||||
@@ -127,16 +185,30 @@ export function mediathek(
|
||||
gitter.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const seite = await inhalt(bibliothek, { search: suche, sort: ordnung, genre, take: 200 });
|
||||
anzahl.textContent = String(seite.total);
|
||||
|
||||
// **Gesehen/offen siebt hier, nicht am Server.** Er kann es noch
|
||||
// nicht; es jetzt wegzulassen hiesse, einen Filter anzubieten, den
|
||||
// es nicht gibt. Der Preis steht in der Zahl daneben: sie zählt,
|
||||
// was übrig bleibt, nicht was der Server gefunden hat.
|
||||
const uebrig = gesehen === '' ? seite.items
|
||||
: seite.items.filter(e => {
|
||||
const ist = 'state' in e ? e.state?.watched === true
|
||||
: 'unwatchedCount' in e ? e.unwatchedCount === 0 : false;
|
||||
return gesehen === 'gesehen' ? ist : !ist;
|
||||
});
|
||||
|
||||
anzahl.textContent = gesehen === ''
|
||||
? String(seite.total)
|
||||
: `${uebrig.length} von ${seite.total}`;
|
||||
|
||||
gitter.innerHTML = '';
|
||||
if (seite.items.length === 0) {
|
||||
gitter.appendChild(leer(suche || genre
|
||||
if (uebrig.length === 0) {
|
||||
gitter.appendChild(leer(suche || genre || gesehen
|
||||
? 'Nichts gefunden.'
|
||||
: `„${bibliothek.name}" ist leer. Starte unter Verwaltung einen Scan.`));
|
||||
return;
|
||||
}
|
||||
seite.items.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen)));
|
||||
uebrig.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen)));
|
||||
} catch (fehler) {
|
||||
gitter.innerHTML = '';
|
||||
gitter.appendChild(fehlerzeile((fehler as Error).message));
|
||||
|
||||
Reference in New Issue
Block a user