Eine Sekunde ist kein Anfang

Drei Zahlen standen als Konstante im Quelltext und bestimmten, wie sich
der Server anfühlt. Jetzt stehen sie in der Verwaltung, unter
„Wiedergabe".

**Weiterschauen ab.** Gemessen: `BelongsInContinueWatching` verlangte nur
`Position.Ticks > 0` — ein Tick genügte. Wer im Aufmacher eine Sekunde
hineinschaute, fand den Titel danach in „Fortsetzen". Jetzt gibt es eine
Untergrenze, voreingestellt auf 1½ Minuten.

Was sich dabei **nicht** ändert: die Stelle wird weiter gemerkt. Das ist
der Unterschied zu Jellyfin, um dessentwillen es diesen Server gibt — die
Schwelle entscheidet, ob der Titel in der Reihe steht, nicht ob man ihn
wiederfindet. Nachgemessen: nach dem Umstellen ist „Heat" aus der Reihe
verschwunden und steht weiterhin bei Sekunde 1.

**Gesehen ab.** War 90 % als `const`, ist jetzt 85/90/95.

**Umkodieren.** `-preset veryfast -crf 21` und VAAPI `-qp 23` waren fest
verdrahtet, ohne Einstellung und ohne Umgebungsvariable. Drei Stufen:
Qualität (medium/19, qp 21), Ausgewogen (wie bisher), Tempo
(ultrafast/23, qp 26). Mit dem Warnsatz daneben, und der ist ernst
gemeint: was in Echtzeit laufen muss, darf nicht langsamer rechnen als es
abspielt.

**Und das Vorhandene wird neu bewertet.** `InProgress` ist eine
gespeicherte Spalte — die Bruchrechnung lässt sich nicht nach SQL
übersetzen. Eine neue Schwelle wirkte sonst nur auf künftige Meldungen.
Der Endpunkt läuft deshalb einmal in Stapeln durch und meldet zurück, wie
viele Zeilen das umgeworfen hat: „Übernommen — 2 Einträge wurden neu
bewertet." Ohne diesen Satz änderte sich die Startseite scheinbar von
selbst.

`ProgressRule` bleibt ein reiner Typ: die Regeln kommen als Wert herein.
Nebenbei zieht der doppelte `zeile()`-Bauer nach `teile.ts` — er stand
zweimal wortgleich da.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-06 16:52:53 +02:00
co-authored by Claude Opus 5
parent 1e4a231d1b
commit 9622da0284
19 changed files with 820 additions and 130 deletions
+28
View File
@@ -787,6 +787,34 @@ export const abweichendeAuffrischen = () =>
anfrage<{ started: number; language: string }>(
'/settings/metadata/languages/refresh', { method: 'POST' });
/** Was der Server über die Wiedergabe entscheidet — serverweit, nur Admin. */
export type Wiedergabeeinstellung = {
/** Ab wann ein Titel in „Weiterschauen" steht. 0 heisst: ab der ersten Sekunde. */
resumeAfterSeconds: number;
/** Ab welchem Anteil der Laufzeit er als gesehen gilt, 0 bis 1. */
watchedFraction: number;
/** „qualitaet", „ausgewogen" oder „tempo". */
encoder: string;
};
export const wiedergabeEinstellung = () =>
anfrage<Wiedergabeeinstellung>('/settings/playback');
/** Setzt, was angegeben ist — ausgelassene Felder bleiben.
*
* Die Antwort nennt, wie viele vorhandene Einträge die neue Schwelle
* umgeworfen hat. Ohne diese Zahl änderte sich die Startseite scheinbar von
* selbst. */
export const wiedergabeSetzen = (werte: Partial<Wiedergabeeinstellung>) =>
anfrage<Wiedergabeeinstellung & { reevaluated: number }>('/settings/playback', {
method: 'PUT',
body: JSON.stringify({
resumeAfterSeconds: werte.resumeAfterSeconds ?? null,
watchedFraction: werte.watchedFraction ?? null,
encoder: werte.encoder ?? null,
}),
});
export type Speicherort = {
name: string; variable: string; pfad: string;
belegt: number; frei: number | null; bilder: number | null;
+5 -1
View File
@@ -4,6 +4,7 @@ import { dashboard, serverbereich, speicherbereich } from './uebersicht';
import { bibliotheksbereich, vermisstenbereich } from './mediatheken';
import { pruefbereich } from './pruefung';
import { ohneZuordnungBereich, unzugeordnetbereich } from './zuordnung';
import { wiedergabebereich } from './wiedergabe';
import { sprachbereich } from './metadaten';
import { benutzerbereich } from './benutzer';
import { protokollbereich } from './protokolle';
@@ -11,13 +12,14 @@ import { protokollbereich } from './protokolle';
/** Welcher Teil der Verwaltung offen ist. */
export type Bereich =
| 'uebersicht' | 'mediatheken' | 'pruefung' | 'zuordnung'
| 'metadaten' | 'benutzer' | 'protokolle';
| 'wiedergabe' | 'metadaten' | 'benutzer' | 'protokolle';
const BEREICHE: { wert: Bereich; text: string; bild: IconName }[] = [
{ wert: 'uebersicht', text: 'Übersicht', bild: 'verwaltung' },
{ wert: 'mediatheken', text: 'Mediatheken', bild: 'filme' },
{ wert: 'pruefung', text: 'Zu prüfen', bild: 'hakenKreis' },
{ wert: 'zuordnung', text: 'Zuordnung', bild: 'suche' },
{ wert: 'wiedergabe', text: 'Wiedergabe', bild: 'stufen' },
{ wert: 'metadaten', text: 'Metadaten', bild: 'stift' },
{ wert: 'benutzer', text: 'Benutzer', bild: 'person' },
{ wert: 'protokolle', text: 'Protokolle', bild: 'protokoll' },
@@ -89,6 +91,8 @@ export function verwaltung(
}
case 'zuordnung':
return [ohneZuordnungBereich(), unzugeordnetbereich()];
case 'wiedergabe':
return [wiedergabebereich()];
case 'metadaten':
return [sprachbereich()];
case 'benutzer':
+2 -33
View File
@@ -2,11 +2,10 @@ import {
sprachen, spracheSetzen, uebersetzungenHolen, meineSprache, metadatenEinstellung,
metadatenSetzen, sprachverteilung, abweichendeAuffrischen,
} from '../../api';
import { esc, knopf, fehlerzeile, meldung } from '../../util';
import { knopf, fehlerzeile, meldung } from '../../util';
import { dialog } from '../../dialog';
import { icon } from '../../icons';
import { abschnitt } from './teile';
import { abschnitt, zeile } from './teile';
/** Sprache und Herkunft der Metadaten. */
@@ -184,35 +183,5 @@ export function sprachbereich(): HTMLElement {
return bereich;
function zeile(
name: string, wert: string, hinweis: string | null, bearbeiten?: () => void,
): HTMLElement {
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(name)}</strong><span class="winzig">${esc(wert)}</span>`;
oben.appendChild(text);
if (bearbeiten) {
const b = knopf('Ändern', 'btn');
b.prepend(icon('stift'));
b.onclick = bearbeiten;
oben.appendChild(b);
}
d.appendChild(oben);
if (hinweis) {
const h = document.createElement('div');
h.className = 'hinweis';
h.textContent = hinweis;
d.appendChild(h);
}
return d;
}
}
+40
View File
@@ -1,3 +1,6 @@
import { esc, knopf } from '../../util';
import { icon } from '../../icons';
/** Gemeinsame Bausteine der Verwaltung. */
@@ -18,3 +21,40 @@ export function abschnitt(titel: string, knoepfe: HTMLElement[] = []): HTMLEleme
return kopf;
}
/** Eine Zeile „Name — Wert" mit einem Ändern-Knopf.
*
* **Eine Stelle, nicht zwei.** Diesen Bauer gab es wortgleich zweimal — in
* der Sprach- und in der Serverauskunft. Zwei Kopien derselben Zeile sind
* genau dann ein Problem, wenn eine davon gepflegt wird.
*/
export function zeile(
name: string, wert: string, hinweis: string | null, bearbeiten?: () => void,
): HTMLElement {
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(name)}</strong><span class="winzig">${esc(wert)}</span>`;
oben.appendChild(text);
if (bearbeiten) {
const b = knopf('Ändern', 'btn');
b.prepend(icon('stift'));
b.onclick = bearbeiten;
oben.appendChild(b);
}
d.appendChild(oben);
if (hinweis) {
const h = document.createElement('div');
h.className = 'hinweis';
h.textContent = hinweis;
d.appendChild(h);
}
return d;
}
+1 -32
View File
@@ -5,9 +5,8 @@ import {
} from '../../api';
import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../../util';
import { dialog } from '../../dialog';
import { icon } from '../../icons';
import { abschnitt } from './teile';
import { abschnitt, zeile } from './teile';
/** Übersicht: was läuft, was der Server meldet, was er belegt. */
@@ -218,36 +217,6 @@ export function serverbereich(geaendert: () => void): HTMLElement {
}
})();
function zeile(
name: string, wert: string, hinweis: string | null, bearbeiten?: () => void,
): HTMLElement {
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(name)}</strong><span class="winzig">${esc(wert)}</span>`;
oben.appendChild(text);
if (bearbeiten) {
const b = knopf('Ändern', 'btn');
b.prepend(icon('stift'));
b.onclick = bearbeiten;
oben.appendChild(b);
}
d.appendChild(oben);
if (hinweis) {
const h = document.createElement('div');
h.className = 'hinweis';
h.textContent = hinweis;
d.appendChild(h);
}
return d;
}
liste.innerHTML = '<div class="spinner"></div>';
return bereich;
+157
View File
@@ -0,0 +1,157 @@
import { wiedergabeEinstellung, wiedergabeSetzen } from '../../api';
import { fehlerzeile, meldung, hinweisZeigen } from '../../util';
import { dialog } from '../../dialog';
import { abschnitt, zeile } from './teile';
/** Wie der Server mit Fortschritt umgeht und wie er umkodiert. */
/** Die Stufen fürs Weiterschauen — lieber eine Auswahl als ein Zahlenfeld.
*
* **„87 Sekunden" will niemand eintippen.** Eine freie Zahl sieht nach
* Genauigkeit aus, die es hier nicht gibt: ob 90 oder 100 Sekunden, merkt
* kein Mensch. Fünf Stufen beantworten die Frage, um die es geht — zählt
* Hineinschauen oder nicht.
*/
const SCHWELLEN: { wert: string; text: string }[] = [
{ wert: '0', text: 'Sofort — jede angefangene Sekunde' },
{ wert: '30', text: 'Nach 30 Sekunden' },
{ wert: '90', text: 'Nach 1½ Minuten' },
{ wert: '180', text: 'Nach 3 Minuten' },
{ wert: '300', text: 'Nach 5 Minuten' },
];
const ANTEILE: { wert: string; text: string }[] = [
{ wert: '0.85', text: '85 % — früher abgehakt' },
{ wert: '0.9', text: '90 %' },
{ wert: '0.95', text: '95 % — erst im Abspann' },
];
const STUFEN: { wert: string; text: string }[] = [
{ wert: 'qualitaet', text: 'Qualität — schöneres Bild, rechnet länger' },
{ wert: 'ausgewogen', text: 'Ausgewogen' },
{ wert: 'tempo', text: 'Tempo — startet schneller, kostet Bild' },
];
export function wiedergabebereich(): HTMLElement {
const bereich = document.createElement('section');
bereich.appendChild(abschnitt('Wiedergabe'));
const liste = document.createElement('div');
liste.className = 'einstellungen';
liste.innerHTML = '<div class="spinner"></div>';
bereich.appendChild(liste);
void laden();
return bereich;
async function laden(): Promise<void> {
let stand;
try {
stand = await wiedergabeEinstellung();
} catch (fehler) {
liste.innerHTML = '';
liste.appendChild(fehlerzeile(meldung(fehler)));
return;
}
liste.innerHTML = '';
liste.appendChild(zeile(
'Weiterschauen ab',
textVon(SCHWELLEN, String(stand.resumeAfterSeconds)),
'Wer kürzer hineinschaut, findet den Titel nicht in „Fortsetzen" wieder. '
+ 'Die Stelle wird trotzdem gemerkt — wer ihn später öffnet, macht dort weiter.',
() => fragen({
titel: 'Weiterschauen ab',
text: 'Ohne Untergrenze genügt eine Sekunde: ein Blick in den Aufmacher, '
+ 'und der Titel steht in „Fortsetzen". Die Umstellung wirkt rückwirkend — '
+ 'was schon in der Reihe steht, wird neu bewertet.',
name: 'schwelle',
beschriftung: 'Erst ab',
auswahl: SCHWELLEN,
wert: naechsteStufe(SCHWELLEN, stand.resumeAfterSeconds),
setzen: v => wiedergabeSetzen({ resumeAfterSeconds: Number(v) }),
})));
liste.appendChild(zeile(
'Gesehen ab',
`${Math.round(stand.watchedFraction * 100)} %`,
'Niemand schaut bis zur letzten Sekunde des Abspanns. Ab diesem Anteil '
+ 'bekommt ein Titel sein Häkchen und verlässt „Fortsetzen".',
() => fragen({
titel: 'Gesehen ab',
text: 'Höher heisst: der Titel bleibt länger in „Fortsetzen" stehen. '
+ 'Auch das wirkt rückwirkend.',
name: 'anteil',
beschriftung: 'Anteil der Laufzeit',
auswahl: ANTEILE,
wert: naechsteStufe(ANTEILE, stand.watchedFraction),
setzen: v => wiedergabeSetzen({ watchedFraction: Number(v) }),
})));
liste.appendChild(zeile(
'Umkodieren',
textVon(STUFEN, stand.encoder),
'Gilt nur, wenn wirklich gerechnet werden muss — wird eine Datei bloss '
+ 'umgepackt, bleibt das Bild unberührt. Vorsicht bei „Qualität": was in '
+ 'Echtzeit laufen muss, darf nicht langsamer rechnen als es abspielt. '
+ 'Auf einer schwachen Maschine kann das Bild dadurch stocken.',
() => fragen({
titel: 'Umkodieren',
text: 'Die Stufe wirkt auf die nächste Wiedergabe, nicht auf eine laufende.',
name: 'stufe',
beschriftung: 'Abwägung',
auswahl: STUFEN,
wert: stand.encoder,
setzen: v => wiedergabeSetzen({ encoder: v }),
})));
}
/** Ein Auswahldialog, der danach meldet, was er umgeworfen hat. */
function fragen(o: {
titel: string; text: string; name: string; beschriftung: string;
auswahl: { wert: string; text: string }[]; wert: string;
setzen: (wert: string) => Promise<{ reevaluated: number }>;
}): void {
dialog({
titel: o.titel,
text: o.text,
felder: [{ name: o.name, beschriftung: o.beschriftung, auswahl: o.auswahl, wert: o.wert }],
bestaetigen: 'Übernehmen',
tun: async wert => {
const ergebnis = await o.setzen(wert(o.name));
// **Die Zahl gehört in die Rückmeldung.** Eine neue Schwelle räumt
// „Fortsetzen" auf; ohne diesen Satz änderte sich die Startseite
// scheinbar von selbst.
hinweisZeigen(ergebnis.reevaluated === 0
? 'Übernommen.'
: ergebnis.reevaluated === 1
? 'Übernommen — ein Eintrag wurde neu bewertet.'
: `Übernommen — ${ergebnis.reevaluated} Einträge wurden neu bewertet.`);
await laden();
},
});
}
}
/** Der Text zur gespeicherten Zahl — oder die Zahl selbst, wenn sie nicht passt. */
function textVon(stufen: { wert: string; text: string }[], wert: string): string {
return stufen.find(s => s.wert === wert)?.text ?? wert;
}
/** Welche Stufe ein gespeicherter Wert am ehesten meint.
*
* In der Datenbank steht eine Zahl, keine Stufe — sie kann aus einer älteren
* Fassung stammen oder von Hand gesetzt sein. Dann soll der Dialog nicht leer
* aufgehen, sondern bei der nächstgelegenen stehen.
*/
function naechsteStufe(stufen: { wert: string; text: string }[], wert: number): string {
let beste = stufen[0]!.wert;
let abstand = Infinity;
for (const s of stufen) {
const d = Math.abs(Number(s.wert) - wert);
if (d < abstand) { abstand = d; beste = s.wert; }
}
return beste;
}