Neun gemeldete Fehler, und die Frage, welcher Build gerade läuft

Ein grosser Commit, weil die Punkte einander bedingen: ohne lesbare
Fehlermeldung war die Serienerkennung nicht zu diagnostizieren, und ohne
Build-Kennung nicht einmal festzustellen, ob überhaupt der gebaute Stand
läuft. Zwei der neun Meldungen betrafen Funktionen, die seit Stunden im
Quelltext stehen.

Version und Build. Die Version steht fest; daneben eine Kennung aus
sechs Zeichen, die sich bei jedem Docker-Bau ändert. Sie steht in der
Verwaltung, unter /health und im Protokoll bei jedem Start. Damit ist
"reden wir vom selben Build?" in zwei Sekunden beantwortet statt in
einem halben Tag.

Serien. Lag eine Datei direkt im Serienordner und trug vor der
Folgennummer nichts, wurde der ganze Dateiname zum Serientitel: aus 95
Serien wurden 737, jede mit einer Folge, bei jedem Lauf neu. Dazu lief
der Scan in Verweise hinein — derselbe Ordner unter zwei Namen ergab
zwei Serien. Und weder neue noch umgezogene Dateien standen in der
Pfadliste des Laufs, weshalb ein doppelt gesehener Pfad eine zweite
Zeile mit gleichem (LibraryId, Path) anlegte und der
Eindeutigkeitsindex den ganzen Speichervorgang scheitern liess. Jede
Serienanlage wird jetzt im Tagesprotokoll begründet.

Fehlermeldungen. "An error occurred while saving the entity changes"
stand englisch in einer deutschen Oberfläche und nannte weder Ursache
noch nächsten Schritt. Jetzt geht der vollständige Text ins Protokoll
und ein deutscher Satz ohne Fehlercode in die Anzeige — auch für
"Failed to fetch", das aus dem Browser kam und nie übersetzt wurde.

Protokolle. Die Ansicht holte die ganze Datei und zeigte 800 Zeilen
davon; bei zwei Megabyte brach die Verbindung ab. Gelesen wird jetzt
serverseitig rückwärts, mit einem Knopf für mehr.

Auswahlfelder. Die drei <select> zeichnete das Betriebssystem: ein
helles Systemmenü mitten in der dunklen Oberfläche. Ersetzt durch ein
eigenes Listenfeld mit Tastaturbedienung.

Bildwahl. Drei Raster untereinander in einem scrollenden Dialog hiess
zwei Scrollebenen übereinander. Jetzt ein Reiter je Bildtyp.

Sprache. Die Serversprache stand in der Serverliste zwischen Name,
Metadaten und Fassung — gesucht und nicht gefunden. Sie hat jetzt einen
eigenen Abschnitt ganz oben.

Dazu: ein Knopf, der den erfassten Inhalt einer Mediathek verwirft, ohne
Name und Pfad mitzunehmen; ein Abschnitt Speicher mit den drei Pfaden,
ihrer Belegung und dem Leeren des Bildspeichers; und die Korrektur einer
Zuordnung frischt nur noch die Kachel auf statt die ganze Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 15:55:56 +02:00
co-authored by Claude Opus 5
parent ab8a66d898
commit ac04901ccc
34 changed files with 1583 additions and 255 deletions
+304 -102
View File
@@ -4,14 +4,15 @@ import {
vermisste, vermisstesEntfernen, type VermissteDatei,
protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen,
meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen,
mediathekFreigeben, reihenfolgeSetzen,
mediathekFreigeben, reihenfolgeSetzen, mediathekZuruecksetzen,
type Pruefeintrag,
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
servernameSetzen, systemauskunft,
laufendeWiedergaben, serverzustand, bildAdresse,
type Bibliothek, type Wiedergabe,
speicherorte, bildspeicherLeeren,
type Bibliothek, type Wiedergabe, ApiFehler,
} from '../api';
import { esc, dauer, knopf, fehlerzeile, leer } from '../util';
import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../util';
import { dialog, nachfragen } from '../dialog';
import { icon, icons } from '../icons';
@@ -29,9 +30,9 @@ export function verwaltung(
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());
wurzel.append(dashboard(signal), sprachbereich(), pruefbereich(geaendert),
vermisstenbereich(geaendert), bibliotheksbereich(geaendert, signal), benutzerbereich(),
serverbereich(geaendert), speicherbereich(), protokollbereich());
return wurzel;
}
@@ -99,7 +100,7 @@ function dashboard(signal: AbortSignal): HTMLElement {
zustand.textContent = teile.join(' · ');
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile((fehler as Error).message));
liste.appendChild(fehlerzeile(meldung(fehler)));
return; // Nicht weiter nachfragen, wenn es scheitert.
}
// **Nur weitermachen, solange die Ansicht hängt.** Ohne diese Zeile
@@ -238,7 +239,7 @@ function pruefbereich(geaendert: () => void): HTMLElement {
await laden();
geaendert();
} catch (fehler) {
liste.prepend(fehlerzeile((fehler as Error).message));
liste.prepend(fehlerzeile(meldung(fehler)));
} finally {
freigeben.disabled = false;
planen.disabled = false;
@@ -316,7 +317,7 @@ function pruefbereich(geaendert: () => void): HTMLElement {
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; }
catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); nur.disabled = false; }
};
const oben = document.createElement('div');
@@ -328,7 +329,7 @@ function pruefbereich(geaendert: () => void): HTMLElement {
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; }
catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); zurueck.disabled = false; }
};
oben.appendChild(zurueck);
}
@@ -375,6 +376,11 @@ function protokollbereich(): HTMLElement {
anzeige.className = 'protokoll hidden';
bereich.appendChild(anzeige);
// **Blättern statt alles auf einmal.** Die ersten 800 Zeilen beantworten
// fast jede Frage; wer weiter zurück muss, verdoppelt mit diesem Knopf.
const mehr = knopf('Mehr laden', 'btn hidden');
bereich.appendChild(mehr);
void (async () => {
let tage;
try { tage = await protokolltage(); } catch { bereich.classList.add('hidden'); return; }
@@ -416,25 +422,44 @@ function protokollbereich(): HTMLElement {
});
})();
async function zeigen(tag: string, nurFehler: boolean): Promise<void> {
/** Wie viele Zeilen zuletzt geholt wurden — der Knopf „Mehr" verdoppelt. */
let gezeigt = 0;
async function zeigen(tag: string, nurFehler: boolean, zeilen = 0): Promise<void> {
// **Nur das Ende holen, und davon anfangs wenig.** Vorher kam die ganze
// Datei, und davon wurden 95 % weggeworfen — bei zwei Megabyte über eine
// wackelige Verbindung endete das in „Failed to fetch". Bei der
// Fehlersuche mehr, weil davon die meisten weggefiltert werden.
gezeigt = zeilen > 0 ? zeilen : (nurFehler ? 5000 : 800);
anzeige.classList.remove('hidden');
anzeige.textContent = 'Wird geladen…';
mehr.classList.add('hidden');
try {
const antwort = await fetch(protokollAdresse(tag));
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 antwort = await fetch(protokollAdresse(tag, gezeigt));
if (!antwort.ok) throw new ApiFehler(antwort.status, `${antwort.status}`);
const roh = (await antwort.text()).split('\n');
const gefiltert = nurFehler
? 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');
? roh.filter(z => z.includes('FEHLER') || z.includes('WARNUNG'))
: roh;
anzeige.textContent = gefiltert.join('\n').trim().length === 0
? (nurFehler ? 'Keine Fehler in den letzten Zeilen.' : 'Leer.')
: gefiltert.join('\n');
anzeige.scrollTop = anzeige.scrollHeight;
// **„Mehr" nur, solange es mehr geben kann.** Kamen weniger Zeilen
// zurück als verlangt, ist die Datei zu Ende — ein Knopf, der dann
// nichts tut, ist schlechter als keiner.
if (roh.length >= gezeigt && gezeigt < 20000) {
mehr.classList.remove('hidden');
mehr.textContent = `Weitere ${Math.min(gezeigt, 20000 - gezeigt)} Zeilen laden`;
mehr.onclick = () => void zeigen(tag, nurFehler, Math.min(gezeigt * 2, 20000));
}
} catch (fehler) {
anzeige.textContent = `Konnte nicht gelesen werden: ${(fehler as Error).message}`;
anzeige.textContent = `Konnte nicht gelesen werden: ${meldung(fehler)}`;
}
}
@@ -500,7 +525,7 @@ function vermisstenbereich(geaendert: () => void): HTMLElement {
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; }
catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); weg.disabled = false; }
};
oben.appendChild(weg);
d.appendChild(oben);
@@ -592,7 +617,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
alle.forEach((b, i) => liste.appendChild(block(b, i)));
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile((fehler as Error).message));
liste.appendChild(fehlerzeile(meldung(fehler)));
}
}
@@ -626,7 +651,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
los.onclick = async () => {
los.disabled = true;
try { await scanStarten(b.id); await verfolgen(true); }
catch (fehler) { stand.textContent = (fehler as Error).message; }
catch (fehler) { stand.textContent = meldung(fehler); }
los.disabled = false;
};
@@ -654,6 +679,25 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
},
});
// **Zurücksetzen steht neben Entfernen, weil es fast dasselbe tut** — nur
// ohne Namen, Pfad und Einstellungen mitzunehmen. Wer eine Mediathek
// löscht und neu anlegt, um sie sauber zu bekommen, tippt alles noch
// einmal; genau das war bisher der einzige Weg.
const zuruecksetzen = knopf('Zurücksetzen', 'btn');
zuruecksetzen.onclick = () => nachfragen({
titel: `„${b.name}" zurücksetzen?`,
text: 'Alle erfassten Titel, Zuordnungen und der Sehfortschritt dieser '
+ 'Mediathek werden verworfen; Name, Pfad und Einstellungen bleiben. '
+ 'Die Mediendateien auf der Platte bleiben unangetastet. Danach einmal '
+ 'scannen, und der Bestand entsteht neu.',
bestaetigen: 'Zurücksetzen',
tun: async () => {
await mediathekZuruecksetzen(b.id);
await laden();
geaendert();
},
});
const entfernen = knopf('Entfernen', 'btn');
entfernen.onclick = () => nachfragen({
titel: `„${b.name}" entfernen?`,
@@ -687,7 +731,9 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
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`);
// „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);
@@ -709,7 +755,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
await pruefungSetzen(b.id, !b.reviewNewItems);
await laden();
} catch (fehler) {
d.appendChild(fehlerzeile((fehler as Error).message));
d.appendChild(fehlerzeile(meldung(fehler)));
pruefung.disabled = false;
}
};
@@ -736,7 +782,8 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
const knoepfe = document.createElement('div');
knoepfe.className = 'knopfreihe';
knoepfe.append(hoch, runter, los, auffrischen, pruefung, umbenennen, entfernen);
knoepfe.append(
hoch, runter, 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
@@ -753,7 +800,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
await laden();
geaendert();
} catch (fehler) {
d.appendChild(fehlerzeile((fehler as Error).message));
d.appendChild(fehlerzeile(meldung(fehler)));
freigeben.disabled = false;
}
};
@@ -814,7 +861,7 @@ function benutzerbereich(): HTMLElement {
});
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile((fehler as Error).message));
liste.appendChild(fehlerzeile(meldung(fehler)));
}
}
@@ -823,9 +870,21 @@ function benutzerbereich(): HTMLElement {
/* ---------- Server ---------- */
function serverbereich(geaendert: () => void): HTMLElement {
/** Sprache — eigener Abschnitt, ganz oben.
*
* **Warum nicht in der Serverliste.** Dort stand sie, zwischen „Name",
* „Metadaten", „Doppelte Einträge" und „Fassung". Gesucht wurde sie
* trotzdem, gefunden nicht — und das ist die Antwort: eine Einstellung, die
* man sucht und nicht findet, steht am falschen Platz, egal wie gut sie
* beschriftet ist.
*
* **Oben, weil sie alles andere färbt.** In dieser Sprache werden Metadaten
* geholt und gespeichert; wer sie spät umstellt, frischt den ganzen Bestand
* noch einmal auf.
*/
function sprachbereich(): HTMLElement {
const bereich = document.createElement('section');
bereich.appendChild(abschnitt('Server'));
bereich.appendChild(abschnitt('Sprache'));
const liste = document.createElement('div');
liste.className = 'einstellungen';
@@ -833,74 +892,9 @@ function serverbereich(geaendert: () => void): HTMLElement {
void (async () => {
try {
const [auskunft, stand] = await Promise.all([systemauskunft(), metadatenEinstellung()]);
const stand = await 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,
@@ -973,11 +967,219 @@ function serverbereich(geaendert: () => void): HTMLElement {
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));
liste.appendChild(fehlerzeile(meldung(fehler)));
}
})();
return bereich;
function zeile(
name: string, wert: string, hinweis: string | null, bearbeiten?: () => void,
): HTMLElement {
const d = document.createElement('div');
d.className = 'einstellung';
const oben = document.createElement('div');
oben.className = 'knopfreihe';
const text = document.createElement('div');
text.className = 'zeile';
text.style.flex = '1';
text.innerHTML = `<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;
}
}
/** Speicher — wo die Dateien des Servers liegen.
*
* **Warum das in die Oberfläche gehört.** Die drei Pfade standen nur in der
* Compose-Datei. Wer wissen wollte, wohin die Bilder wandern oder warum das
* `cache`-Volume vollläuft, musste sich auf das NAS verbinden — für eine
* Auskunft, die der Server längst hat.
*
* **Die Pfade bleiben Umgebungsvariablen.** Sie im Betrieb umzubiegen, während
* Dateien darin liegen, hiesse: die Datenbank zeigt auf den alten Ort, der
* Server schreibt in den neuen, und niemand merkt es, bis ein Bild fehlt.
* Angezeigt wird deshalb der Variablenname — damit klar ist, wo man schraubt.
*/
function speicherbereich(): HTMLElement {
const bereich = document.createElement('section');
bereich.appendChild(abschnitt('Speicher'));
const liste = document.createElement('div');
liste.className = 'einstellungen';
liste.innerHTML = '<div class="spinner"></div>';
bereich.appendChild(liste);
void laden();
async function laden(): Promise<void> {
let orte;
try { orte = await speicherorte(); }
catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile(meldung(fehler)));
return;
}
liste.innerHTML = '';
orte.forEach(o => {
const d = document.createElement('div');
d.className = 'einstellung';
const oben = document.createElement('div');
oben.className = 'knopfreihe';
const text = document.createElement('div');
text.className = 'zeile';
text.style.flex = '1';
const frei = o.frei === null ? '' : ` · ${groesse(o.frei)} frei`;
text.innerHTML = `<strong>${esc(o.name)}</strong>`
+ `<span class="winzig">${esc(o.pfad)} — ${groesse(o.belegt)} belegt${esc(frei)}</span>`
+ `<span class="winzig">Wird gesetzt über ${esc(o.variable)}.</span>`;
oben.appendChild(text);
// Leeren gibt es nur dort, wo es gefahrlos ist.
if (o.bilder !== null) {
const leeren = knopf('Bildspeicher leeren', 'btn');
leeren.onclick = async () => {
leeren.disabled = true;
try {
const { freed } = await bildspeicherLeeren();
await laden();
// Erst nach dem Neuladen melden, sonst steht die alte Zahl daneben.
const gemeldet = document.createElement('p');
gemeldet.className = 'hinweis';
gemeldet.textContent = `${groesse(freed)} freigegeben. `
+ 'Die Bilder werden beim nächsten Aufruf neu geholt.';
liste.prepend(gemeldet);
} catch (fehler) {
d.appendChild(fehlerzeile(meldung(fehler)));
leeren.disabled = false;
}
};
oben.appendChild(leeren);
}
d.appendChild(oben);
liste.appendChild(d);
});
}
return bereich;
}
function serverbereich(geaendert: () => void): HTMLElement {
const bereich = document.createElement('section');
bereich.appendChild(abschnitt('Server'));
const liste = document.createElement('div');
liste.className = 'einstellungen';
bereich.appendChild(liste);
void (async () => {
try {
const [auskunft, stand] = await Promise.all([systemauskunft(), metadatenEinstellung()]);
liste.innerHTML = '';
liste.appendChild(zeile(
'Name', auskunft.serverName ?? '—',
'So nennen ihn die Apps und die Kopfzeile.',
() => dialog({
titel: 'Server umbenennen',
felder: [{ name: 'name', beschriftung: 'Name', wert: auskunft.serverName ?? '' }],
bestaetigen: 'Speichern',
tun: async wert => { await servernameSetzen(wert('name')); location.reload(); },
})));
// **Wartung, nicht Alltag.** Nach jedem Scan passiert das von selbst;
// dieser Knopf ist für den Fall, dass doch wieder Dubletten auftauchen —
// etwa nachdem eine Zuordnung von Hand geändert wurde.
const wartung = zeile(
'Doppelte Einträge', 'Zusammenlegen, was denselben Film meint',
'Zwei Dateien mit leicht verschiedenen Namen konnten früher zwei '
+ 'Einträge ergeben. Der Sehfortschritt bleibt dabei erhalten — es '
+ 'gewinnt die weitere Stelle.');
const legen = knopf('Zusammenlegen', 'btn');
legen.onclick = async () => {
legen.disabled = true;
const vorher = legen.textContent;
legen.textContent = 'Läuft…';
try {
const bericht = await doppelteZusammenlegen();
legen.textContent = bericht.gesamt === 0
? 'Nichts zu tun'
: `${bericht.gesamt} zusammengelegt`;
if (bericht.gesamt > 0) geaendert();
} catch (fehler) {
wartung.appendChild(fehlerzeile(meldung(fehler)));
legen.textContent = vorher;
legen.disabled = false;
}
};
wartung.querySelector('.knopfreihe')?.appendChild(legen);
liste.appendChild(wartung);
liste.appendChild(zeile(
'Metadaten',
// Ohne die Sprache: die steht eine Zeile tiefer und gehört dort hin.
stand.usingProjectKey ? 'Mitgelieferter TMDB-Zugang'
: stand.hasApiKey ? 'Eigener TMDB-Zugang'
: 'Kein Zugang hinterlegt',
stand.usingProjectKey
? 'Ein eigener Zugang löst den mitgelieferten ab.'
: 'Nach einer Sprachänderung die Metadaten auffrischen — '
+ 'vorhandene Titel ändern sich sonst nicht.',
() => dialog({
titel: 'Metadaten',
felder: [
{
name: 'schluessel', beschriftung: 'Eigener TMDB-Zugang', art: 'password',
platzhalter: 'Read Access Token oder API Key',
hinweis: 'Leer lassen, um den mitgelieferten zu behalten.',
},
],
bestaetigen: 'Speichern',
tun: async wert => {
await metadatenSetzen({ apiKey: wert('schluessel') || undefined });
location.reload();
},
})));
// **Fassung und Build nebeneinander.**
//
// Die Fassung sagt, welcher Stand gemeint ist; der Build sagt, welches
// Abbild wirklich läuft. Ohne den zweiten Wert war nicht feststellbar,
// ob das NAS den Bau von eben zieht — und eine Fehlersuche, die diese
// Frage nicht beantworten kann, dreht sich im Kreis.
liste.appendChild(zeile(
'Fassung',
`${auskunft.version} · Build ${auskunft.build}`,
auskunft.build === 'DEV'
? 'Läuft aus dem Quellbaum, nicht aus einem Abbild.'
: 'Diese Kennung druckt das Veröffentlichungsskript am Ende. '
+ 'Stimmt sie nicht überein, läuft hier ein älteres Abbild.'));
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile(meldung(fehler)));
}
})();