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:
co-authored by
Claude Opus 5
parent
f44779c899
commit
255eb7c2f0
@@ -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',
|
||||
|
||||
@@ -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"/>'),
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ---------- */
|
||||
|
||||
|
||||
Reference in New Issue
Block a user