Files
Florian Wilde b560ee9c51
CI / pruefen (pull_request) Canceled after 0s
Errungenschaften: Abzeichen fuer erreichte Meilensteine
Zwanzig feste Abzeichen plus dynamische fuer vollstaendig gesehene Filmreihen
und abgeschlossene Universen. Sie tauchen auf, wenn etwas erreicht ist, und
bleiben — auch wenn die Bedingung spaeter wegfaellt. Eine zurueckgenommene
Sichtung soll kein Abzeichen kosten.

Alle Regeln arbeiten auf *einem* Datensatz, der einmal geholt wird. Sonst
stellte jede Regel ihre eigene Abfrage, und aus zwanzig Abzeichen wuerden
zwanzig Abfragen bei jedem Abhaken. Ausgewertet wird nach Sichtungsaktionen
und einmal taeglich; der erste Lauf eines Kontos ist stumm, weil eine
gewachsene Bibliothek sonst zwei Dutzend Meldungen auf einmal ausloest.

Fuer Filmreihen braucht es keine gepflegte Liste: TMDB gruppiert sie selbst,
und tmdbCollectionId liegt beim Import schon am Titel. Eigennamen wie
"Spider Bro" kommen aus einer kleinen Datendatei obendrauf; fehlt ein Eintrag,
greift der generische Name.

Die kuratierten Eintraege tragen jeweils ein Stueck des erwarteten
TMDB-Namens. Ich habe die Collection-Kennungen aus dem Gedaechtnis
geschrieben und kann sie hier nicht gegen TMDB pruefen — passt der Name
nicht, wird der Eintrag ignoriert und der generische greift. Eine falsch
abgeschriebene Kennung bleibt damit folgenlos statt jemandem das falsche
Abzeichen zu geben.

Die Uhrzeit-Regel liest die Stunde ueber formatToParts statt ueber die
formatierte Zeichenkette: auf Deutsch liefert Intl fuer eine einzelne Stunde
"03 Uhr", und daraus wird beim Umwandeln NaN. Ein Test hat das aufgedeckt.

Beim ersten Oeffnen der Uebersicht wird einmal ausgewertet — sonst bliebe sie
bei einem Bestandskonto leer, bis nachts der Hintergrundlauf kommt.

Zehn Tests, Anleitung mit Screenshot. Symbole als eigene SVG auf einem
gemeinsamen Raster; erreicht und offen unterscheiden sich nicht nur durch
Farbe, sondern auch im Text.

Refs #15
2026-09-02 12:09:51 +02:00

236 lines
9.1 KiB
JavaScript

import { chromium } from "playwright";
import fs from "node:fs";
import { createServer } from "node:http";
const OUT = "docs/screenshots";
const BASE = "http://localhost:3011";
fs.mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch();
const errs = [];
async function session(width, height, theme) {
const ctx = await browser.newContext({
viewport: { width, height },
deviceScaleFactor: 2,
colorScheme: theme === "light" ? "light" : "dark",
locale: "de-DE",
timezoneId: "Europe/Berlin",
});
await ctx.addInitScript((mode) => localStorage.setItem("flimmer-theme", mode), theme);
// Der Entwicklungs-Indikator von Next hat in einer Anleitung nichts verloren.
await ctx.addStyleTag?.({ content: "nextjs-portal { display: none !important; }" });
const page = await ctx.newPage();
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }).catch(() => {});
page.on("pageerror", (e) => errs.push(String(e).slice(0, 160)));
return { ctx, page };
}
async function login(page) {
await page.goto(`${BASE}/anmelden`);
await page.fill("#email", "demo@flimmer.local");
await page.fill("#password", "flimmer-demo-2026");
await Promise.all([page.waitForURL(`${BASE}/`), page.click("button[type=submit]")]);
}
/** Screenshot eines einzelnen Abschnitts statt der ganzen Seite. */
async function ausschnitt(page, name, ueberschrift) {
// Einblendungen wegklicken — sie legen sich sonst über den Inhalt.
const schliessen = page.locator("[aria-live] button");
for (let i = await schliessen.count(); i > 0; i -= 1) {
await schliessen.first().click().catch(() => {});
await page.waitForTimeout(150);
}
await page.waitForTimeout(400);
// Die Kopfleiste klebt oben und legt sich sonst über die Überschrift des
// Ausschnitts. Fürs Bild kurz aufheben.
await page.addStyleTag({ content: "header { position: static !important; }" }).catch(() => {});
await page.waitForTimeout(200);
const bereich = page.locator("section", { hasText: ueberschrift }).first();
await bereich.screenshot({ path: `${OUT}/${name}.png` });
console.log(" ", name);
}
/**
* Ein Jellyfin, das gerade genug kann: anmelden, Systeminfo, Bestand.
* Die TMDB-Kennungen zeigen auf Titel aus dem Seed, damit der Bericht echte
* Zuordnungen zeigt. Alles auf ungesehen — der Screenshot soll den Bestand
* zeigen, nicht die Sichtungen der Beispieldaten verbiegen.
*/
async function starteJellyfinNachbau() {
const items = [
{ Id: "jf-1", Name: "Die Stille zwischen den Bildern", Type: "Movie",
ProviderIds: { Tmdb: "9100013" }, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } },
{ Id: "jf-2", Name: "Die Stille zwischen den Bildern II", Type: "Movie",
ProviderIds: { Tmdb: "9100014" }, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } },
{ Id: "jf-3", Name: "Die Vermessung des Feierabends", Type: "Movie",
ProviderIds: { Tmdb: "9100017" }, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } },
{ Id: "jf-4", Name: "Urlaubsvideo 2019", Type: "Movie",
ProviderIds: {}, UserData: { Played: false, PlayCount: 0, LastPlayedDate: null } },
];
const server = createServer((req, res) => {
const url = new URL(req.url, "http://x");
const json = (body) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
if (url.pathname === "/Users/AuthenticateByName") {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () =>
json({ AccessToken: "screenshot-token", User: { Id: "jf-user", Name: "florian" } }),
);
return;
}
if (url.pathname === "/System/Info") return json({ ServerName: "Heimserver", Version: "10.10.3" });
if (url.pathname === "/Items") return json({ Items: items, TotalRecordCount: items.length });
res.writeHead(404);
res.end();
});
await new Promise((fertig) => server.listen(0, "127.0.0.1", fertig));
const port = server.address().port;
return {
url: `http://127.0.0.1:${port}`,
close: () => new Promise((fertig) => server.close(fertig)),
};
}
async function shot(page, name, url, opts = {}) {
await page.goto(`${BASE}${url}`, { waitUntil: "networkidle" });
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }).catch(() => {});
await page.waitForTimeout(opts.wait ?? 900);
if (opts.before) await opts.before(page);
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: Boolean(opts.full) });
console.log(" ", name);
}
// --- Anmeldung (ohne Sitzung) ---
{
const { ctx, page } = await session(1280, 800, "dark");
await shot(page, "01-anmeldung", "/anmelden");
await ctx.close();
}
// --- Angemeldet, dunkel ---
{
const { ctx, page } = await session(1400, 900, "dark");
await login(page);
await shot(page, "02-uebersicht", "/", { full: true });
await shot(page, "03-suche", "/suche?q=sternenpfad");
await shot(page, "05-bibliothek", "/bibliothek");
await shot(page, "09-kalender", "/kalender");
await shot(page, "10-statistik", "/statistik", { full: true });
await shot(page, "11-einstellungen", "/einstellungen");
await shot(page, "12-benachrichtigungen", "/einstellungen/benachrichtigungen");
await shot(page, "13-api", "/einstellungen/api");
await shot(page, "14-daten", "/einstellungen/daten");
await shot(page, "15-verwaltung", "/verwaltung", { full: true });
await shot(page, "16-papierkorb", "/papierkorb");
// Detailseite und Universum brauchen IDs aus der Bibliothek
await page.goto(`${BASE}/bibliothek?q=nordlicht`, { waitUntil: "networkidle" });
const titleHref = await page.locator("a[href^='/titel/']").first().getAttribute("href");
if (titleHref) await shot(page, "04-serie", titleHref, { full: true });
await page.goto(`${BASE}/universen`, { waitUntil: "networkidle" });
await shot(page, "06-universen", "/universen");
const uniHref = await page.locator("a[href^='/universen/']").first().getAttribute("href");
if (uniHref) await shot(page, "07-universum", uniHref, { full: true });
await ctx.close();
}
// --- Zugang: Passkeys und Medienserver ---
//
// Beide brauchen Gegenstellen: einen Authenticator und einen Jellyfin-Server.
// Statt echte Geräte vorauszusetzen, stellt das Skript sie selbst bereit —
// der virtuelle Authenticator kommt vom Browser, der Medienserver ist ein
// paar Zeilen HTTP hier drin. Beide Spuren werden am Ende wieder entfernt.
{
const jellyfin = await starteJellyfinNachbau();
const { ctx, page } = await session(1400, 1000, "dark");
const cdp = await ctx.newCDPSession(page);
await cdp.send("WebAuthn.enable");
await cdp.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "internal",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
});
await login(page);
await page.goto(`${BASE}/einstellungen`, { waitUntil: "networkidle" });
// Passkey anlegen, damit die Liste nicht leer ist.
await page.fill("input[aria-label='Name für den neuen Passkey']", "Mein Notebook");
await page.getByRole("button", { name: "Passkey anlegen" }).click();
await page.waitForTimeout(2500);
await ausschnitt(page, "21-passkeys", "Passkeys");
// Jellyfin verbinden und einmal abgleichen.
const medien = page.locator("section", { hasText: "Medienserver" });
await page.fill("input[placeholder='http://jellyfin.fritz.box:8096']", jellyfin.url);
await medien.locator("input").nth(1).fill("florian");
await medien.locator("input").nth(2).fill("geheim");
await page.getByRole("button", { name: "Verbinden" }).click();
await page.waitForTimeout(2500);
await page.getByRole("button", { name: "Jetzt abgleichen" }).click();
await page.waitForTimeout(3500);
await ausschnitt(page, "22-medienserver", "Medienserver");
// Spuren beseitigen: erst die Verbindung, dann den Passkey.
await page.getByRole("button", { name: "Entfernen" }).first().click();
await page.waitForTimeout(1500);
await page.reload({ waitUntil: "networkidle" });
const passkeyWeg = page.locator("section", { hasText: "Passkeys" }).getByRole("button", { name: "Entfernen" });
if (await passkeyWeg.count()) {
await passkeyWeg.first().click();
await page.waitForTimeout(1500);
}
await ctx.close();
await jellyfin.close();
}
// --- Errungenschaften ---
{
const { ctx, page } = await session(1400, 1200, "dark");
await login(page);
await page.goto(`${BASE}/statistik`, { waitUntil: "networkidle" });
await page.waitForTimeout(900);
await ausschnitt(page, "23-errungenschaften", "Errungenschaften");
await ctx.close();
}
// --- Hell ---
{
const { ctx, page } = await session(1400, 900, "light");
await login(page);
await shot(page, "08-hell", "/bibliothek");
await ctx.close();
}
// --- Mobil ---
{
const { ctx, page } = await session(430, 900, "dark");
await login(page);
await shot(page, "17-mobil-uebersicht", "/");
await shot(page, "18-mobil-serie", "/bibliothek");
await ctx.close();
}
console.log("Fehler:", errs.length ? [...new Set(errs)].slice(0, 5) : "keine");
await browser.close();