Files
Flaro/web/app/src/views/einstellungen/zuordnung.ts
T
ScarriffleandClaude Opus 5 1b4784f1db Zwei Fassungen dürfen gewollt sein, und die Startseite darf nicht warten
**Mehrfachfassungen lassen sich abhaken.** Zwei Dateien an einem Film sind
meist zwei Fassungen und völlig in Ordnung. In der Liste standen sie
trotzdem für immer: sie rechnet jedes Mal frisch, und es gab keinen Zustand.
Wer absichtlich zwei Fassungen hält, konnte die Gruppe nicht loswerden — und
unter den gewollten ging das Ungewollte unter.

Abgehakt wird eine Dateizahl, kein Zeitpunkt. Ein Zeitstempel müsste gegen
`MediaFile.ModifiedAt` verglichen werden, und eine alte Datei, frisch
hineinkopiert, behält ihre alte Zeit und käme nie wieder hoch. Die Anzahl
ist genau die Frage: kommt eine dritte Fassung dazu, meldet sich die Gruppe
von selbst zurück. Gerechnet wird sie auf dem Server — ein Client mit
veralteter Liste hakte sonst eine Gruppe mit zwei Dateien ab, die inzwischen
drei hat.

„Ignorierte zeigen" führt zurück, wie bei den erledigten Meldungen; ohne das
wäre das Abhaken unumkehrbar und niemand wüsste, was er weggeklickt hat.

**Die Startseite wartete dreimal nacheinander.** Erst die Serverauskunft,
dann Konto und Mediatheken, dann erst die Startseite — drei Rundreisen,
bevor das erste Bild in Auftrag ging. Liegt eine Sitzung im Speicher, laufen
die ersten beiden jetzt zugleich los. Nur dann: ohne Sitzung liefen
`/auth/me` und `/libraries` vor der Antwort auf `needsSetup` in ein 401, und
die Sitzungsprüfung kann daraufhin abmelden.

Gemessen am laufenden Server: `users/me`, `system/info` und `libraries`
starten alle bei 37 ms, die Startseitenabfragen folgen bei 73 ms. Vorher
lagen drei Runden dazwischen.

**Sieben Hintergrundbilder auf einmal waren sechs zu viel.** Jede Folie des
Aufmachers bekam sofort ihr `w1280`-Bild; sichtbar ist immer nur eine. Jetzt
lädt die sichtbare sofort und der Rest in der Leerlaufzeit — und wer
weiterschaltet, bevor das lief, bekommt sein Bild dann.

**Die Schrift kam von Google.** `display=swap` rettete den Text, nicht den
ersten Aufbau: ein Stylesheet blockiert das Zeichnen, und ein Heimserver
ohne Internet oder mit träger Namensauflösung wartete davor. Inter liegt
jetzt als zwei Dateien von zusammen sechzig Kilobyte im Projekt, variabel
für alle Schnitte, mit `unicode-range` getrennt nach latin und latin-ext.
Gemessen: null Anfragen an fonts.googleapis.com. Lizenztext liegt dabei.

**Und mein Untertitelvorrat bremste mit.** Er startete sofort beim
Hochfahren und las die ganze Mediathek — von derselben Platte, von der die
Startseite ihre Bilder holt. Jetzt beginnt er eine Minute später und stellt
zurück, solange eine Wiedergabe läuft. Die Datei bleibt dabei vorn in der
Reihe; verworfen wird nichts.

678 Tests grün, davon zwei neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 17:56:41 +02:00

336 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
bibliotheken, unzugeordnet, ohneZuordnung, mehrfachfassungen, fassungenAbhaken, dateiHerausloesen,
type Mehrfachtitel,
} from '../../api';
import { esc, knopf, meldung, hinweisZeigen } from '../../util';
import { bearbeiten } from '../../bearbeiten';
import { abschnitt } from './teile';
/** Was sich nicht zuordnen liess. */
/** Titel, zu denen die Metadatenquelle nichts hergab.
*
* **Weil die Zahl allein nichts erklärt.** In der Übersicht standen 448
* Filme, in Plex 451 — und nirgends, welche drei fehlen oder warum. Ein Film
* ohne Zuordnung steht zwar in der Mediathek, aber unter seinem Dateinamen
* und ohne Plakat; wer ihn sucht, findet ihn nicht, weil er anders heisst.
*
* Daneben: Titel mit mehr als einer Datei. Zwei Dateien an einem Film sind
* meist zwei Fassungen und in Ordnung — manchmal aber zwei verschiedene
* Filme, die bei der Quelle auf denselben Treffer liefen. An den Dateinamen
* sieht man das in zwei Sekunden.
*/
export function ohneZuordnungBereich(): HTMLElement {
const bereich = document.createElement('section');
bereich.classList.add('hidden');
/** Ob die abgehakten Mehrfachfassungen gezeigt werden statt der offenen.
*
* **Ohne diesen Schalter wäre das Abhaken unumkehrbar.** Man klickt eine
* Gruppe weg und weiss hinterher nicht mehr, welche es war — dieselbe
* Überlegung wie bei den erledigten Meldungen. */
let zeigeIgnorierte = false;
const umschalten = knopf('Ignorierte zeigen', 'btn');
umschalten.onclick = () => {
zeigeIgnorierte = !zeigeIgnorierte;
umschalten.textContent = zeigeIgnorierte ? 'Offene zeigen' : 'Ignorierte zeigen';
void laden();
};
bereich.appendChild(abschnitt('Ohne Zuordnung', [umschalten]));
const liste = document.createElement('div');
liste.className = 'einstellungen';
bereich.appendChild(liste);
void laden();
async function laden(): Promise<void> {
let alle;
try { alle = await bibliotheken(); }
catch { return; }
const ergebnisse = await Promise.all(alle.map(async b => {
try {
const [offen, mehrfach] = await Promise.all([
ohneZuordnung(b.id), mehrfachfassungen(b.id, 300, zeigeIgnorierte),
]);
return { b, offen, mehrfach };
} catch { return null; }
}));
const mitFunden = ergebnisse
.filter(e => e !== null)
.filter(e => e!.offen.length > 0 || e!.mehrfach.gesamt > 0);
// **Solange jemand die Ignorierten ansieht, bleibt der Bereich stehen.**
// Sonst verschwände er samt Umschalter in dem Augenblick, in dem die
// letzte abgehakte Gruppe zurückgeholt ist — und niemand fände den Weg
// zurück auf die offenen.
if (mitFunden.length === 0 && !zeigeIgnorierte) {
bereich.classList.add('hidden');
return;
}
bereich.classList.remove('hidden');
liste.innerHTML = '';
if (mitFunden.length === 0) {
const leer = document.createElement('p');
leer.className = 'winzig';
leer.textContent = 'Hier ist nichts abgehakt.';
liste.appendChild(leer);
return;
}
mitFunden.forEach(e => {
const { b, offen, mehrfach } = e!;
if (offen.length > 0) {
liste.appendChild(gruppe(
`${b.name} — ${anzahl(offen.length, 'Titel')} ohne Zuordnung`,
'Diese stehen unter ihrem Dateinamen in der Mediathek. '
+ 'Über „Zuordnung korrigieren" lassen sie sich geradeziehen.',
offen.map(t => ({
titel: t.titel + (t.jahr ? ` (${t.jahr})` : ''),
dazu: t.beispiel ?? `${anzahl(t.dateien, 'Datei')}`,
// **Hier bearbeiten, nicht woandershin springen.** Wer eine
// Liste durchgeht, will nach dem Geradeziehen beim nächsten
// weitermachen — nicht jedes Mal zurücknavigieren.
tun: () => bearbeiten(t.art === 'series' ? 'series' : 'movies', t.id, () => void laden()),
}))));
}
if (mehrfach.gesamt > 0) {
liste.appendChild(fassungsgruppe(
`${b.name} — ${anzahl(mehrfach.gesamt, 'Titel')} mit mehreren Dateien`,
zeigeIgnorierte
? 'Diese Gruppen wurden als geprüft abgehakt. „Wieder zeigen" holt sie zurück.'
: 'Meist zwei Fassungen desselben Films — dann ist alles in Ordnung und '
+ '„Ignorieren" nimmt die Gruppe aus der Liste. Stehen hier zwei '
+ 'verschiedene Filme, war einer davon nicht mehr zu sehen: '
+ '„Eigener Film" löst ihn wieder heraus.',
mehrfach.titel, b.id));
}
});
}
function anzahl(n: number, wort: string): string {
return `${n} ${n === 1 ? wort : (wort === 'Datei' ? 'Dateien' : wort)}`;
}
/** Dieselbe Gruppe, aber jede Datei einzeln mit einem Weg heraus. */
function fassungsgruppe(
ueberschrift: string, hinweis: string, titel: Mehrfachtitel[], bibliothek: string,
): HTMLElement {
const d = document.createElement('div');
d.className = 'einstellung';
const kopf = document.createElement('div');
kopf.className = 'zeile';
kopf.innerHTML = `<strong>${esc(ueberschrift)}</strong>`
+ `<span class="winzig">${esc(hinweis)}</span>`;
d.appendChild(kopf);
const innen = document.createElement('div');
innen.className = 'ohnezuordnung';
titel.forEach(t => {
const zeile = document.createElement('div');
zeile.className = 'fassungstitel';
const kopfzeile = document.createElement('div');
kopfzeile.className = 'fassungsdatei';
const name = document.createElement('button');
name.className = 'ohnezuordnung-zeile';
name.innerHTML = `<span class="oz-titel">${esc(t.titel + (t.jahr ? ` (${t.jahr})` : ''))}</span>`;
name.onclick = () => bearbeiten(
t.art === 'series' ? 'series' : 'movies', t.id, () => void laden());
// **Für die, die absichtlich zwei Fassungen halten.** Die Liste
// rechnet jedes Mal frisch; ohne das Abhaken stand eine gewollte
// Gruppe hier für immer, und darunter ging das Ungewollte unter.
const abhaken = knopf(zeigeIgnorierte ? 'Wieder zeigen' : 'Ignorieren', 'btn klein');
abhaken.onclick = async () => {
abhaken.disabled = true;
try {
await fassungenAbhaken(bibliothek, [t.id], !zeigeIgnorierte);
hinweisZeigen(zeigeIgnorierte
? `„${t.titel}" steht wieder in der Liste.`
: `„${t.titel}" gilt als geprüft. Kommt eine Fassung dazu, meldet er sich wieder.`);
await laden();
} catch (fehler) {
hinweisZeigen(meldung(fehler), 'fehler');
abhaken.disabled = false;
}
};
kopfzeile.append(name, abhaken);
zeile.appendChild(kopfzeile);
t.dateien.forEach(f => {
const d2 = document.createElement('div');
d2.className = 'fassungsdatei';
const n = document.createElement('span');
n.className = 'oz-dazu';
n.textContent = f.name;
// **Nur bei mehr als einer Datei sinnvoll** — und mehr als eine ist
// hier die Bedingung, überhaupt in der Liste zu stehen.
const raus = knopf('Eigener Film', 'btn klein');
raus.onclick = async () => {
raus.disabled = true;
try {
const neu = await dateiHerausloesen(f.id);
hinweisZeigen(`„${neu.title}" ist jetzt ein eigener Film — ohne Zuordnung.`);
await laden();
} catch (fehler) {
hinweisZeigen(meldung(fehler), 'fehler');
raus.disabled = false;
}
};
d2.append(n, raus);
zeile.appendChild(d2);
});
innen.appendChild(zeile);
});
d.appendChild(innen);
return d;
}
function gruppe(
ueberschrift: string, hinweis: string,
zeilen: { titel: string; dazu: string; tun: () => void }[],
): HTMLElement {
const d = document.createElement('div');
d.className = 'einstellung';
const kopf = document.createElement('div');
kopf.className = 'zeile';
kopf.innerHTML = `<strong>${esc(ueberschrift)}</strong>`
+ `<span class="winzig">${esc(hinweis)}</span>`;
d.appendChild(kopf);
const innen = document.createElement('div');
innen.className = 'ohnezuordnung';
zeilen.forEach(z => {
const b = document.createElement('button');
b.className = 'ohnezuordnung-zeile';
b.innerHTML = `<span class="oz-titel">${esc(z.titel)}</span>`
+ `<span class="oz-dazu">${esc(z.dazu)}</span>`;
b.onclick = z.tun;
innen.appendChild(b);
});
d.appendChild(innen);
return d;
}
return bereich;
}
export function unzugeordnetbereich(): HTMLElement {
const bereich = document.createElement('section');
const kopf = abschnitt('Nicht zugeordnet');
bereich.appendChild(kopf);
const liste = document.createElement('div');
liste.className = 'einstellungen';
bereich.appendChild(liste);
void laden();
async function laden(): Promise<void> {
let alle;
try { alle = await bibliotheken(); }
catch { bereich.classList.add('hidden'); return; }
const ergebnisse = await Promise.all(alle.map(async b => {
try { return { b, offen: await unzugeordnet(b.id) }; }
catch { return null; }
}));
const mitFunden = ergebnisse.filter(e => e !== null).filter(e => e!.offen.gesamt > 0);
// Nichts offen heisst: der Abschnitt hat hier nichts verloren.
if (mitFunden.length === 0) { bereich.classList.add('hidden'); return; }
bereich.classList.remove('hidden');
liste.innerHTML = '';
mitFunden.forEach(eintrag => {
const { b, offen } = eintrag!;
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(b.name)}</strong>`
+ `<span class="winzig">${offen.gesamt} ${offen.gesamt === 1 ? 'Datei' : 'Dateien'} `
+ 'ohne Zuordnung</span>';
oben.appendChild(text);
// **Zum Verschicken.** Eine Liste, die man markieren und kopieren muss,
// schickt niemand — und ohne die Pfade lässt sich nicht sagen, woran
// die Erkennung scheitert.
const holen = knopf('Liste herunterladen', 'btn');
holen.onclick = () => {
const zeilen = offen.dateien.map(f => `${f.pfad}\t${f.grund}`).join('\n');
const blob = new Blob(
[`# ${b.name} — ${offen.gesamt} ohne Zuordnung, davon ${offen.dateien.length} aufgeführt\n`
+ zeilen + '\n'],
{ type: 'text/plain;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `nicht-zugeordnet-${b.name}.txt`;
a.click();
URL.revokeObjectURL(a.href);
};
oben.appendChild(holen);
d.appendChild(oben);
// **Nach Grund gebündelt, nicht als Liste von dreitausend Zeilen.** So
// sieht man in zwei Zeilen, ob umzubenennen oder einzuordnen ist.
const gruende = new Map<string, string[]>();
offen.dateien.forEach(f => {
if (!gruende.has(f.grund)) gruende.set(f.grund, []);
gruende.get(f.grund)!.push(f.pfad);
});
[...gruende.entries()]
.sort((a, c) => c[1].length - a[1].length)
.forEach(([grund, pfade]) => {
const g = document.createElement('details');
g.className = 'grundblock';
const z = document.createElement('summary');
z.innerHTML = `<strong>${pfade.length}×</strong> ${esc(grund)}`;
g.appendChild(z);
const pre = document.createElement('pre');
pre.className = 'protokoll';
pre.textContent = pfade.slice(0, 50).join('\n')
+ (pfade.length > 50 ? `\n… und ${pfade.length - 50} weitere` : '');
g.appendChild(pre);
d.appendChild(g);
});
liste.appendChild(d);
});
}
return bereich;
}