Files
Flaro/web/app/src/views/einstellungen/mediatheken.ts
T
ScarriffleandClaude Opus 5 767491ef03 Serie für Serie, Film für Film — statt einer Zeile mit einer GUID
Die Markensuche lief stundenlang über 95 Serien, 4213 Folgen und 453
Filme und schrieb dabei fünf Zeilen, in denen Kennungen standen statt
Namen. „Markenerkennung für 4f3a…: 12 Marken." beantwortet keine der
Fragen, die in den letzten Tagen tatsächlich aufkamen — welche Staffel
nichts bekommen hat, warum, und ob die Serie überhaupt dran war.

Jetzt nennt jede Zeile den Titel, und es gibt eine je Serie, je Staffel
und je Folge:

    „Biohackers": 2 Staffel(n), 18 Folge(n).
    „Biohackers", Staffel 2: Vorspann in 8 von 8 Folge(n), Abspann in 1 von 8.
    Biohackers S02E01 · Neuanfang: Vorspann 0:31–1:24, Abspann ab 41:12 (über das Bild).
    Biohackers S02E07 · Letzte Fahrt: Vorspann 0:31–1:24, kein Abspann.
    „Biohackers" fertig: 14 Marke(n).

Die Folgenzeilen werden **aus der Datenbank gelesen, nicht mitgezählt**:
so steht dort, was danach wirklich an der Folge hängt, und nicht, was
dieser eine Lauf geschrieben hat. Nur das beantwortet „warum kommt bei
dieser Folge kein Knopf?".

**Drei Gründe statt eines `false`.** `FilmAsync` gab für „keine Datei",
„schon durchsucht" und „nichts gefunden" dasselbe zurück, und von aussen
waren sie nicht zu unterscheiden. Jetzt sagt jeder seinen Namen.

**Und eine Live-Zeile bei der Mediathek**, wie der Scan sie hat. Sie
benutzt denselben Mechanismus — `Scanschritt` mit einer neuen Phase,
derselbe Endpunkt, ein eigenes Feld. Ein eigenes, weil der Scan sich auf
„fertig" setzt, sobald er die Markensuche angestossen hat; in sein Feld
geschrieben sähe er wieder laufend aus.

**Zwei Fehler sind dabei herausgefallen.** In `NachScanAsync` stand
`if (serien.Count == 0) return;` vor dem Filmteil — eine reine
Filmmediathek bekam nach einem Scan also nie eine Abspannsuche. Und die
fpcalc-Prüfung stand ganz oben und galt für beides, obwohl der Bildweg
fpcalc gar nicht braucht: ohne fpcalc fand der Server keinen einzigen
Filmabspann.

Dazu ein Suchfeld im Protokoll und tausend Anfangszeilen statt
dreihundert. Ohne das wäre eine Zeile je Folge keine Auskunft, sondern
eine Mauer — genau der Fall, gegen den sich `ScanCoordinator` für den
Scan schon einmal entschieden hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 13:11:58 +02:00

525 lines
22 KiB
TypeScript

import {
bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, scanStarten,
mediathekMarken,
scanstand, metadatenAuffrischen, vermisste, vermisstesEntfernen, type VermissteDatei,
pruefungSetzen, mediathekZuruecksetzen, type Bibliothek,
} from '../../api';
import { esc, knopf, fehlerzeile, leer, meldung, hinweisZeigen} 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' },
{ wert: 'Videos', text: 'Videos (ohne Abgleich)' },
],
// **Was „ohne Abgleich" heisst, bevor man es auswählt.** Hinterher
// wäre es eine Überraschung: keine Plakate, keine Beschreibungen,
// und der Dateiname steht als Titel da.
hinweis: 'Videos: der Dateiname ist der Titel, es wird nichts bei '
+ 'TMDB nachgeschlagen und nichts zusammengelegt. Der Unterordner '
+ 'wird zur Kategorie. Für Urlaubsaufnahmen, Mitschnitte, Vorträge.',
},
{
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);
},
});
// **Je Serie einen Knopf zu drücken ist bei fünfundneunzig kein
// Angebot.** Den gibt es auf der Serienseite; hier gilt er für alles,
// was in dieser Mediathek liegt — Serien über den Tonabgleich, Filme
// über das Bild.
const marken = knopf('Vorspann und Abspann suchen', 'btn');
marken.title = 'Sucht überspringbare Abschnitte in allen Titeln dieser Mediathek.';
marken.onclick = () => nachfragen({
titel: `Vorspann und Abspann in „${aktuell.name}" suchen?`,
text: 'Der Server liest dafür von jeder Folge einmal die Tonspur und sieht '
+ 'sich bei Filmen die letzten zwanzig Minuten an. Über einen grossen '
+ 'Bestand dauert das Stunden — es läuft im Hintergrund weiter, und im '
+ 'Protokoll von heute steht, wie weit es ist. Schon durchsuchte Titel '
+ 'werden noch einmal geprüft.',
bestaetigen: 'Suchen',
tun: async () => {
laufendSetzen(true);
await mediathekMarken(aktuell.id, true);
hinweisZeigen('Die Suche läuft. Der Stand steht hier und im Protokoll von heute.');
void verfolgen(false);
},
});
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;
} else if (s.marken) {
// **Die Markensuche hängt sich hinten an und läuft weiter.**
// Sie dauert Stunden, wo der Scan Minuten dauert; ohne diesen
// Zweig stünde hier längst das Ergebnis des Scans, während der
// Server noch jede Folge durchhört.
laufendSetzen(true);
stand.textContent = s.marken;
}
// **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());
// Ein fertiger Scan ist nicht das Ende, solange die Markensuche
// dahinter noch läuft — sonst fiele die Zeile mitten im Lauf weg.
if (s.state === 'finished' && !s.marken) {
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, marken, 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;
};