Files
Flaro/web/app/src/views/einstellungen/mediatheken.ts
T
ScarriffleandClaude Opus 5 327e2e9195 Einstellungen hat jeder, Verwaltung nicht
Die Seite hiess „Verwaltung" und war für alle anderen gar nicht
erreichbar — „Meine Sprache" lag dadurch hinter einer Tür, die den meisten
verschlossen ist. Jetzt heisst sie **Einstellungen**, steht jedem offen und
hat zwei beschriftete Gruppen: oben, was einem selbst gehört (Mein Konto,
Geschaut), darunter — nur für Administratoren — **Verwaltung**. Die
Überschrift ist hier die halbe Aussage: sie sagt ohne weiteren Satz, wem
der untere Teil gehört.

In der Hauptleiste steht der Eintrag jetzt unten, ohne Gruppenüberschrift.
Eine Überschrift über einem einzigen Eintrag ist eine Überschrift über
nichts, und am Fuss sucht man Einstellungen ohnehin. `#/profil` und
`#/verwaltung/…` leiten weiter; ein Lesezeichen merkt davon nichts.

**Ein kurzes Menü am Bild.** Erst meldete der Klick ab — ohne Rückfrage.
Dann führte er auf die eigene Seite, und zum Abmelden musste man erst
dorthin. Beides sind zwei Punkte; ein Menü mit zwei Punkten ist ehrlicher
als eine Tür, hinter der eine zweite liegt.

**Und das Profilbild lässt sich zuschneiden.** Ein Profilbild ist rund,
was hochgeladen wird selten quadratisch — den Ausschnitt dem Browser zu
überlassen hiesse: mittig, und das trifft bei einem Porträt den Hals. Der
Dialog zeigt den runden Ausschnitt, man zieht und vergrössert, und
hochgeladen wird genau das: ein Quadrat von 512 Pixeln. Gemessen, es kommt
auch so an.

**Dazu die Spielerleiste.** Beide Knopfgruppen durften schrumpfen, ihre
Knöpfe aber nicht — wird es eng, steht der äusserste halb hinter dem
Fensterrand. Rechts sind es nur Knöpfe, da gibt es nichts nachzugeben;
links schon. Dazu seitlich `env(safe-area-inset-*)`, damit auf einem Gerät
mit runden Ecken nichts darunter liegt. Gemessen: vorher riss es ab 420
Pixeln, jetzt bis 320 nicht mehr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-06 18:24:44 +02:00

483 lines
20 KiB
TypeScript

import {
bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, scanStarten,
scanstand, metadatenAuffrischen, vermisste, vermisstesEntfernen, type VermissteDatei,
pruefungSetzen, mediathekZuruecksetzen, type Bibliothek,
} from '../../api';
import { esc, knopf, fehlerzeile, leer, meldung } from '../../util';
import { dialog, nachfragen } from '../../dialog';
import { abschnitt } from './teile';
/** Mediatheken anlegen, einlesen, aufräumen. */
export function bibliotheksbereich(
geaendert: (bekannt?: Bibliothek[]) => 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);
/** Die gezeichneten Zeilen, je Mediathek.
*
* **Vor dem ersten `laden()`.** Stand die Map darunter, lief der erste
* Aufruf in die temporale Totzone von `const` — „Cannot access 'zeilen'
* before initialization", und der ganze Abschnitt blieb leer.
*/
const zeilen = new Map<string, Mediathekzeile>();
void laden();
/** **Auffrischen heisst fortschreiben, nicht neu zeichnen.**
*
* Hier stand `liste.innerHTML = ''` und danach ein Neuaufbau — alle drei
* Sekunden, während ein Scan läuft. Der ganze Abschnitt verschwand dabei
* sichtbar und kam wieder; wer gerade zielte, klickte ins Leere.
*
* Schlimmer noch: jeder Neuaufbau startete über `verfolgen(false)` einen
* weiteren Takt. Nach einer Minute liefen zwanzig davon nebeneinander und
* fragten im Sekundentakt denselben Scanstand ab.
*/
async function laden(): Promise<Bibliothek[] | undefined> {
if (zeilen.size === 0 && liste.childElementCount === 0) {
liste.innerHTML = '<div class="spinner"></div>';
}
let alle: Bibliothek[];
try { alle = await bibliotheken(); }
catch (fehler) {
// Beim Auffrischen bleibt stehen, was da ist: ein Aussetzer darf keine
// Liste wegräumen, die noch stimmt.
if (zeilen.size === 0) { liste.innerHTML = ''; liste.appendChild(fehlerzeile(meldung(fehler))); }
return undefined;
}
if (alle.length === 0) {
zeilen.clear();
liste.innerHTML = '';
liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.'));
return alle;
}
// Spinner oder Hinweis weg — aber nur beim allerersten Mal.
if (zeilen.size === 0) liste.innerHTML = '';
// Was es nicht mehr gibt, verschwindet.
[...zeilen.keys()]
.filter(id => !alle.some(b => b.id === id))
.forEach(id => { zeilen.get(id)!.wurzel.remove(); zeilen.delete(id); });
alle.forEach((b, i) => {
let zeile = zeilen.get(b.id);
if (zeile) zeile.aktualisieren(b);
else { zeile = block(b); zeilen.set(b.id, zeile); }
// Reihenfolge angleichen, ohne alles anzufassen: `insertBefore` mit
// einem Knoten, der schon dort steht, tut nichts.
if (liste.children[i] !== zeile.wurzel) {
liste.insertBefore(zeile.wurzel, liste.children[i] ?? null);
}
});
return alle;
}
function block(b: Bibliothek): Mediathekzeile {
// Die Knöpfe lesen den Stand über diese Grösse, nicht über `b` — sonst
// arbeiteten sie nach dem ersten Auffrischen mit veralteten Werten.
let aktuell = b;
const d = document.createElement('div');
d.className = 'einstellung';
const kopf = document.createElement('div');
kopf.className = 'zeile';
d.appendChild(kopf);
function kopfSchreiben(): void {
const serie = aktuell.kind === 'Shows';
// **Alle Zahlen nebeneinander, weil sie Verschiedenes meinen.**
//
// Plex zählte 451 Filme, Flaro 449, und der Scan lief über 461 Dateien.
// Keine davon war falsch — es sind Titel, Dateien und zweite Fassungen.
// Einzeln gezeigt bleibt die Frage offen, nebeneinander beantwortet sie
// sich.
const teile: string[] = [aktuell.path];
if (aktuell.itemCount != null) {
teile.push(`${aktuell.itemCount} ` + (aktuell.itemCount === 1
? (serie ? 'Serie' : 'Film')
: (serie ? 'Serien' : 'Filme')));
}
if (serie && aktuell.episodeCount != null) {
teile.push(`${aktuell.episodeCount} ${aktuell.episodeCount === 1 ? 'Folge' : 'Folgen'}`);
}
teile.push(`${aktuell.fileCount} ${aktuell.fileCount === 1 ? 'Datei' : 'Dateien'}`);
if (aktuell.extraVersionCount > 0) {
teile.push(`${aktuell.extraVersionCount} zweite Fassung`
+ (aktuell.extraVersionCount === 1 ? '' : 'en'));
}
if (aktuell.unassignedCount > 0) teile.push(`${aktuell.unassignedCount} nicht zugeordnet`);
if (aktuell.pendingCount > 0) {
teile.push(`${aktuell.pendingCount} wartet${aktuell.pendingCount === 1 ? '' : 'en'} auf Freigabe`);
}
kopf.innerHTML = `<strong>${esc(aktuell.name)}</strong>`
+ `<span class="winzig">${esc(teile.join(' · '))}</span>`;
}
kopfSchreiben();
const stand = document.createElement('div');
stand.className = 'hinweis';
const los = knopf('Scannen', 'btn');
los.onclick = async () => {
laufendSetzen(true);
try { await scanStarten(aktuell.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 „${aktuell.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(aktuell.id);
void verfolgen(true);
},
});
const umbenennen = knopf('Umbenennen', 'btn');
umbenennen.onclick = () => dialog({
titel: 'Mediathek umbenennen',
felder: [{ name: 'name', beschriftung: 'Name', wert: aktuell.name }],
bestaetigen: 'Speichern',
tun: async wert => {
await bibliothekUmbenennen(aktuell.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: `„${aktuell.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(aktuell.id);
await laden();
geaendert();
},
});
const entfernen = knopf('Entfernen', 'btn');
entfernen.onclick = () => nachfragen({
titel: `„${aktuell.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(aktuell.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<void> {
for (let i = 0; i < 900; i++) {
if (signal.aborted) return;
const s = await scanstand(aktuell.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) geaendert(await laden());
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(aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus', 'btn');
pruefung.onclick = async () => {
pruefung.disabled = true;
try {
await pruefungSetzen(aktuell.id, !aktuell.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.
//
// Angelegt wird er trotzdem immer und nur versteckt: ihn beim Auffrischen
// anzulegen und wieder wegzuwerfen wäre genau das Flackern, das hier
// gerade abgestellt wird.
const freigeben = knopf('', 'btn btn-primary');
// **Zeigen statt blind freigeben.**
//
// Der Knopf veröffentlichte bisher auf einen Klick alles, was wartete —
// ohne dass man gesehen hätte, was. Auf die Frage „wo sehe ich die vier
// Filme?" gab es damit keine Antwort: die Liste stand weiter oben, aber
// niemand wusste das, und sie war obendrein nicht nachgeladen worden.
//
// Jetzt führt er dorthin. Veröffentlicht wird dort, wo die Titel mit
// Plakat und Dateiname stehen und sich einzeln anhaken lassen.
//
// **Ein Bereichswechsel, kein Sprung in der Seite.** Solange alles in
// einer Spalte stand, genügte `scrollIntoView`; seit „Zu prüfen" ein
// eigener Bereich ist, steht die Liste gar nicht mehr im selben Dokument.
freigeben.onclick = () => { location.hash = '#/verwaltung/pruefung'; };
knoepfe.appendChild(freigeben);
function freigabeSchreiben(): void {
// **`hidden` reicht hier nicht.** Das Attribut wirkt über
// `display: none` mit der Spezifität von Null — `.btn` setzt
// `display: inline-flex` und gewinnt. Der Knopf stand deshalb als
// „0 Titel freigeben" da, obwohl nichts wartet. `.hidden` trägt ein
// `!important` und setzt sich durch.
freigeben.classList.toggle('hidden', aktuell.pendingCount === 0);
freigeben.textContent = aktuell.pendingCount === 1
? 'Ein Titel wartet — ansehen'
: `${aktuell.pendingCount} Titel warten — ansehen`;
}
freigabeSchreiben();
d.append(knoepfe, stand);
// **Genau ein Takt je Mediathek.** Er entsteht hier, beim Anlegen der
// Zeile — und weil die Zeile nicht mehr neu gebaut wird, bleibt es dabei.
void verfolgen(false);
return {
wurzel: d,
aktualisieren: (neu: Bibliothek) => {
aktuell = neu;
kopfSchreiben();
pruefung.textContent = aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus';
freigabeSchreiben();
},
};
}
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. */
export 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<void> {
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 = `<strong>${esc(v.title ?? '(ohne Titel)')}`
+ `${v.versionLabel ? ` · ${esc(v.versionLabel)}` : ''}</strong>`
+ `<span class="winzig">${esc(v.libraryName)} · vermisst seit ${esc(seit)}</span>`;
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 ---------- */
/** Eine gezeichnete Mediathekszeile und der Weg, sie fortzuschreiben. */
type Mediathekzeile = {
wurzel: HTMLElement;
aktualisieren: (b: Bibliothek) => void;
};