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>
525 lines
22 KiB
TypeScript
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;
|
|
};
|
|
|