Der Scan verglich den Titel aus dem Dateinamen zeichengenau. Ein Punkt Unterschied — Dr. Dolittle 2 gegen Dr Dolittle 2 —, oder eine Datei mit Jahresangabe neben einer ohne, und es entstanden zwei Einträge. Die Metadaten ordneten beide demselben TMDB-Eintrag zu, beide bekamen denselben Titel, dasselbe Jahr, dasselbe Plakat, und nichts führte sie je wieder zusammen. Drei Ebenen, weil eine nicht trägt. Titelschluessel vergleicht ohne Satzzeichen, ohne Akzente und ohne Grossschreibung; ein fehlendes Jahr passt auf jedes, zwei bekannte verschiedene trennen. Bewusst nicht aggressiver: Spider-Man und Spiderman bleiben getrennt, und die beiden Dune von 1984 und 2021 sowieso. Ein zusammengelegter Film lässt sich nicht mehr trennen, ohne zu wissen, welche Datei wohin gehörte. Ein gefilterter Eindeutigkeitsindex auf (LibraryId, TmdbId) stellt die Regel in die Datenbank statt in Code, der sie einhalten soll. Gefiltert, weil ohne Metadaten alle Einträge null tragen. Zusammenlegung räumt auf, was kein Vergleich erkennen könnte: Ich einfach unverbesserlich und Despicable Me sind derselbe Film, und das verrät allein die TMDB-Kennung. Läuft nach jedem Scan, und von Hand über einen Knopf in der Verwaltung. Der Sehfortschritt wird gefaltet, nicht umgehängt — die weitere Stelle gewinnt, gesehen gewinnt, weggelegt gewinnt nicht. Die Migration räumt zuerst auf und legt dann erst den Index an. Andersherum scheiterte sie auf jeder Datenbank mit Dubletten, und zwar beim Start im Container, wo die Meldung niemand sieht. Gemessen an einer Datenbank mit zwei Dubletten und zwei Sehständen: 3 Filme auf 2, beide Dateien erhalten, Position 1800 statt 300 behalten. Dazu Fassungen. MediaFile.VersionLabel liest Mobil, 4K oder Director's Cut aus dem Namen, sonst aus der gemessenen Bildhöhe. Die Liste ist geschlossen, damit eine Fassung nicht YIFY heisst. Und der Fassungsname beendet jetzt den Titel wie jedes andere Beiwerk — vorher wurde aus "The Matrix (1999) - Mobil.mp4" der Film "The Matrix - Mobil", also ein zweiter Eintrag neben dem Original. Genau die Dublette, um die es ging. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
475 lines
17 KiB
TypeScript
475 lines
17 KiB
TypeScript
import {
|
|
bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen,
|
|
scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen,
|
|
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
|
|
servernameSetzen, systemauskunft,
|
|
laufendeWiedergaben, serverzustand, bildAdresse,
|
|
type Bibliothek, type Wiedergabe,
|
|
} from '../api';
|
|
import { esc, dauer, knopf, fehlerzeile, leer } from '../util';
|
|
import { dialog, nachfragen } from '../dialog';
|
|
import { icon } from '../icons';
|
|
|
|
/** 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(dashboard(), bibliotheksbereich(geaendert), benutzerbereich(), serverbereich(geaendert));
|
|
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 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.',
|
|
},
|
|
],
|
|
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.appendChild(liste);
|
|
void laden();
|
|
|
|
async function laden(): Promise<void> {
|
|
liste.innerHTML = '<div class="spinner"></div>';
|
|
try {
|
|
const alle = await bibliotheken();
|
|
liste.innerHTML = '';
|
|
if (alle.length === 0) {
|
|
liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.'));
|
|
return;
|
|
}
|
|
alle.forEach(b => liste.appendChild(block(b)));
|
|
} catch (fehler) {
|
|
liste.innerHTML = '';
|
|
liste.appendChild(fehlerzeile((fehler as Error).message));
|
|
}
|
|
}
|
|
|
|
function block(b: Bibliothek): HTMLElement {
|
|
const d = document.createElement('div');
|
|
d.className = 'einstellung';
|
|
|
|
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');
|
|
los.onclick = async () => {
|
|
los.disabled = true;
|
|
try { await scanStarten(b.id); await verfolgen(); }
|
|
catch (fehler) { stand.textContent = (fehler as Error).message; }
|
|
los.disabled = false;
|
|
};
|
|
|
|
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);
|
|
if (s.state === 'running') stand.textContent = 'Wird durchsucht…';
|
|
else if (s.state === 'enriching') stand.textContent = 'Metadaten werden geholt…';
|
|
else if (s.state === 'failed') { stand.textContent = s.error ?? 'Gescheitert.'; return; }
|
|
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`);
|
|
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);
|
|
stand.textContent = teile.join(' · ');
|
|
await laden();
|
|
geaendert();
|
|
return;
|
|
}
|
|
await new Promise(r => setTimeout(r, 1000));
|
|
}
|
|
}
|
|
|
|
const knoepfe = document.createElement('div');
|
|
knoepfe.className = 'knopfreihe';
|
|
knoepfe.append(los, auffrischen, umbenennen, entfernen);
|
|
d.append(knoepfe, stand);
|
|
void verfolgen();
|
|
return d;
|
|
}
|
|
|
|
return bereich;
|
|
}
|
|
|
|
/* ---------- Benutzer ---------- */
|
|
|
|
function benutzerbereich(): HTMLElement {
|
|
const bereich = document.createElement('section');
|
|
|
|
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';
|
|
bereich.appendChild(liste);
|
|
void laden();
|
|
|
|
async function laden(): Promise<void> {
|
|
liste.innerHTML = '<div class="spinner"></div>';
|
|
try {
|
|
const alle = await benutzer();
|
|
liste.innerHTML = '';
|
|
alle.forEach(b => {
|
|
const d = document.createElement('div');
|
|
d.className = 'einstellung';
|
|
d.innerHTML = `<div class="zeile"><strong>${esc(b.userName)}</strong>`
|
|
+ `<span class="winzig">${b.isAdministrator ? 'Administrator' : 'Benutzer'}</span></div>`;
|
|
liste.appendChild(d);
|
|
});
|
|
} catch (fehler) {
|
|
liste.innerHTML = '';
|
|
liste.appendChild(fehlerzeile((fehler as Error).message));
|
|
}
|
|
}
|
|
|
|
return bereich;
|
|
}
|
|
|
|
/* ---------- Server ---------- */
|
|
|
|
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((fehler as Error).message));
|
|
legen.textContent = vorher;
|
|
legen.disabled = false;
|
|
}
|
|
};
|
|
wartung.querySelector('.knopfreihe')?.appendChild(legen);
|
|
liste.appendChild(wartung);
|
|
|
|
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();
|
|
},
|
|
})));
|
|
|
|
liste.appendChild(zeile('Fassung', auskunft.version, null));
|
|
} catch (fehler) {
|
|
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;
|
|
}
|