Files
Flaro/web/app/src/views/library.ts
T
ScarriffleandClaude Opus 5 230beb9664 Mehrere Mediatheken, jede für sich
Bisher gab es in der Seitenleiste „Filme" und „Serien" — zwei feste Zeilen,
hinter denen alles zusammenfiel, was der Server kannte. Wer „Filme" und
„Dokumentationen" getrennt angelegt hatte, sah sie wieder in einem Topf.
Das nahm die Entscheidung zurück, sie zu trennen.

Jetzt führt die Seitenleiste **jede Mediathek einzeln**, mit Namen und Zahl,
wie bei Plex und Jellyfin. Die Startseite bekommt je Mediathek eine Reihe
mit dem zuletzt Hinzugefügten; steht mehr drin, als die Reihe zeigt, führt
„Alle zeigen" dorthin. Die Mediathekenseite trägt den Namen im Titel, sucht
innerhalb der Mediathek und lässt sich sortieren.

**Am Server dafür:**

`?sort=` auf `/movies` und `/series` — `title`, `added`, `year`. Der Titel
immer als letzter Vergleich, sonst hätten zwei Filme aus demselben Jahr keine
feste Reihenfolge und Seite 2 zeigte denselben Film noch einmal oder gar
nicht.

Die Liste der Bibliotheken nennt je Zeile einen Zählwert. „Dokumentationen ·
0" sagt mehr als ein Eintrag, hinter dem sich nach dem Klick nichts findet —
da ist der Unterschied zwischen „noch nicht gescannt" und „falscher Pfad"
wenigstens sichtbar. `null` heisst „nicht gezählt", nicht „leer".

Umbenennen und Entfernen, beides nur für Administratoren. **Umbenennen, mehr
nicht:** die Art zu ändern hiesse, Filme in Serien zu verwandeln; den Pfad
zu ändern hiesse, einen Bestand gegen einen anderen zu tauschen, während die
Einträge stehenbleiben. Beides ist kein Bearbeiten, sondern eine neue
Bibliothek.

**Beim Entfernen bleiben die Mediendateien liegen.** Weg sind der Eintrag,
die Zuordnungen und der Sehfortschritt aller Benutzer dazu; die Oberfläche
sagt das vorher, und eine Prüfung hält fest, dass die Datei danach noch da
ist. Ein Server, der beim Aufräumen in der Oberfläche fremde Filme löscht,
ist einer, den man nicht noch einmal benutzt.

---

**Der Fehler, den der Browser zeigte, und er betrifft jeden Betrieb:** nach
einem Server-Update lief weiter die alte Oberfläche.

Die Dateien unter `assets/` tragen eine Prüfsumme im Namen — ändert sich der
Inhalt, ändert sich der Name. `index.html` trägt keine; sie nennt nur deren
Namen. Lag sie im Zwischenspeicher, bekam der Browser weiter die alte App:
nicht kaputt, sondern veraltet, und das fällt niemandem auf. Nachgemessen:
geladen war `index-C0K3V8eg.js`, ausgeliefert wurde längst
`index-DomHN4_b.js`.

Jetzt zwei Lebensdauern — `no-cache` für die Seite, ein Jahr und `immutable`
für die Bausteine. Mit Prüfung, und am Container nachgemessen.

Der Beispielbestand bekommt zwei weitere Mediatheken, „Dokumentationen" und
„Konzerte". Mit nur Filme und Serien liesse sich gar nicht prüfen, ob
mehrere wirklich getrennt bleiben — zwei Bestände **derselben Art** sind der
Fall, in dem es auffällt.

358 Tests grün. Am Container nachgewiesen: vier Mediatheken in der
Seitenleiste mit Zählwerten, je eine Reihe auf der Startseite, und
„Dokumentationen" zeigt zwei Titel statt sieben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 22:57:25 +02:00

125 lines
4.2 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 { inhalt, type Bibliothek, type Film, type Serie, type Ordnung } from '../api';
import { esc, plakat, leer, fehlerzeile, anteil } from '../util';
type Art = 'filme' | 'serien';
/** Eine Mediathek als Raster.
*
* **Eine Ansicht je Mediathek, nicht eine je Art.** Welche gezeigt wird,
* entscheidet die Seitenleiste — wie bei Plex, wo jede Mediathek ihr eigener
* Punkt in der Navigation ist. Wer „Filme" und „Dokumentationen" getrennt
* angelegt hat, hat sie getrennt gemeint.
*
* Gesucht und sortiert wird am Server: bei zehntausend Titeln wäre alles zu
* laden und dann zu sieben der falsche Weg. */
export function mediathek(
bibliothek: Bibliothek,
oeffnen: (art: Art, id: string) => void,
): HTMLElement {
const wurzel = document.createElement('div');
const art: Art = bibliothek.kind === 'Shows' ? 'serien' : 'filme';
let suche = '';
let ordnung: Ordnung = 'title';
let tippt: number | undefined;
const kopf = document.createElement('div');
kopf.className = 'row';
const titel = document.createElement('h1');
titel.className = 'seitentitel';
titel.textContent = bibliothek.name;
const sortierung = document.createElement('select');
sortierung.className = 'field schmal';
([
['title', 'A–Z'],
['added', 'Zuletzt hinzugefügt'],
['year', 'Jahr'],
] as [Ordnung, string][]).forEach(([wert, text]) => {
const o = document.createElement('option');
o.value = wert;
o.textContent = text;
sortierung.appendChild(o);
});
sortierung.onchange = () => { ordnung = sortierung.value as Ordnung; void laden(); };
const suchfeld = document.createElement('input');
suchfeld.className = 'field';
suchfeld.type = 'search';
suchfeld.placeholder = `In ${bibliothek.name} suchen`;
suchfeld.oninput = () => {
// Nicht bei jedem Tastendruck fragen: das wären bei „Untergang" neun
// Anfragen, von denen acht niemanden interessieren.
window.clearTimeout(tippt);
tippt = window.setTimeout(() => { suche = suchfeld.value.trim(); void laden(); }, 250);
};
kopf.append(titel, sortierung, suchfeld);
const raster = document.createElement('div');
raster.className = 'grid';
wurzel.append(kopf, raster);
async function laden(): Promise<void> {
raster.innerHTML = '';
raster.appendChild(ladeanzeige());
try {
const seite = await inhalt(bibliothek, { search: suche, sort: ordnung, take: 100 });
raster.innerHTML = '';
if (seite.items.length === 0) {
raster.appendChild(leer(suche
? `Nichts gefunden für „${suche}“.`
: `„${bibliothek.name}“ ist leer. Starte unter Verwaltung einen Scan.`));
return;
}
seite.items.forEach(eintrag => raster.appendChild(karte(eintrag)));
} catch (fehler) {
raster.innerHTML = '';
raster.appendChild(fehlerzeile((fehler as Error).message));
}
}
function karte(eintrag: Film | Serie): HTMLElement {
const k = document.createElement('button');
k.className = 'card';
const bild = plakat(
eintrag.posterImage, eintrag.title,
'state' in eintrag ? anteil(eintrag.state) : null);
if ('unwatchedCount' in eintrag && eintrag.unwatchedCount > 0) {
const plakette = document.createElement('span');
plakette.className = 'offen';
plakette.textContent = String(eintrag.unwatchedCount);
bild.appendChild(plakette);
}
k.appendChild(bild);
const unten = document.createElement('div');
unten.className = 'rowline';
// Zusammengesetzt aus dem, was da ist — ein fest verdrahtetes „ · " davor
// hinterlässt bei fehlendem Jahr einen Punkt ohne Angabe.
const teile = [
eintrag.year ? String(eintrag.year) : null,
'episodeCount' in eintrag ? `${eintrag.episodeCount} Folgen` : null,
'state' in eintrag && eintrag.state?.watched ? 'Gesehen' : null,
].filter(Boolean);
unten.innerHTML = `<strong>${esc(eintrag.title)}</strong>`
+ `<span class="tiny">${esc(teile.join(' · '))}</span>`;
k.appendChild(unten);
k.onclick = () => oeffnen(art, eintrag.id);
return k;
}
void laden();
return wurzel;
}
function ladeanzeige(): HTMLElement {
const d = document.createElement('div');
d.className = 'spinner';
return d;
}