Spider-Man ist nicht 720p, er ist breit

Die Auflösung wurde allein aus der Höhe gebildet. Ein Kinoformat von 2.39:1
liegt als 1920×800 vor — nach der Höhe gerechnet kam „720p" heraus, und das
stand so in der Oberfläche. Jetzt zählt die grössere von Breite und Höhe;
Hochformat kippt damit nicht, und 4K-Breitbild heisst nicht mehr 1440p.

Die Detailansicht hat zwei Leserschaften, und bisher bekamen beide dasselbe.
Wer schauen will, sieht jetzt Laufzeit, Auflösung und die Sprachen — jede
einmal, statt „Englisch · Englisch · Englisch · Englisch · Deutsch". Wer die
Mediathek betreut, sieht darunter Dateiformat, Grösse und den Ordner. Der
Ordner verrät eine falsche Zuordnung in einer Sekunde: steht über der
Beschreibung „Hanni & Nanni 3" und der Ordner heisst „Hanni und Nanni", ist
die Sache klar. Er kommt nur für Administratoren überhaupt vom Server —
versteckt im Client wäre er weiterhin auslesbar.

Ein geplanter Titel war unsichtbar. Das ist die Hälfte, die nichts bringt:
dass er kommt, ist der Sinn des Planens. Er steht jetzt mit „Verfügbar ab …"
in der Mediathek, und die Datei geht trotzdem nicht heraus — Download wie
Strom lehnen bis zum Termin ab, auch für Administratoren. Wer vorziehen will,
hat „Jetzt veröffentlichen"; eine Hintertür für eine Rolle wäre eine Regel,
die manchmal gilt.

Was ein Auffrischen geändert hat, steht jetzt unter „Zu prüfen" — in einer
eigenen Gruppe mit „Zur Kenntnis" statt „Veröffentlichen". Diese Titel sind
sichtbar; es gibt nichts freizugeben, nur etwas abzuhaken.

Und das Protokoll sagt endlich, wo es knallt. „InvalidOperationException:
Nullable object must have a value." ohne eine einzige Zeile Herkunft ist für
niemanden eine Diagnose — weder für den Betreiber noch für mich.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-06 14:51:00 +02:00
co-authored by Claude Opus 5
parent f44779c899
commit 255eb7c2f0
19 changed files with 1569 additions and 64 deletions
+14
View File
@@ -15,12 +15,16 @@ export type Spur = {
channels: number | null; width: number | null; height: number | null;
/** Die fertige Zeile vom Server, etwa Deutsch · Dolby Digital Plus · 5.1. */
display: string | null;
/** Die Sprache als Wort — „Deutsch" statt „deu". */
languageName: string | null;
};
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;
/** Der Ordner unterhalb der Mediathek — nur für Administratoren gefüllt. */
folder: string | null;
};
/** Was dieser Benutzer mit einem Titel zu tun hatte. */
export type Stand = {
@@ -698,6 +702,8 @@ export const bildHochladen = async (
};
export type Pruefeintrag = {
/** „neu" (wartet auf Freigabe) oder „geaendert" (nur zur Kenntnis). */
grund?: 'neu' | 'geaendert';
kind: 'movie' | 'series';
id: string; title: string; year: number | null;
posterImage: string | null;
@@ -713,6 +719,14 @@ export type Pruefeintrag = {
export const zuPruefen = () => anfrage<Pruefeintrag[]>('/review');
/** Hakt geänderte Titel ab — ohne Kennung alle.
*
* **Nicht dasselbe wie veröffentlichen.** Diese Titel stehen längst in der
* Mediathek; es gibt nichts freizugeben, nur etwas zur Kenntnis zu nehmen. */
export const zurKenntnis = (ids?: string[]) =>
anfrage<{ acknowledged: number }>('/review/acknowledge',
{ method: 'POST', body: JSON.stringify({ ids: ids ?? null, publishAt: null }) });
/** Ohne Kennungen: alles, was noch nicht sichtbar ist. Ohne Zeitpunkt: jetzt. */
export const veroeffentlichen = (ids?: string[], publishAt?: string) =>
anfrage<{ published: number }>('/review/publish',
+3
View File
@@ -71,6 +71,9 @@ export const icons = {
regler: rahmen('<path d="M4 7h7M15 7h5M4 17h3M11 17h9"/>'
+ '<circle cx="13" cy="7" r="2.1"/><circle cx="9" cy="17" r="2.1"/>'),
/** Eine Uhr — für alles, was erst später gilt. */
uhr: rahmen('<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 1.8"/>'),
/** Den Spieler auf die Leiste unten schrumpfen. */
verkleinern: rahmen('<path d="M5 9.5 12 16l7-6.5"/>'),
+26
View File
@@ -1626,3 +1626,29 @@ body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 104px; }
100% { background: transparent; }
}
@media (prefers-reduced-motion: reduce) { .hervor { animation: none; } }
/* Der Ordner in den Medienangaben — schmaler gesetzt, er ist ein Pfad. */
.medientabelle dd.pfad {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; color: var(--text-dim);
overflow-wrap: anywhere;
}
/* Die Überschrift der zweiten Gruppe in der Prüfliste. */
.pruef-gruppe {
display: flex; align-items: center; gap: var(--s-regular);
flex-wrap: wrap;
padding: var(--s-regular) var(--s-snug) var(--s-snug);
border-top: 1px solid var(--line); margin-top: var(--s-snug);
}
.pruef-gruppe strong { font-size: 14px; }
.pruef-gruppe .winzig { flex: 1 1 240px; }
/* „Verfügbar ab …" steht dort, wo sonst Abspielen stünde. */
.verfuegbar-ab {
display: inline-flex; align-items: center; gap: var(--s-snug);
padding: 10px 16px; border-radius: var(--r-control);
background: var(--raised); border: 1px solid var(--line);
font-size: 14px; font-weight: 600; color: var(--text-dim);
}
.verfuegbar-ab .icon svg { width: 18px; height: 18px; }
+51 -10
View File
@@ -3,7 +3,7 @@ import {
scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen,
vermisste, vermisstesEntfernen, type VermissteDatei,
protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen,
meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen,
meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen, zurKenntnis,
mediathekZuruecksetzen,
type Pruefeintrag,
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
@@ -290,17 +290,48 @@ function pruefbereich(geaendert: () => void): {
freigeben.textContent = `Veröffentlichen (${wieViele})`;
liste.innerHTML = '';
stand.forEach(e => liste.appendChild(zeileFuer(e)));
// **Zwei Gruppen, zwei Bedeutungen.** Ein neuer Fund wartet auf Freigabe
// und ist bis dahin unsichtbar; ein geänderter Titel steht längst in der
// Mediathek und soll nur nicht unbemerkt anders heissen. Beides in einer
// Reihe hiesse, „Veröffentlichen" auf etwas anzubieten, das veröffentlicht
// ist.
const neue = stand.filter(e => e.grund !== 'geaendert');
const geaenderte = stand.filter(e => e.grund === 'geaendert');
neue.forEach(e => liste.appendChild(zeileFuer(e)));
if (geaenderte.length > 0) {
const kopf = document.createElement('div');
kopf.className = 'pruef-gruppe';
kopf.innerHTML = '<strong>Geändert</strong>'
+ '<span class="winzig">Diese Titel sind schon sichtbar. Ein Auffrischen '
+ 'hat Titel, Jahr oder Plakat geändert — hier nur zur Kenntnis.</span>';
const abhaken = knopf('Alle zur Kenntnis', 'btn');
abhaken.onclick = async () => {
abhaken.disabled = true;
try { await zurKenntnis(); await laden(); }
catch (fehler) { liste.prepend(fehlerzeile(meldung(fehler))); abhaken.disabled = false; }
};
kopf.appendChild(abhaken);
liste.append(kopf, ...geaenderte.map(zeileFuer));
}
}
function zeileFuer(e: Pruefeintrag): HTMLElement {
const d = document.createElement('div');
d.className = 'einstellung pruefzeile';
const geaendertes = e.grund === 'geaendert';
const haken = document.createElement('input');
haken.type = 'checkbox';
haken.checked = gewaehlt.has(e.id);
haken.setAttribute('aria-label', `„${e.title}“ auswählen`);
// Die Sammelaktionen oben veröffentlichen — auf Geändertes passt das nicht.
haken.classList.toggle('hidden', geaendertes);
haken.onchange = () => {
if (haken.checked) gewaehlt.add(e.id); else gewaehlt.delete(e.id);
zeichnen();
@@ -323,11 +354,13 @@ function pruefbereich(geaendert: () => void): {
const was = e.kind === 'series'
? `${e.count} ${e.count === 1 ? 'Folge' : 'Folgen'}`
: null;
const lage = geplant
? `Geplant für ${geplant.toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}`
: e.kind === 'series' && e.count > 1 && !e.publishAt
? 'Wartet'
: 'Wartet auf Freigabe';
const lage = geaendertes
? 'Sichtbar — ein Auffrischen hat etwas geändert'
: geplant
? `Geplant für ${geplant.toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}`
: e.kind === 'series' && e.count > 1 && !e.publishAt
? 'Wartet'
: 'Wartet auf Freigabe';
// Ohne Zuordnung fehlen Titel und Bilder — dann ist nicht die Freigabe
// das Nächste, sondern die Zuordnung.
const hinweis = e.matched ? null : 'noch keinem Titel zugeordnet';
@@ -335,11 +368,19 @@ function pruefbereich(geaendert: () => void): {
text.innerHTML = `<strong>${esc(e.title)}${e.year ? ` (${e.year})` : ''}</strong>`
+ `<span class="winzig">${[was, lage, hinweis].filter(Boolean).map(esc).join(' · ')}</span>`;
const nur = knopf(geplant ? 'Jetzt veröffentlichen' : 'Veröffentlichen', 'btn');
// **Geändertes wird abgehakt, nicht veröffentlicht** — es ist schon
// sichtbar. „Veröffentlichen" wäre dort ein Knopf, der nichts tut.
const nur = knopf(
geaendertes ? 'Zur Kenntnis' : geplant ? 'Jetzt veröffentlichen' : 'Veröffentlichen',
'btn');
nur.onclick = async () => {
nur.disabled = true;
try { await veroeffentlichen(e.ids); await laden(); geaendert(); }
catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); nur.disabled = false; }
try {
if (geaendertes) await zurKenntnis([e.id]);
else await veroeffentlichen(e.ids);
await laden();
geaendert();
} catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); nur.disabled = false; }
};
// **Dasselbe Dreipunktemenü wie auf einer Kachel.**
+83 -23
View File
@@ -239,7 +239,25 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
const hinweisfeld = document.createElement('div');
hinweisfeld.className = 'hinweis';
if (datei) {
// **Geplant heisst sichtbar, nicht abspielbar.**
//
// Der Titel steht in der Mediathek, damit man weiss, dass er kommt — das
// ist der Sinn des Planens. Abspielen und Herunterladen gibt es erst ab
// dem Termin, und der Server lehnt sie bis dahin auch ab; stünden die
// Knöpfe trotzdem da, wäre die Angabe eine Zierde.
const geplantAb = eintrag.publishAt ? new Date(eintrag.publishAt) : null;
const nochNicht = geplantAb !== null && geplantAb > new Date();
if (nochNicht) {
const hinweisZeile = document.createElement('div');
hinweisZeile.className = 'verfuegbar-ab';
hinweisZeile.append(icon('uhr'), document.createTextNode(
`Verfügbar ab ${geplantAb!.toLocaleString('de-CH',
{ dateStyle: 'long', timeStyle: 'short' })}`));
reihe.appendChild(hinweisZeile);
}
if (datei && !nochNicht) {
const stelle = stand?.positionSeconds ?? 0;
const los = knopf(stelle > 0 ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
los.prepend(icon('abspielen'));
@@ -305,6 +323,19 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
/* ---------- Medienangaben ---------- */
/** Video / Ton / Untertitel — Plex' Tabelle unter der Beschreibung. */
/** Was über einer Datei steht.
*
* **Zwei Leserschaften, zwei Bedürfnisse.**
*
* Wer einen Film sucht, will wissen: wie lang, in welcher Qualität, in
* welchen Sprachen — Ton wie Untertitel. Dateiformat, Grösse und Ordner
* sagen ihm nichts und gehen ihn auch nichts an: der Pfad verriete den
* Aufbau des Servers.
*
* Wer die Mediathek betreut, braucht genau das Gegenteil. Deshalb stehen
* die technischen Angaben darunter und nur für Administratoren — der Ordner
* kommt ohnehin nur dann vom Server.
*/
function medienangaben(datei: Datei): HTMLElement {
const liste = document.createElement('dl');
liste.className = 'medientabelle';
@@ -313,19 +344,59 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
const ton = datei.streams.filter(s => s.kind === 'Audio');
const text = datei.streams.filter(s => s.kind === 'Subtitle');
zeile('Datei', [datei.container?.toUpperCase(), groesse(datei.sizeBytes),
dauer(datei.durationSeconds)].filter(Boolean).join(' · '));
// **Auch hier die Formulierung vom Server.** Vorher baute diese Zeile
// ihren Text selbst — „240p (H264)" statt „240p · H.264" —, und damit
// stand in derselben Tabelle zweierlei Schreibweise untereinander.
/* ---------- Für alle ---------- */
if (datei.durationSeconds) zeile('Laufzeit', laufzeit(datei.durationSeconds));
if (bild) {
const v = zeile('Video', spurname(bild));
// Die gemessene Höhe gehört in den Tooltip: 1040 ist keine Stufe,
// sondern ein beschnittenes Bild.
if (bild.width && bild.height) v.title = `${bild.width} × ${bild.height}`;
// Nur die Stufe, nicht der Codec: „H.264" ist für den, der schauen
// will, kein Unterschied, den er trifft.
const stufe = bild.display?.split(' · ')[0] ?? null;
if (stufe) {
const v = zeile('Auflösung', stufe);
// Die gemessene Grösse gehört in den Tooltip: 1920×800 ist kein
// Fehler, sondern Kinoformat.
if (bild.width && bild.height) v.title = `${bild.width} × ${bild.height}`;
}
}
// **Nur die Sprachen, und jede einmal.** Vorher stand hier „Englisch ·
// Englisch · Englisch · Englisch · Deutsch · …", weil jede Spur einzeln
// aufgezählt wurde — bei sieben Tonspuren eine Zeile, die niemand liest.
if (ton.length) zeile('Ton', sprachen(ton));
if (text.length) zeile('Untertitel', sprachen(text));
/* ---------- Nur für die Verwaltung ---------- */
if (istAdmin()) {
const technisch = [datei.container?.toUpperCase(), groesse(datei.sizeBytes)]
.filter(Boolean).join(' · ');
if (technisch) {
const d = zeile('Datei', technisch);
d.title = datei.fileName;
}
// Der Ordner verrät eine falsche Zuordnung in einer Sekunde: steht über
// der Beschreibung „Hanni & Nanni 3" und der Ordner heisst „Hanni und
// Nanni", ist die Sache klar — ohne Umweg über den Zuordnungsdialog.
if (datei.folder) zeile('Ordner', datei.folder).className = 'pfad';
}
/** Die Sprachen einer Spurenliste — jede einmal, in ihrer Reihenfolge. */
function sprachen(spuren: Datei['streams']): string {
const namen: string[] = [];
spuren.forEach(s => {
const name = s.languageName ?? s.display?.split(' · ')[0] ?? null;
if (name && !namen.includes(name)) namen.push(name);
});
return namen.length > 0 ? namen.join(' · ') : `${spuren.length} Spuren`;
}
/** „2 Std. 25 Min." — nicht „2:25:01". */
function laufzeit(sekunden: number): string {
const m = Math.round(sekunden / 60);
const h = Math.floor(m / 60);
return h > 0 ? `${h} Std. ${m % 60} Min.` : `${m} Min.`;
}
if (ton.length) zeile('Ton', ton.map(spurname).join(' · '));
if (text.length) zeile('Untertitel', text.map(spurname).join(' · '));
function zeile(name: string, wert: string): HTMLElement {
const dt = document.createElement('dt');
@@ -339,17 +410,6 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
return liste;
}
/** Der Name einer Spur — vom Server, nicht hier zusammengesetzt.
*
* **Vorher stand hier „DEU EAC3 6.0".** Jedes Stück davon war richtig und
* keines verständlich: EAC3 heisst Dolby Digital Plus, und sechs Kanäle
* sind 5.1, nicht 6.0. Der Server formuliert das jetzt, weil die Apps
* denselben Satz brauchen — zweimal formuliert driftet er auseinander. */
function spurname(s: Datei['streams'][number]): string {
if (s.display) return s.display;
// Rückfall für einen älteren Server, der das Feld noch nicht schickt.
return [s.language?.toUpperCase(), s.codec?.toUpperCase()].filter(Boolean).join(' ');
}
/* ---------- Besetzung ---------- */