feat: Settings → Diagnostics, a consented log level and the tab's own log

A user can raise their diagnostics level to Részletes (DEBUG) or Teljes (TRACE) for 24 hours from a
new Settings tab, after a consent box that says what is recorded, that the server keeps it and for
how long (the instance's own retention), and that it lapses. Alap is the base and needs no consent.

- users.diagnostics_level/_until (migration 0087), written only by PUT /api/me/diagnostics, which
  computes the expiry itself; the demo account cannot raise it. /api/me reports the level in force.
- The server logs an elevated user's requests at that level and stores every line of them
  (logs.LevelGate on the stdout handler and the sink; the root is lowered only while an elevation
  is active). Teljes adds the redacted query string to the request line.
- POST /api/diagnostics/events stores the browser's log as source=web rows: account from the
  session, body capped while streaming, events/attrs capped, re-redacted, events under the
  consented level dropped, timestamps clamped, 30 batches a minute, 409 once the level lapsed.
- lib/diagnostics.ts applies the level to the tab and streams only what happens after consent, in
  batches, never its own request lines, with a keepalive flush on pagehide; stops on lapse (its
  own timer), on withdrawal and on 409.
- Detail hooks at DEBUG: which control was clicked (test id, icon, app copy in English — never user
  text, input values or a link's query), visibility/bfcache/resize, filter changes
  (lib/filterLog.ts; typed search only at TRACE), player events (lib/mediaLog.ts; media errors are
  warnings at every level). Offline/online at the base level.
- "Saját napló" viewer: live, newest first, level groups with counts, search, row details, copy
  the filtered events as JSON lines, clear.
- Privacy policy and Terms exist in Hungarian and English (a contract test holds both languages to
  the same sections); the policy now describes the server logs and diagnostic logging, self-service
  deletion and password sign-in.

Fixed on the way:
- A sync auth dependency ran logs.set_user on a copied context, so the account never reached a
  handler's own log lines (measured); a per-request mutable scope carries it now.
- The Settings tab rail left a phone no room (clipped labels, buttons over text): below `sm` it is
  a scrolling row above the content.
- PUT /api/me/preferences took a bare dict body; it is a Pydantic model now.
This commit is contained in:
2026-09-17 02:42:12 +02:00
parent a210992bfc
commit 5883ea509d
49 changed files with 2790 additions and 122 deletions
+4
View File
@@ -68,6 +68,9 @@ import { messagingApi } from "./api/messaging";
export type * from "./api/messaging";
import { downloadsApi } from "./api/downloads";
export type * from "./api/downloads";
import { diagnosticsApi } from "./api/diagnostics";
export type * from "./api/diagnostics";
export { DIAGNOSTICS_EVENTS_PATH } from "./api/diagnostics";
export const api = {
...accountApi,
@@ -86,4 +89,5 @@ export const api = {
...plexApi,
...messagingApi,
...downloadsApi,
...diagnosticsApi,
};
+3
View File
@@ -1,6 +1,7 @@
// The signed-in account & session: the bootstrap `me` probe, multi-account switch/list, sign-out,
// app version, and preference save. Only touches core's req.
import { req } from "./core";
import type { DiagnosticsState } from "./diagnostics";
export interface Me {
id: number;
@@ -28,6 +29,8 @@ export interface Me {
/** Admin-only: false when SMTP is incomplete, so every outbound message is silently skipped. */
email_configured: boolean;
preferences: Record<string, unknown>;
/** The consented diagnostics level in force and when it lapses (E22 S5). */
diagnostics: DiagnosticsState;
}
export interface Account {
+35
View File
@@ -0,0 +1,35 @@
// The user's diagnostics level (E22 S5) and the browser log it lets out. Only touches core's req and
// beacon. The streaming itself — what is sent, when, and when it stops — is `lib/diagnostics.ts`.
import type { LogEvent } from "../log";
import { beacon, req } from "./core";
/** Alap / Részletes / Teljes. The server decides how long a raised level lasts. */
export type DiagnosticsLevel = "INFO" | "DEBUG" | "TRACE";
export interface DiagnosticsState {
level: DiagnosticsLevel;
/** When a raised level lapses back to INFO (ISO); null at INFO. */
until: string | null;
/** How many days this instance keeps stored log lines — the consent text states it. */
retention_days: number;
}
/** Where batches go. The shipper leaves the log lines ABOUT this request out of what it sends, or
* every batch would carry the previous batch's request line forever. */
export const DIAGNOSTICS_EVENTS_PATH = "/api/diagnostics/events";
export const diagnosticsApi = {
setDiagnosticsLevel: (
level: DiagnosticsLevel,
): Promise<{ ok: true; diagnostics: DiagnosticsState }> =>
req("/api/me/diagnostics", { method: "PUT", body: JSON.stringify({ level }) }),
// Quiet: a lost batch is not the user's problem to be told about in a dialog.
sendLogEvents: (events: LogEvent[]): Promise<{ ok: true; stored: number }> =>
req(
DIAGNOSTICS_EVENTS_PATH,
{ method: "POST", body: JSON.stringify({ events }) },
{ quiet: true },
),
/** The last batch of a closing tab (keepalive, fire-and-forget). */
sendLogEventsOnUnload: (events: LogEvent[]): void => beacon(DIAGNOSTICS_EVENTS_PATH, { events }),
};
+1
View File
@@ -32,6 +32,7 @@ const SLICE_MODULES = [
["plexApi", () => import("./plex")],
["messagingApi", () => import("./messaging")],
["downloadsApi", () => import("./downloads")],
["diagnosticsApi", () => import("./diagnostics")],
] as const;
let api: Slice;
+71
View File
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
//
// E22 S5: the detail the consented levels add — filter changes and what a player went through.
// Below Részletes they cost nothing and log nothing, except a media error, which is always a warning.
import { afterEach, describe, expect, it } from "vitest";
import { logFilterChange } from "./filterLog";
import { clear, events, setLevel } from "./log";
import { logMediaElement, logYouTubeError, logYouTubeState } from "./mediaLog";
afterEach(() => {
clear();
setLevel("INFO");
});
const attrsOf = (code: string) =>
events()
.filter((e) => e.event === code)
.map((e) => e.attrs);
describe("filter changes", () => {
it("are not logged at the base level", () => {
logFilterChange("feed", { tags: [3] }, "lofi");
expect(events()).toEqual([]);
});
it("log their values at DEBUG, but only whether there is a search", () => {
setLevel("DEBUG");
logFilterChange("feed", { tags: [3], sort: "newest", q: "lofi beats" }, "lofi beats");
expect(attrsOf("filter.change")).toEqual([
{ scope: "feed", tags: [3], sort: "newest", query_set: true },
]);
expect(JSON.stringify(events())).not.toContain("lofi");
});
it("log the typed search at TRACE, and never the display titles riding in a filter", () => {
setLevel("TRACE");
logFilterChange("plex", { filters: { collections: ["9"] }, collectionTitles: { 9: "Mine" } });
logFilterChange("plex", {}, "alien");
expect(attrsOf("filter.change")).toEqual([
{ scope: "plex", filters: { collections: ["9"] } },
{ scope: "plex", query: "alien" },
]);
});
});
describe("player events", () => {
it("a media error is a warning at every level; the rest waits for DEBUG", () => {
const video = document.createElement("video");
const detach = logMediaElement(video, "plex");
video.dispatchEvent(new Event("waiting"));
video.dispatchEvent(new Event("error"));
logYouTubeState(3, 12.4);
logYouTubeError(150);
expect(events().map((e) => [e.event, e.level])).toEqual([
["player.media_error", "WARNING"],
["player.media_error", "WARNING"],
]);
clear();
setLevel("DEBUG");
video.dispatchEvent(new Event("waiting"));
logYouTubeState(3, 12.4);
detach();
video.dispatchEvent(new Event("stalled"));
expect(attrsOf("player.media")).toEqual([
{ player: "plex", event: "waiting", at: 0 },
{ player: "youtube", event: "buffering", at: 12 },
]);
});
});
+147
View File
@@ -0,0 +1,147 @@
// @vitest-environment jsdom
//
// E22 S5: the consented level applied to the tab, and the stream it opens. The module keeps its state
// at module level (what is applied, the queue), so every test imports a fresh copy — and the log
// from that same copy, or it would be reading a different ring.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const sent: unknown[][] = [];
const unloaded: unknown[][] = [];
let answer: () => Promise<unknown> = () => Promise.resolve({ ok: true, stored: 0 });
vi.mock("./api", async () => {
const core = await vi.importActual<typeof import("./api/core")>("./api/core");
return {
HttpError: core.HttpError,
DIAGNOSTICS_EVENTS_PATH: "/api/diagnostics/events",
api: {
sendLogEvents: (events: unknown[]) => {
sent.push(events);
return answer();
},
sendLogEventsOnUnload: (events: unknown[]) => void unloaded.push(events),
},
};
});
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
sent.length = 0;
unloaded.length = 0;
answer = () => Promise.resolve({ ok: true, stored: 0 });
});
let current: typeof import("./diagnostics") | null = null;
afterEach(() => {
// A module copy left streaming would still hear `pagehide` in the next test.
current?.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
current = null;
vi.useRealTimers();
});
async function fresh() {
const diagnostics = await import("./diagnostics");
current = diagnostics;
const logMod = await import("./log");
const apiMod = await import("./api");
return { ...diagnostics, ...logMod, HttpError: apiMod.HttpError };
}
const raised = (level: "DEBUG" | "TRACE", hours = 24) => ({
level,
until: new Date(Date.now() + hours * 3_600_000).toISOString(),
retention_days: 7,
});
const codes = (batch: unknown[] | undefined) =>
(batch ?? []).map((e) => (e as { event: string }).event);
describe("diagnostics level", () => {
it("the base level at boot sets INFO, streams nothing and logs nothing", async () => {
const d = await fresh();
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
d.log.info("nav.view", "x");
await vi.advanceTimersByTimeAsync(60_000);
expect(sent).toEqual([]);
expect(d.enabled("DEBUG")).toBe(false);
expect(codes(d.events())).toEqual(["nav.view"]);
});
it("a raised level streams only what happens after it, without its own request lines", async () => {
const d = await fresh();
d.log.info("before.consent", "not sent");
d.applyDiagnostics(raised("DEBUG"), () => {});
expect(d.enabled("DEBUG")).toBe(true);
d.log.debug("ui.click", "click");
d.log.request("INFO", "POST", { path: "/api/diagnostics/events", status: 200 }, "abcdef0123");
d.log.request("INFO", "GET", { path: "/api/feed", status: 200 }, "abcdef0124");
await vi.advanceTimersByTimeAsync(5_000);
expect(sent).toHaveLength(1);
expect(codes(sent[0])).toEqual(["diagnostics.level", "ui.click", "http.request"]);
expect(JSON.stringify(sent[0])).not.toContain("/api/diagnostics/events");
});
it("sends in batches of at most 100, and a full batch goes without waiting", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), () => {});
for (let i = 0; i < 250; i++) d.log.debug("ui.click", `click ${i}`);
await vi.advanceTimersByTimeAsync(0);
expect(sent.map((b) => b.length)).toEqual([100]);
await vi.advanceTimersByTimeAsync(5_000);
await vi.advanceTimersByTimeAsync(5_000);
expect(sent.every((b) => b.length <= 100)).toBe(true);
expect(sent.flat()).toHaveLength(251); // the 250 clicks + the line that opened the stream
});
it("stops at once when the level is lowered, and drops what was not sent yet", async () => {
const d = await fresh();
d.applyDiagnostics(raised("TRACE"), () => {});
d.log.trace("search.typed", "lofi");
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
d.log.info("nav.view", "after");
await vi.advanceTimersByTimeAsync(60_000);
expect(sent).toEqual([]);
expect(d.enabled("TRACE")).toBe(false);
});
it("lapses on its own timer and tells the app to refetch", async () => {
const d = await fresh();
const lapsed = vi.fn();
d.applyDiagnostics(raised("DEBUG", 1), lapsed);
await vi.advanceTimersByTimeAsync(3_600_000 + 1);
expect(lapsed).toHaveBeenCalledOnce();
expect(d.enabled("DEBUG")).toBe(false);
sent.length = 0;
d.log.info("nav.view", "after the lapse");
await vi.advanceTimersByTimeAsync(10_000);
expect(sent).toEqual([]);
});
it("an already-expired level is the base level", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG", -1), () => {});
expect(d.enabled("DEBUG")).toBe(false);
});
it("the server saying it is off stops the stream; being told to slow down keeps the batch", async () => {
const d = await fresh();
const lapsed = vi.fn();
d.applyDiagnostics(raised("DEBUG"), lapsed);
answer = () => Promise.reject(new d.HttpError(429, "slow down"));
d.log.debug("ui.click", "kept for later");
await vi.advanceTimersByTimeAsync(5_000);
answer = () => Promise.reject(new d.HttpError(409, "off"));
await vi.advanceTimersByTimeAsync(5_000);
expect(codes(sent[1])).toEqual(codes(sent[0])); // the same batch, retried after the 429
expect(lapsed).toHaveBeenCalledOnce();
expect(d.enabled("DEBUG")).toBe(false);
});
it("a closing tab sends what is still waiting with a keepalive request", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), () => {});
d.log.debug("ui.click", "last click");
window.dispatchEvent(new Event("pagehide"));
expect(codes(unloaded[0])).toEqual(["diagnostics.level", "ui.click"]);
expect(sent).toEqual([]);
});
});
+123
View File
@@ -0,0 +1,123 @@
// The user's consented diagnostics level, applied to this tab (E22 S5).
//
// `applyDiagnostics(me.diagnostics)` runs whenever `me` answers. It sets the client log's level, and
// while that level is raised (Részletes / Teljes) it streams the log to the server:
// * only events kept AFTER the level was raised — the consent is for what happens from then on;
// * in batches, every FLUSH_MS or once BATCH_MAX are waiting, and the rest with a keepalive
// request when the tab closes;
// * never the lines about its own requests, or each batch would carry the previous one's;
// * and it stops at once when the level is lowered, when it lapses (a timer, not the next `me`
// refetch), or when the server answers that it is off.
// A lost batch is dropped, not retried in a loop: this is a diagnostic stream, not a ledger, and a
// retry storm against a struggling server is the last thing a diagnosis needs. The one exception is
// being told to slow down (429): that batch goes back to the front of the queue.
import { api, DIAGNOSTICS_EVENTS_PATH, HttpError, type DiagnosticsState } from "./api";
import { log, setLevel, subscribe, type LogEvent } from "./log";
const FLUSH_MS = 5_000;
/** Per request. The server takes up to 200; staying under it leaves room for a burst. */
const BATCH_MAX = 100;
/** Waiting events kept while the server is slow or away; past it the oldest go. */
const QUEUE_MAX = 1_000;
/** The level actually in force: the consented one until it lapses. */
export function levelInForce(state: DiagnosticsState | null | undefined, now = Date.now()) {
if (!state || state.level === "INFO" || !state.until) return "INFO";
return Date.parse(state.until) > now ? state.level : "INFO";
}
let applied: string | null = null;
let lapseTimer: number | null = null;
let onLapsed: (() => void) | null = null;
/** Apply `state` to this tab. `lapsed` is called when a raised level runs out or the server says it
* is off, so the caller can refetch `me` (and the Settings tab can show the base level again). */
export function applyDiagnostics(
state: DiagnosticsState | null | undefined,
lapsed: () => void,
): void {
onLapsed = lapsed;
const level = levelInForce(state);
const key = level === "INFO" ? "INFO" : `${level}@${state?.until}`;
if (key === applied) return;
const wasRaised = applied !== null && applied !== "INFO";
applied = key;
if (lapseTimer !== null) window.clearTimeout(lapseTimer);
lapseTimer = null;
if (level === "INFO") {
stopStreaming();
setLevel("INFO");
// Only a CHANGE is worth a line; every tab starts at the base level.
if (wasRaised) log.info("diagnostics.level", "diagnostics back at the base level", { level });
return;
}
setLevel(level);
startStreaming(); // first, so the stream opens with the line saying why it exists
log.info("diagnostics.level", "diagnostics level raised", { level, until: state?.until });
// setTimeout's delay is a signed 32-bit number; a longer wait would fire at once. The server
// grants 24 h, so the clamp only matters for a clock that is badly off.
const wait = Math.min(Date.parse(state!.until!) - Date.now(), 2 ** 31 - 1);
lapseTimer = window.setTimeout(lapse, wait);
}
function lapse(): void {
lapseTimer = null;
applied = "INFO";
stopStreaming();
setLevel("INFO");
log.info("diagnostics.level", "diagnostics level lapsed", { level: "INFO" });
onLapsed?.();
}
// --- streaming ----------------------------------------------------------------------------------
let queue: LogEvent[] = [];
let unsubscribe: (() => void) | null = null;
let flushTimer: number | null = null;
let inFlight = false;
const isOwnRequest = (event: LogEvent) =>
event.event === "http.request" && event.attrs?.path === DIAGNOSTICS_EVENTS_PATH;
function startStreaming(): void {
if (unsubscribe) return;
unsubscribe = subscribe((event) => {
if (!event || isOwnRequest(event)) return;
queue.push(event);
if (queue.length > QUEUE_MAX) queue.splice(0, queue.length - QUEUE_MAX);
if (queue.length >= BATCH_MAX) void flush();
});
flushTimer = window.setInterval(() => void flush(), FLUSH_MS);
window.addEventListener("pagehide", flushOnUnload);
}
function stopStreaming(): void {
unsubscribe?.();
unsubscribe = null;
if (flushTimer !== null) window.clearInterval(flushTimer);
flushTimer = null;
window.removeEventListener("pagehide", flushOnUnload);
// What was captured under the consent but not yet sent stays in the tab's own log, not the server's.
queue = [];
}
async function flush(): Promise<void> {
if (inFlight || !queue.length) return;
inFlight = true;
const batch = queue.splice(0, BATCH_MAX);
try {
await api.sendLogEvents(batch);
} catch (err) {
if (err instanceof HttpError && err.status === 429) {
queue.unshift(...batch);
if (queue.length > QUEUE_MAX) queue.length = QUEUE_MAX;
} else if (err instanceof HttpError && err.status === 409) {
lapse(); // the server has it off: withdrawn in another tab, or lapsed by its clock
}
} finally {
inFlight = false;
}
}
function flushOnUnload(): void {
while (queue.length) api.sendLogEventsOnUnload(queue.splice(0, BATCH_MAX));
}
+21
View File
@@ -0,0 +1,21 @@
// A filter/sort/view change, for the client log (E22 S5). Filter state is what makes "the feed shows
// nothing" diagnosable, so its VALUES are logged — at Részletes (DEBUG), the level the user consented
// to for exactly that. What the user TYPED into a search box is Teljes (TRACE) only; below it the log
// just says whether there is a search at all.
import { enabled, log } from "./log";
// Keys that are display data riding along in a filter object, not filter state.
const NOT_STATE = new Set(["q", "collectionTitles"]);
export function logFilterChange(scope: string, values: object, typed?: string): void {
if (!enabled("DEBUG")) return;
const attrs: Record<string, unknown> = { scope };
for (const [key, value] of Object.entries(values)) {
if (!NOT_STATE.has(key)) attrs[key] = value;
}
if (typed !== undefined) {
if (enabled("TRACE")) attrs.query = typed;
else attrs.query_set = typed !== "";
}
log.debug("filter.change", `${scope} filters changed`, attrs);
}
+30 -3
View File
@@ -4,8 +4,9 @@
// the same fields and level names as a server line, so a client batch can land in `log_events` beside
// the server's own rows and a request joins across the two by `request_id`.
//
// Events are kept in memory only, in a bounded ring: nothing leaves the browser from here. (Sending a
// batch is the diagnostics switch's job, and only with the user's consent — E22 S5.)
// Events are kept in memory only, in a bounded ring: nothing leaves the browser from here. Sending
// them is `lib/diagnostics.ts`'s job, and only while the user's consented level is raised (E22 S5);
// it and the Settings log viewer follow the ring through `subscribe`.
//
// REDACTION HAPPENS HERE, at capture, not at the call site — the same rules as the server's handler,
// held to them by a fixture both test suites run (`backend/tests/fixtures/log_redaction_cases.json`).
@@ -144,9 +145,34 @@ export function events(): LogEvent[] {
return ring.slice();
}
/** Drop every kept event (tests; later the viewer's "clear"). */
/** Drop every kept event (the viewer's "clear", tests). */
export function clear(): void {
ring = [];
notify(null);
}
// Told about every kept event as it is kept (`null` = the ring was cleared). A listener that throws
// is its own problem: it must not stop the others, nor the code that logged.
type Listener = (event: LogEvent | null) => void;
const listeners = new Set<Listener>();
/** Follow the ring: called with each event once it is kept, and with `null` when it is cleared.
* Returns the unsubscribe. */
export function subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function notify(event: LogEvent | null): void {
for (const listener of listeners) {
try {
listener(event);
} catch {
// see Listener
}
}
}
function emit(
@@ -172,6 +198,7 @@ function emit(
if (requestId) entry.request_id = requestId;
ring.push(entry);
if (ring.length > RING_MAX) ring.splice(0, ring.length - RING_MAX);
notify(entry);
} catch {
// Logging must never break the code that logs (an attr with a throwing getter, a cycle).
}
+73 -2
View File
@@ -6,9 +6,13 @@ import { installClientLog } from "./logHooks";
// History is wrapped ONCE per window (installClientLog is idempotent), so this file installs once
// and every test reads only what it caused.
// The app passes i18n/appCopy.ts; here only "Frissítés" (and "Refresh") is app copy, read as "Refresh".
const appCopy = (text: string | null) =>
["Refresh", "Frissítés"].includes(text?.trim() ?? "") ? "Refresh" : null;
beforeAll(() => {
installClientLog();
installClientLog(); // a hot reload's second call must not wrap History twice
installClientLog(appCopy);
installClientLog(appCopy); // a hot reload's second call must not wrap History twice
expect(events().map((e) => e.event)).toEqual(["app.start", "nav.view"]);
clear();
});
@@ -107,3 +111,70 @@ describe("client log hooks", () => {
});
});
});
describe("control capture (a raised diagnostics level only)", () => {
const clicks = () => events().filter((e) => e.event === "ui.click");
afterEach(() => {
document.body.innerHTML = "";
});
it("records nothing at the base level", () => {
document.body.innerHTML = `<button data-testid="feed-refresh">Refresh</button>`;
document.querySelector("button")!.click();
expect(clicks()).toEqual([]);
});
it("names a control by its test id, icon, app-copy label and state", () => {
setLevel("DEBUG");
window.history.replaceState({ sfPage: "feed" }, "");
document.body.innerHTML = `
<div data-testid="feed-toolbar">
<button aria-pressed="true"><svg class="lucide lucide-refresh-cw"></svg> Frissítés </button>
</div>`;
document.querySelector("svg")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(clicks().map((e) => e.attrs)).toEqual([
{
tag: "button",
within: "feed-toolbar",
icon: "refresh-cw",
label: "Refresh",
pressed: "true",
page: "feed",
},
]);
});
it("never records what the user wrote: a title on a card, an input's value, a link's query", () => {
setLevel("TRACE");
document.body.innerHTML = `
<button data-testid="video-card">My secret holiday video</button>
<input type="text" value="typed private words" />
<input type="checkbox" checked />
<a href="/watch/Ab3xYz9Tok?token=zzz">Some video title</a>
<a href="https://example.org/profile/someone">elsewhere</a>
<div role="button" aria-label="Delete tag holiday2024">x</div>`;
for (const el of document.querySelectorAll("button, input, a, [role=button]")) {
el.addEventListener("click", (e) => e.preventDefault());
(el as HTMLElement).click();
}
const text = JSON.stringify(clicks());
for (const secret of [
"holiday",
"typed private",
"Ab3xYz9Tok",
"zzz",
"Some video",
"someone",
]) {
expect(text).not.toContain(secret);
}
expect(clicks().map((e) => e.attrs)).toMatchObject([
{ control: "video-card" },
{ tag: "input", type: "text" },
{ tag: "input", type: "checkbox", checked: false }, // the state the click is turning it to
{ tag: "a", href: "/watch/{}" },
{ tag: "a", href: "external" },
{ tag: "div", role: "button" },
]);
});
});
+108 -3
View File
@@ -1,5 +1,7 @@
// The client log's app-wide hooks (E22 S4), installed once from main.tsx before anything renders:
// the start snapshot, uncaught errors and rejections, and navigation.
// the start snapshot, uncaught errors and rejections, navigation, connectivity, and — only while the
// user's consented level is raised (E22 S5) — which control was used and what the tab itself went
// through (hidden/shown, restored from the back-forward cache, resized).
//
// Navigation has no single choke point in this router-free app — `setPage`, the channel and search
// overlays, sub-views, the overlay stack and the URL mirrors each call `history.pushState` /
@@ -61,10 +63,112 @@ function recordView(via: Via): void {
log.info("nav.view", `${via} ${String(view.page ?? view.path)}`, { via, ...view });
}
// What counts as a control for click capture: anything a user operates.
const CONTROL = [
"button",
"a[href]",
"input",
"select",
"textarea",
"summary",
...[
"button",
"link",
"tab",
"menuitem",
"menuitemradio",
"menuitemcheckbox",
"option",
"switch",
"checkbox",
"radio",
].map((role) => `[role="${role}"]`),
].join(", ");
const ARIA_STATE = ["aria-pressed", "aria-checked", "aria-expanded", "aria-selected"] as const;
type AppCopy = (text: string | null) => string | null;
/** Which control this is — never what it says, unless what it says is the app's own copy.
* Recorded: its test id (its own, else the nearest enclosing one as `within`), its lucide icon, the
* English wording of its aria-label/text/title when that is exactly app copy (`appCopy`), its tag,
* role and input type, its aria state, whether a checkbox is checked, and for a link the path
* TEMPLATE or just "external". Never: an input's value, free label text, an href's query. */
function controlAttrs(el: Element, appCopy: AppCopy): Record<string, unknown> {
const attrs: Record<string, unknown> = { tag: el.tagName.toLowerCase() };
const role = el.getAttribute("role");
if (role) attrs.role = role;
const testId = el.getAttribute("data-testid");
if (testId) attrs.control = testId;
else {
const within = el.parentElement?.closest("[data-testid]")?.getAttribute("data-testid");
if (within) attrs.within = within;
}
const icon = [...(el.querySelector("svg.lucide")?.classList ?? [])].find(
(c) => c.startsWith("lucide-") && c !== "lucide-icon",
);
if (icon) attrs.icon = icon.slice("lucide-".length);
const label =
appCopy(el.getAttribute("aria-label")) ??
appCopy(el.textContent) ??
appCopy(el.getAttribute("title"));
if (label) attrs.label = label;
if (el instanceof HTMLInputElement) {
attrs.type = el.type;
if (el.type === "checkbox" || el.type === "radio") attrs.checked = el.checked;
}
for (const name of ARIA_STATE) {
const value = el.getAttribute(name);
if (value !== null) attrs[name.slice("aria-".length)] = value;
}
if (el instanceof HTMLAnchorElement) {
attrs.href = el.origin === window.location.origin ? pathTemplate(el.pathname) : "external";
}
const page = (window.history.state as Record<string, unknown> | null)?.sfPage;
if (typeof page === "string") attrs.page = page;
return attrs;
}
function installDetailHooks(appCopy: AppCopy): void {
// Capture phase, so a handler that stops propagation cannot hide the click from the log.
document.addEventListener(
"click",
(e) => {
if (!enabled("DEBUG") || !(e.target instanceof Element)) return;
const control = e.target.closest(CONTROL);
if (!control) return;
const attrs = controlAttrs(control, appCopy);
const name = attrs.control ?? attrs.label ?? attrs.icon ?? attrs.within ?? attrs.tag;
log.debug("ui.click", `click ${String(name)}`, attrs);
},
true,
);
document.addEventListener("visibilitychange", () => {
log.debug("app.visibility", document.visibilityState, { state: document.visibilityState });
});
window.addEventListener("pageshow", (e) => {
if (e.persisted) log.debug("app.restored", "restored from the back-forward cache");
});
let resizeTimer: number | null = null;
window.addEventListener("resize", () => {
if (!enabled("DEBUG")) return;
if (resizeTimer !== null) window.clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
resizeTimer = null;
const viewport = `${window.innerWidth}x${window.innerHeight}`;
log.debug("app.resize", viewport, { viewport });
}, 500);
});
// Connectivity is worth a line at the base level: it explains a burst of failed requests.
window.addEventListener("offline", () => log.warn("app.network", "offline", { online: false }));
window.addEventListener("online", () => log.info("app.network", "online", { online: true }));
}
let installed = false;
/** Install the hooks. Idempotent: a second call (a hot reload) must not wrap History twice. */
export function installClientLog(): void {
/** Install the hooks. Idempotent: a second call (a hot reload) must not wrap History twice.
* `appCopy` turns on-screen app copy into its English wording (i18n/appCopy.ts) — passed in, so this
* module stays free of the i18n runtime. */
export function installClientLog(appCopy: AppCopy = () => null): void {
if (installed) return;
installed = true;
@@ -103,4 +207,5 @@ export function installClientLog(): void {
}
window.addEventListener("popstate", () => recordView("pop"));
recordView("start");
installDetailHooks(appCopy);
}
+90
View File
@@ -0,0 +1,90 @@
// What a player went through, for the client log (E22 S5) — one definition for every player, so a
// stall in the Plex player and one in the library player read the same in a diagnosis.
//
// A media ERROR is a warning at every level: it is the thing a user reports. The rest — play, pause,
// buffering, seeking, the end, metadata, rate — is Részletes (DEBUG) and costs nothing below it.
// Positions are whole seconds; nothing here names what is playing (the request log already has the
// item's route template, and a title is the user's content).
import { useCallback, useRef } from "react";
import { enabled, log } from "./log";
const MEDIA_EVENTS = [
"loadedmetadata",
"play",
"pause",
"waiting",
"stalled",
"seeking",
"seeked",
"ratechange",
"ended",
"emptied",
"error",
] as const;
/** Log `media`'s playback events as `player`. Returns the detach. */
export function logMediaElement(media: HTMLMediaElement, player: string): () => void {
const onEvent = (e: Event) => {
const at = Math.round(media.currentTime || 0);
if (e.type === "error") {
const err = media.error;
log.warn("player.media_error", `${player} media error ${err?.code ?? "?"}`, {
player,
code: err?.code ?? null,
message: err?.message || null,
at,
});
return;
}
if (!enabled("DEBUG")) return;
const attrs: Record<string, unknown> = { player, event: e.type, at };
if (e.type === "ratechange") attrs.rate = media.playbackRate;
if (e.type === "loadedmetadata" && Number.isFinite(media.duration)) {
attrs.duration = Math.round(media.duration);
}
log.debug("player.media", `${player} ${e.type}`, attrs);
};
for (const type of MEDIA_EVENTS) media.addEventListener(type, onEvent);
return () => {
for (const type of MEDIA_EVENTS) media.removeEventListener(type, onEvent);
};
}
/** A ref callback that logs the element it is given (and stops when React hands it null). */
export function useMediaLogRef(player: string): (media: HTMLMediaElement | null) => void {
const detach = useRef<(() => void) | null>(null);
return useCallback(
(media: HTMLMediaElement | null) => {
detach.current?.();
detach.current = media ? logMediaElement(media, player) : null;
},
[player],
);
}
// The IFrame API's numeric player states, named (https://developers.google.com/youtube/iframe_api_reference).
const YT_STATES: Record<number, string> = {
[-1]: "unstarted",
0: "ended",
1: "playing",
2: "paused",
3: "buffering",
5: "cued",
};
/** The YouTube player changed state (DEBUG). */
export function logYouTubeState(state: number, at: number): void {
if (!enabled("DEBUG")) return;
const name = YT_STATES[state] ?? String(state);
log.debug("player.media", `youtube ${name}`, {
player: "youtube",
event: name,
at: Math.round(at),
});
}
/** The YouTube player refused the video — 2 bad id, 5 HTML5 error, 100 gone, 101/150 embed denied. */
export function logYouTubeError(code: number): void {
log.warn("player.media_error", `youtube error ${code}`, { player: "youtube", code });
}
+7
View File
@@ -21,6 +21,7 @@ import { api, type Video } from "./api";
import { qk } from "./queryKeys";
import { patchCachedProgress } from "./progressCache";
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
import { logYouTubeError, logYouTubeState } from "./mediaLog";
import type { AutoMode, LoopMode } from "./playerModes";
// How close to the end (seconds) counts as "finished" → auto-mark watched.
@@ -401,6 +402,11 @@ export function useYtProgress({
reapplyStored();
},
onStateChange: (e: YTPlayerEvent) => {
const p0 = playerRef.current;
logYouTubeState(
e?.data,
p0 && typeof p0.getCurrentTime === "function" ? p0.getCurrentTime() : 0,
);
// 1 === playing → sync the navigated-to video's title/author for display.
if (e?.data === 1) {
errorStreakRef.current = 0; // a successful play breaks any unplayable run
@@ -418,6 +424,7 @@ export function useYtProgress({
}
},
onError: (e: YTPlayerEvent) => {
logYouTubeError(typeof e?.data === "number" ? e.data : -1);
setPlayerError(typeof e?.data === "number" ? e.data : -1);
// onError (not 'ended') fires for an unplayable item, so advanceOnEnd() never runs and an
// auto-advancing queue would freeze — skip forward instead, but bounded so a run of dead