Die Mediathek hörte bei I auf, und jede Folge stand doppelt

Zwei Befunde aus dem laufenden Betrieb.

Die Liste holte genau eine Seite. Der Server gibt höchstens 200 Einträge
je Zug — das ist richtig so —, nur fragte die Oberfläche nie nach der
zweiten. Bei 455 Filmen endete die Mediathek mitten im Alphabet, ohne
Hinweis, dass da noch etwas kommt. Jetzt wird beim Scrollen nachgeladen,
und die Zahl sagt „200 von 455" statt einer Behauptung, die man beim
Scrollen widerlegt.

Dabei kam heraus, dass ein IntersectionObserver eine Seite braucht, die
auch gezeichnet wird: im Fenster im Hintergrund löst er nicht aus, und
beim Prüfen blieb die Liste genau deshalb bei 200 stehen. Daneben steht
jetzt eine Prüfung am Scrollereignis, die überall greift.

Der zweite Befund: jede Folge zeigte zwei gleich aussehende Knopfreihen.
Es sind zwei Dateien — Plex' „Optimized for Mobile"-Kopien. Flaro kannte
„optimized", aber nicht „Optimized for Mobile", und sah ohnehin nur den
unmittelbaren Elternordner; Plex legt die Fassung aber als
`Plex Versions/Optimized for Mobile/<Serie>/<Staffel>/…` ab. Jetzt wird
drei Ebenen hoch gesucht, die Schublade `Plex Versions` benennt keine
Serie mehr, und an der Folge steht „Mobil" statt einer zweiten,
unerklärten Reihe.

Nicht um Plex zu bedienen — die Dateien liegen nun einmal so auf der
Platte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 18:45:07 +02:00
co-authored by Claude Opus 5
parent 960cca3dae
commit 8809b9f46f
8 changed files with 246 additions and 32 deletions
+2
View File
@@ -19,6 +19,8 @@ export type Spur = {
export type Datei = {
id: string; fileName: string; sizeBytes: number;
container: string | null; durationSeconds: number | null; streams: Spur[];
/** „Mobil", „4K", „Director's Cut" — oder `null`, wenn es nur eine gibt. */
versionLabel: string | null;
};
/** Was dieser Benutzer mit einem Titel zu tun hatte. */
export type Stand = {
+14
View File
@@ -1045,6 +1045,20 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m
.grundblock summary strong { color: var(--accent); margin-right: 6px; }
.grundblock .protokoll { max-height: 220px; margin-top: var(--s-snug); }
/* Der Dateiname über einer Aktionsreihe — nur wenn es mehrere gibt. */
.dateiname.fassung {
font-family: inherit; color: var(--text-dim); font-weight: 600;
}
.dateiname {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--text-faint); margin-top: var(--s-snug);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Der Fühler, der das Nachladen auslöst — unsichtbar, aber nicht null hoch:
* ein Element ohne Höhe kreuzt die Sichtgrenze nie zuverlässig. */
.nachlader { height: 1px; }
.bildblock { display: flex; flex-direction: column; gap: var(--s-snug); }
.bildblock h3 { margin: 0; font-size: var(--t-gross); font-weight: 600; flex: 1; }
+21 -2
View File
@@ -452,8 +452,27 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
block.appendChild(text);
}
folge.files.forEach(datei => block.appendChild(
aktionsreihe({ ...({} as Film), id: folge.id, files: folge.files } as Film, folge.state, datei)));
// **Bei mehreren Dateien steht dabei, welche es ist.**
//
// Vorher standen zwei gleich aussehende Reihen untereinander, und die
// Folge wirkte doppelt. Es sind zwei Dateien — eine zweite Fassung, ein
// anderes Format, eine Kopie —, und genau das muss dastehen, damit man
// entscheiden kann, ob eine davon wegkann.
folge.files.forEach(datei => {
if (folge.files.length > 1) {
const welche = document.createElement('div');
welche.className = 'winzig dateiname';
// Der Fassungsname, wenn es einen gibt — „Mobil" sagt mehr als ein
// Pfad. Sonst der Dateiname, damit überhaupt etwas dasteht.
welche.textContent = (datei.versionLabel ?? datei.fileName)
+ (datei.sizeBytes ? ` · ${groesse(datei.sizeBytes)}` : '');
welche.title = datei.fileName;
if (datei.versionLabel) welche.classList.add('fassung');
block.appendChild(welche);
}
block.appendChild(aktionsreihe(
{ ...({} as Film), id: folge.id, files: folge.files } as Film, folge.state, datei));
});
return block;
}
+86 -15
View File
@@ -172,18 +172,69 @@ export function mediathek(
]);
}).catch(() => {});
void laden();
/** Wie viele Einträge der Server höchstens je Zug gibt. */
const SEITE = 200;
// Beim Zurückkommen aus einer anderen Ansicht leise nachladen — die alte
// Liste bleibt stehen, bis die neue da ist.
beiRueckkehr(wurzel, () => void laden());
let geladen = 0;
let gesamt = 0;
let amEnde = false;
let holtGerade = false;
/** Der Fühler unter dem Raster: wird er sichtbar, kommt die nächste Seite. */
const fuehler = document.createElement('div');
fuehler.className = 'nachlader';
ziel.appendChild(fuehler);
// **Nachladen beim Scrollen statt eines Knopfes.**
//
// Die Mediathek holte genau eine Seite und hörte danach auf — bei 455
// Filmen endete die Liste mitten im Alphabet, ohne Hinweis, dass da noch
// etwas kommt. Der Fühler steht 600 Bildpunkte vor dem Ende, damit die
// nächste Seite da ist, bevor man sie braucht.
const beobachter = new IntersectionObserver(
eintraege => { if (eintraege.some(e => e.isIntersecting)) void laden(false); },
{ rootMargin: '600px' });
beobachter.observe(fuehler);
// Kein eigenes Aufräumen: ein abgehängter Fühler löst nichts aus, und
// wird die Ansicht aus dem Zwischenspeicher zurückgeholt, soll er wieder
// greifen.
// **Und ein zweiter Weg über das Scrollen.**
//
// Ein IntersectionObserver braucht eine Seite, die auch gezeichnet wird;
// in einem Fenster im Hintergrund löst er nicht aus, und beim Prüfen blieb
// die Liste genau deshalb bei 200 stehen. Die Prüfung von Hand kostet
// nichts — sie läuft nur, solange überhaupt noch etwas nachzuladen ist.
const beimScrollen = () => {
if (amEnde || holtGerade) return;
if (!fuehler.isConnected) return;
if (fuehler.getBoundingClientRect().top < window.innerHeight + 600) void laden(false);
};
window.addEventListener('scroll', beimScrollen, { passive: true, capture: true });
window.addEventListener('resize', beimScrollen, { passive: true });
async function laden(vonVorn = true): Promise<void> {
if (holtGerade) return;
if (!vonVorn && amEnde) return;
holtGerade = true;
async function laden(): Promise<void> {
// Der Spinner nur beim ersten Mal; beim Nachladen bleibt stehen, was da
// ist, bis es ersetzt wird.
if (gitter.childElementCount === 0) gitter.innerHTML = '<div class="spinner"></div>';
if (vonVorn && gitter.childElementCount === 0) {
gitter.innerHTML = '<div class="spinner"></div>';
}
try {
const seite = await inhalt(bibliothek, { search: suche, sort: ordnung, genre, take: 200 });
const seite = await inhalt(bibliothek, {
search: suche, sort: ordnung, genre,
skip: vonVorn ? 0 : geladen, take: SEITE,
});
if (vonVorn) { gitter.innerHTML = ''; geladen = 0; amEnde = false; }
gesamt = seite.total;
geladen += seite.items.length;
if (seite.items.length < SEITE || geladen >= gesamt) amEnde = true;
// **Gesehen/offen siebt hier, nicht am Server.** Er kann es noch
// nicht; es jetzt wegzulassen hiesse, einen Filter anzubieten, den
@@ -196,23 +247,43 @@ export function mediathek(
return gesehen === 'gesehen' ? ist : !ist;
});
anzahl.textContent = gesehen === ''
? String(seite.total)
: `${uebrig.length} von ${seite.total}`;
uebrig.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen)));
gitter.innerHTML = '';
if (uebrig.length === 0) {
// **Die Zahl sagt, wie viel schon da ist.** „455" über einer Liste mit
// 200 Kacheln wäre eine Behauptung, die man beim Scrollen widerlegt.
anzahl.textContent = amEnde && gesehen === ''
? String(gesamt)
: `${gitter.childElementCount} von ${gesamt}`;
if (gitter.childElementCount === 0 && amEnde) {
gitter.appendChild(leer(suche || genre || gesehen
? 'Nichts gefunden.'
: `„${bibliothek.name}" ist leer. Starte unter Verwaltung einen Scan.`));
return;
}
uebrig.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen)));
} catch (fehler) {
gitter.innerHTML = '';
if (vonVorn) gitter.innerHTML = '';
gitter.appendChild(fehlerzeile(meldung(fehler)));
amEnde = true;
} finally {
holtGerade = false;
}
// Füllt eine Seite den Bildschirm nicht, kommt der Fühler nie in Sicht
// — dann muss von Hand nachgefasst werden.
if (!amEnde && !vonVorn && fuehler.getBoundingClientRect().top < window.innerHeight) {
void laden(false);
}
}
// **Erst hier starten.** `laden()` greift auf `holtGerade`, `geladen` und
// den Fühler zu; stand der Aufruf darüber, lief er in die temporale
// Totzone von `const`/`let` — „Cannot access 'holtGerade' before
// initialization", und das Raster blieb leer.
void laden();
// Beim Zurückkommen aus einer anderen Ansicht leise nachladen — die alte
// Liste bleibt stehen, bis die neue da ist.
beiRueckkehr(wurzel, () => void laden());
}
/* ---------- Reiter „Empfehlungen" ---------- */