Neue Funde erst prüfen, dann veröffentlichen

Je Mediathek einstellbar, Vorgabe aus: wer eine Mediathek anlegt, will sie
sehen. Angeschaltet warten neue Funde unter "Zu prüfen", bis jemand sie
freigibt — oder ihnen einen Termin gibt.

Ein Zeitpunkt, kein Zustandsfeld. PublishAt null heisst wartet, in der
Vergangenheit heisst veröffentlicht, in der Zukunft heisst geplant. Die
Sichtbarkeit wird bei der Abfrage gerechnet statt von einem Dienst
umgeschaltet; damit stimmt sie nach einem Neustart, nach einer
Zeitumstellung und auch dann, wenn der Server zum geplanten Zeitpunkt gar
nicht lief. Ein Ticker hätte in jedem dieser Fälle das Falsche getan.

Das eigentliche Risiko waren die zwanzig Abfragestellen in vier Dateien.
Schriebe jede ihren eigenen Filter, reichte eine vergessene — und
auffallen würde es nicht beim Programmieren, sondern wenn jemand im
Haushalt einen Titel sieht, den er nicht sehen sollte. Deshalb ein
globaler Abfragefilter in FlaroContext, der sich nicht vergessen lässt,
und eine Middleware, die je Anfrage setzt, wer Unveröffentlichtes sehen
darf. Vorgabe am Kontext ist "alles sehen", damit Scanner und Metadaten
ohne Benutzer arbeiten können; für jede HTTP-Anfrage entscheidet die
Middleware.

Der Test zählt die Inhaltsrouten auf und prüft jede einzeln auf die
Abwesenheit eines wartenden Titels. Eine neue Route, die den Filter
umgeht, fällt dort durch.

Die Migration trägt für alle vorhandenen Titel das Hinzufügedatum als
Veröffentlichung nach. Ohne diese zwei Zeilen stünde jeder auf null — und
der gesamte Bestand wäre für alle ausser Administratoren verschwunden,
sofort beim Start im Container und ohne Meldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 13:03:24 +02:00
co-authored by Claude Opus 5
parent eb63271e92
commit 632d3efa35
18 changed files with 1726 additions and 11 deletions
+33 -2
View File
@@ -83,6 +83,9 @@ export type Bibliothek = {
path: string;
/** `null` heisst „nicht gezählt", nicht „leer". */
itemCount: number | null;
/** Ob neue Funde erst geprüft werden. */
reviewNewItems: boolean;
};
export type Seite<T> = { items: T[]; total: number };
export type Lieferung = {
@@ -516,6 +519,31 @@ export const bildHochladen = async (
{ method: 'POST', body: inhalt });
};
export type Pruefeintrag = {
kind: 'movie' | 'episode';
id: string; title: string; year: number | null;
posterImage: string | null;
/** `null` heisst: wartet. Ein Zeitpunkt in der Zukunft heisst: geplant. */
publishAt: string | null;
path: string | null;
matched: boolean;
};
export const zuPruefen = () => anfrage<Pruefeintrag[]>('/review');
/** Ohne Kennungen: alles, was noch nicht sichtbar ist. Ohne Zeitpunkt: jetzt. */
export const veroeffentlichen = (ids?: string[], publishAt?: string) =>
anfrage<{ published: number }>('/review/publish',
{ method: 'POST', body: JSON.stringify({ ids: ids ?? null, publishAt: publishAt ?? null }) });
export const zurueckziehen = (ids: string[]) =>
anfrage<{ withheld: number }>('/review/withhold',
{ method: 'POST', body: JSON.stringify({ ids, publishAt: null }) });
export const pruefungSetzen = (bibliothek: string, an: boolean) =>
anfrage<void>(`/libraries/${bibliothek}/review`,
{ method: 'PUT', body: JSON.stringify({ reviewNewItems: an }) });
export type Protokolltag = { date: string; bytes: number };
/** Welche Tage protokolliert sind, neueste zuerst. */
@@ -560,9 +588,12 @@ export const bibliothekUmbenennen = (id: string, name: string) =>
export const bibliothekLoeschen = (id: string) =>
anfrage<void>(`/libraries/${id}`, { method: 'DELETE' });
export const bibliothekAnlegen = (name: string, art: string, pfad: string) =>
export const bibliothekAnlegen = (
name: string, art: string, pfad: string, pruefen = false,
) =>
anfrage<Bibliothek>('/libraries', {
method: 'POST', body: JSON.stringify({ name, kind: art, path: pfad }),
method: 'POST',
body: JSON.stringify({ name, kind: art, path: pfad, reviewNewItems: pruefen }),
});
export const scanStarten = (id: string) =>
+9
View File
@@ -929,3 +929,12 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m
background: var(--accent); color: #fff;
}
.bildwahl-marke svg { width: 14px; height: 14px; }
/* Prüfliste — mit Miniplakat, damit erkennbar ist, worum es geht. */
.pruefzeile .pruef-bild {
width: 40px; height: 60px; flex: none;
border-radius: 4px; overflow: hidden;
background: var(--placeholder);
}
.pruefzeile .pruef-bild img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pruefzeile .knopfreihe { align-items: center; }
+154 -4
View File
@@ -3,7 +3,8 @@ import {
scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen,
vermisste, vermisstesEntfernen, type VermissteDatei,
protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen,
meineSprache,
meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen,
type Pruefeintrag,
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
servernameSetzen, systemauskunft,
laufendeWiedergaben, serverzustand, bildAdresse,
@@ -27,7 +28,7 @@ export function verwaltung(
signal: AbortSignal = new AbortController().signal,
): HTMLElement {
const wurzel = document.createElement('div');
wurzel.append(dashboard(signal), vermisstenbereich(geaendert),
wurzel.append(dashboard(signal), pruefbereich(geaendert), vermisstenbereich(geaendert),
bibliotheksbereich(geaendert, signal), benutzerbereich(), serverbereich(geaendert),
protokollbereich());
return wurzel;
@@ -161,6 +162,134 @@ function laufzeit(sekunden: number): string {
return `${minuten} Min.`;
}
/* ---------- Zu prüfen ---------- */
/** Neue Funde, die noch nicht erscheinen — und geplante mit ihrem Termin.
*
* **Erscheint nur, wenn etwas wartet.** Ein leerer Abschnitt wäre Rauschen.
*
* Geplante bleiben in der Liste. Ein Titel mit Termin ist nicht erledigt, er
* hat nur schon einen — ihn verschwinden zu lassen hiesse, dass man ihn nur
* wiederfindet, wenn man sich den Termin gemerkt hat. */
function pruefbereich(geaendert: () => void): HTMLElement {
const bereich = document.createElement('section');
bereich.className = 'hidden';
const liste = document.createElement('div');
liste.className = 'einstellungen';
const alle = knopf('Alle veröffentlichen', 'btn btn-primary');
alle.onclick = async () => {
alle.disabled = true;
try { await veroeffentlichen(); await laden(); geaendert(); }
catch (fehler) { liste.prepend(fehlerzeile((fehler as Error).message)); alle.disabled = false; }
};
bereich.append(abschnitt('Zu prüfen', [alle]), liste);
void laden();
async function laden(): Promise<void> {
let wartend: Pruefeintrag[];
try { wartend = await zuPruefen(); } catch { bereich.classList.add('hidden'); return; }
if (wartend.length === 0) { bereich.classList.add('hidden'); return; }
bereich.classList.remove('hidden');
alle.disabled = false;
liste.innerHTML = '';
wartend.forEach(e => liste.appendChild(zeileFuer(e)));
}
function zeileFuer(e: Pruefeintrag): HTMLElement {
const d = document.createElement('div');
d.className = 'einstellung pruefzeile';
const bild = document.createElement('div');
bild.className = 'pruef-bild';
const adresse = bildAdresse(e.posterImage);
if (adresse) {
const img = document.createElement('img');
img.src = adresse; img.alt = ''; img.loading = 'lazy';
bild.appendChild(img);
}
const text = document.createElement('div');
text.className = 'zeile';
text.style.flex = '1';
const geplant = e.publishAt ? new Date(e.publishAt) : null;
const stand = geplant
? `Geplant für ${geplant.toLocaleString('de-CH', { dateStyle: 'medium', timeStyle: 'short' })}`
: e.matched ? 'Wartet auf Freigabe'
// Ohne Zuordnung fehlen Titel und Bilder — dann ist nicht die Freigabe
// das Nächste, sondern die Zuordnung.
: 'Wartet — und ist noch keinem Titel zugeordnet';
text.innerHTML = `<strong>${esc(e.title)}${e.year ? ` (${e.year})` : ''}</strong>`
+ `<span class="winzig">${esc(stand)}</span>`;
const frei = knopf('Veröffentlichen', 'btn');
frei.onclick = async () => {
frei.disabled = true;
try { await veroeffentlichen([e.id]); await laden(); geaendert(); }
catch (fehler) { d.appendChild(fehlerzeile((fehler as Error).message)); frei.disabled = false; }
};
const planen = knopf('Planen', 'btn');
planen.onclick = () => dialog({
titel: `„${e.title}" planen`,
text: 'Ab diesem Zeitpunkt erscheint der Titel für alle. '
+ 'Es läuft dafür nichts im Hintergrund — die Sichtbarkeit wird bei '
+ 'jeder Abfrage gerechnet und stimmt deshalb auch, wenn der Server '
+ 'gerade nicht lief.',
felder: [{
name: 'wann', beschriftung: 'Zeitpunkt', art: 'text',
wert: inEingabeform(geplant ?? new Date(Date.now() + 86400000)),
hinweis: 'Form: 2026-12-24 20:15',
}],
bestaetigen: 'Planen',
tun: async wert => {
const wann = new Date(wert('wann').replace(' ', 'T'));
if (Number.isNaN(wann.getTime())) throw new Error('Das ist kein Zeitpunkt.');
await veroeffentlichen([e.id], wann.toISOString());
await laden();
},
});
const oben = document.createElement('div');
oben.className = 'knopfreihe';
oben.append(bild, text, frei, planen);
if (geplant) {
const zurueck = knopf('Zurückziehen', 'btn');
zurueck.onclick = async () => {
zurueck.disabled = true;
try { await zurueckziehen([e.id]); await laden(); }
catch (fehler) { d.appendChild(fehlerzeile((fehler as Error).message)); zurueck.disabled = false; }
};
oben.appendChild(zurueck);
}
d.appendChild(oben);
if (e.path) {
const pfad = document.createElement('div');
pfad.className = 'winzig';
pfad.textContent = e.path;
d.appendChild(pfad);
}
return d;
}
/** „2026-12-24 20:15" — ohne Zeitzone, weil man sie eintippt, nicht liest. */
function inEingabeform(d: Date): string {
const z = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} `
+ `${z(d.getHours())}:${z(d.getMinutes())}`;
}
return bereich;
}
/* ---------- Protokolle ---------- */
/** Eine Datei je Tag, lesbar ohne Verbindung auf das NAS.
@@ -343,10 +472,17 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
hinweis: 'Der Pfad im Container, nicht der auf dem NAS — '
+ 'also das, was in der Compose-Datei hinter dem Doppelpunkt steht.',
},
{
name: 'pruefen', beschriftung: 'Neue Funde erst prüfen', schalter: true,
hinweis: 'Aus heisst: gefundene Titel erscheinen sofort. An heisst: sie '
+ 'warten unter „Zu prüfen", bis du sie freigibst — Zeit, die Zuordnung '
+ 'und die Bilder zu kontrollieren oder einen Termin zu setzen.',
},
],
bestaetigen: 'Anlegen',
tun: async wert => {
await bibliothekAnlegen(wert('name'), wert('art'), wert('pfad'));
await bibliothekAnlegen(wert('name'), wert('art'), wert('pfad'),
wert('pruefen') === 'true');
await laden();
geaendert();
},
@@ -471,9 +607,23 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
}
}
// Die Prüfung lässt sich je Mediathek umstellen — was beim Anlegen
// entschieden wurde, soll kein Urteil fürs Leben sein.
const pruefung = knopf(b.reviewNewItems ? 'Prüfung an' : 'Prüfung aus', 'btn');
pruefung.onclick = async () => {
pruefung.disabled = true;
try {
await pruefungSetzen(b.id, !b.reviewNewItems);
await laden();
} catch (fehler) {
d.appendChild(fehlerzeile((fehler as Error).message));
pruefung.disabled = false;
}
};
const knoepfe = document.createElement('div');
knoepfe.className = 'knopfreihe';
knoepfe.append(los, auffrischen, umbenennen, entfernen);
knoepfe.append(los, auffrischen, pruefung, umbenennen, entfernen);
d.append(knoepfe, stand);
void verfolgen(false);
return d;