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
+2 -2
View File
@@ -58,7 +58,7 @@ how it gets built. Concretely:
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
`downloads/`, `youtube/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`,
`security`, `logs`, `logsink`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
`security`, `logs`, `logsink`, `diagnostics`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
@@ -96,7 +96,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
| FE network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) |
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`) | `console.*` (lint-enforced) |
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
## House rules that bite (learned from real bugs)
@@ -0,0 +1,31 @@
"""add users.diagnostics_level + diagnostics_until — a user's consented diagnostics level (E22 S5)
Revision ID: 0087_users_diagnostics_level
Revises: 0086_log_events
Create Date: 2026-09-17
Both NULL = the base level (Alap). A raised level is ALWAYS paired with its expiry, computed by the
server at consent time (`app/diagnostics.py`), so no client can ask for a level that never ends.
Columns rather than `users.preferences`: that JSON is written wholesale by the client, and a consent
with a lifetime must not be something a client can write.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0087_users_diagnostics_level"
down_revision: str | None = "0086_log_events"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column("users", sa.Column("diagnostics_level", sa.String(8), nullable=True))
op.add_column(
"users", sa.Column("diagnostics_until", sa.DateTime(timezone=True), nullable=True)
)
def downgrade() -> None:
op.drop_column("users", "diagnostics_until")
op.drop_column("users", "diagnostics_level")
+3 -3
View File
@@ -21,9 +21,9 @@ Three layers, by *who* runs the code — not by feature:
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`,
`notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `logs` (the one logging
configuration) and `logsink` (its `log_events` table), … are single-file primitives many
layers import. They depend on nothing above them (a core module must never import a `routes/…`
module).
configuration), `logsink` (its `log_events` table) and `diagnostics` (a user's consented log
level), … are single-file primitives many layers import. They depend on nothing above them (a core
module must never import a `routes/…` module).
**Extraction trigger: the SECOND consumer.** Keep new logic inline in its first caller. The moment a
*second* caller needs it (a worker as well as a route; a scheduler as well as a route), extract it down
+4 -2
View File
@@ -6,6 +6,7 @@ from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from starlette.requests import HTTPConnection
from app import diagnostics
from app import email as email_mod
from app import logs
from app import mailtext
@@ -355,8 +356,9 @@ def _session_user(request: Request, db: Session, user_id: int | None, is_default
# Attribute this request's log lines to the account (E22). Here, not in the request middleware:
# this is the one place a session becomes an account, and the middleware runs outside both the
# session middleware and this task's context. `request.state` is shared across middleware layers
# (it lives on the ASGI scope), so the access-log line can read it back.
logs.set_user(user.id)
# (it lives on the ASGI scope), so the access-log line can read it back. The user's consented
# diagnostics level rides along: their requests are logged — and stored — in that detail (S5).
logs.set_user(user.id, diagnostics.effective_level(user), user.diagnostics_until)
request.state.user_id = user.id
return user
+61
View File
@@ -0,0 +1,61 @@
"""A user's diagnostics level (E22 S5): what they consented to, and for how long.
Three levels, named in the UI Alap / Részletes / Teljes:
* INFO — the base, for everyone, no consent. The browser keeps its log in memory; nothing extra
is sent or stored.
* DEBUG — consented. The server logs this user's requests in detail and stores every line of
them, and the browser streams its own log to `POST /api/diagnostics/events`.
* TRACE — consented. As DEBUG, plus what the user typed into a search.
A raised level always lapses after `ELEVATION_HOURS`: the expiry is computed HERE at consent time,
never taken from the client, and an expired level reads as INFO without anything having to clear it.
The shared demo account cannot raise its level — its log would mix every visitor's session.
Core layer: imports `models` and `sysconfig`.
"""
from datetime import datetime, timedelta, timezone
from typing import Literal, get_args
from sqlalchemy.orm import Session
from app import sysconfig
from app.models import User
DiagnosticsLevel = Literal["INFO", "DEBUG", "TRACE"]
LEVELS: tuple[str, ...] = get_args(DiagnosticsLevel)
BASE: DiagnosticsLevel = "INFO"
ELEVATION_HOURS = 24
def effective_level(user: User, now: datetime | None = None) -> DiagnosticsLevel:
"""The level in force for `user` right now: the consented one until it expires, else BASE."""
level, until = user.diagnostics_level, user.diagnostics_until
if level not in LEVELS or level == BASE or until is None:
return BASE
if until <= (now or datetime.now(timezone.utc)):
return BASE
return level # type: ignore[return-value] — narrowed by the LEVELS check above
def state(db: Session, user: User) -> dict:
"""The `/api/me` view of it: the level in force, when a raised one lapses, and how long stored
log lines are kept — the consent text states that number, so it must be the instance's own."""
level = effective_level(user)
return {
"level": level,
"until": user.diagnostics_until.isoformat() if level != BASE else None,
"retention_days": sysconfig.get_int(db, "log_retention_days"),
}
def set_level(user: User, level: DiagnosticsLevel) -> None:
"""Record a consent (or its withdrawal). The caller commits. Raising the level again restarts
the clock — each consent is for the next ELEVATION_HOURS, not an extension of an older one."""
if level == BASE:
user.diagnostics_level = None
user.diagnostics_until = None
return
user.diagnostics_level = level
user.diagnostics_until = datetime.now(timezone.utc) + timedelta(hours=ELEVATION_HOURS)
+131 -19
View File
@@ -35,6 +35,7 @@ import logging
import logging.config
import re
import sys
import threading
import time
import uuid
from datetime import datetime, timezone
@@ -86,11 +87,44 @@ _CONTEXT_VARS = {
}
def set_user(user_id: int | None) -> None:
"""Attribute the rest of this request's log lines to `user_id`. Called from the auth dependency
— the one place the session is actually resolved into an account — rather than from the request
middleware, which runs OUTSIDE the session middleware and in a different task context."""
class _RequestScope:
"""What the auth dependency learns about a request, for every line logged after it.
A MUTABLE object behind a contextvar, not plain contextvars, because of how a request actually
runs: FastAPI resolves a sync dependency (`auth.current_user`) in a threadpool on a COPY of the
context, so a `ContextVar.set()` there is gone by the time the handler runs — measured: that is
how the account never reached a handler's own lines. `log_requests` installs one scope per
request; every copied context shares the same object, so what the dependency writes is seen by
the handler and by the request line itself."""
__slots__ = ("user_id", "level")
def __init__(self) -> None:
self.user_id: int | None = None
# The user's consented diagnostics level as a logging number; None at the base level.
self.level: int | None = None
_scope: contextvars.ContextVar[_RequestScope | None] = contextvars.ContextVar(
"log_request_scope", default=None
)
def set_user(user_id: int | None, level: str = "INFO", until: datetime | None = None) -> None:
"""Attribute the rest of this request's log lines to `user_id`, and log them at the user's
consented diagnostics `level` until `until` (E22 S5; "INFO" = no elevation). Called from the
auth dependency — the one place the session is actually resolved into an account — rather than
from the request middleware, which runs OUTSIDE the session middleware."""
_user_id.set(user_id)
scope = _scope.get()
if scope is None: # outside log_requests (a script, a unit test): this context only
scope = _RequestScope()
_scope.set(scope)
elevated = level != "INFO" and until is not None and until > datetime.now(timezone.utc)
scope.user_id = user_id
scope.level = resolve_level(level) if elevated else None
if user_id is not None:
_track_elevation(user_id, scope.level, until)
def set_job(job_id: int | None) -> None:
@@ -193,7 +227,7 @@ def redact(value: Any, *, key: str = "") -> Any:
# attribute and is merged into `attrs` (so `extra={"job_id": 7}` works as well as `event(...)`).
_RECORD_STANDARD = frozenset(
vars(logging.LogRecord("", 0, "", 0, "", None, None)).keys()
| {"message", "asctime", "taskName", "event", "attrs", PERSIST}
| {"message", "asctime", "taskName", "event", "attrs", PERSIST, "elevated"}
)
@@ -207,9 +241,31 @@ class ContextFilter(logging.Filter):
value = var.get()
if value is not None and not hasattr(record, name):
setattr(record, name, value)
scope = _scope.get()
if scope is not None and scope.user_id is not None and not hasattr(record, "user_id"):
record.user_id = scope.user_id
return True
class LevelGate(logging.Filter):
"""The configured level, enforced at the HANDLER, with one exception: the lines of a request
whose user raised their diagnostics level (E22 S5).
The root logger's level cannot do this alone: it is one number for the whole process, and the
point is to log ONE user's requests in detail while everyone else's stay at the base level. So
the root is lowered to the lowest level any active elevation needs (`_track_elevation`), and
this gate drops what only an elevation let through unless the line belongs to that elevated
request. It also marks such a line `elevated`, which is what makes the `log_events` sink keep
every line of that request, not just its warnings."""
def filter(self, record: logging.LogRecord) -> bool:
scope = _scope.get()
if scope is not None and scope.level is not None:
record.elevated = True
return record.levelno >= min(scope.level, _base_level)
return record.levelno >= _base_level
def _record_attrs(record: logging.LogRecord) -> dict[str, Any]:
extra = {k: v for k, v in vars(record).items() if k not in _RECORD_STANDARD}
attrs = dict(getattr(record, "attrs", None) or {})
@@ -341,29 +397,73 @@ def configure_logging(source: str) -> None:
"json": {"()": f"{__name__}.JsonFormatter"},
"text": {"()": f"{__name__}.TextFormatter"},
},
"filters": {"context": {"()": f"{__name__}.ContextFilter"}},
"filters": {
"context": {"()": f"{__name__}.ContextFilter"},
"gate": {"()": f"{__name__}.LevelGate"},
},
"handlers": {
"stdout": {
"class": "logging.StreamHandler",
"stream": sys.stdout,
"formatter": formatter,
"filters": ["context"],
"filters": ["context", "gate"],
}
},
"root": {"level": resolve_level(settings.log_level), "handlers": ["stdout"]},
"loggers": {name: {"level": lvl} for name, lvl in _THIRD_PARTY_LEVELS.items()},
}
)
set_level(settings.log_level)
def set_level(name: str) -> None:
"""Change the running process's level (the `log_level` admin knob). Third-party loggers keep
their pinned levels, so turning on DEBUG shows OUR detail, not SQLAlchemy's."""
logging.getLogger().setLevel(resolve_level(name))
global _base_level
_base_level = resolve_level(name)
_apply_root_level(force=True)
def current_level() -> str:
return logging.getLevelName(logging.getLogger().getEffectiveLevel())
"""The configured level — not the root logger's, which an active elevation may have lowered."""
return logging.getLevelName(_base_level or logging.getLogger().getEffectiveLevel())
# --- per-user elevation (E22 S5) -----------------------------------------------------------
# The base level (`LOG_LEVEL`, then the admin knob). NOTSET until configure_logging runs — a unit
# test, a script — so the gate lets everything through there, as it did before the gate existed.
_base_level = logging.NOTSET
# user id → (level number, expiry) for every elevation this process has seen and not yet expired.
_elevations: dict[int, tuple[int, datetime]] = {}
_elevations_lock = threading.Lock()
_root_applied: int | None = None
def _track_elevation(user_id: int, level: int | None, until: datetime | None) -> None:
with _elevations_lock:
if level is None or until is None:
_elevations.pop(user_id, None)
else:
_elevations[user_id] = (level, until)
active = bool(_elevations)
if active or _root_applied != _base_level:
_apply_root_level()
def _apply_root_level(force: bool = False) -> None:
"""Root level = the lowest of the base and every unexpired elevation (expired ones are pruned
here). `setLevel` clears every logger's level cache, so it runs only when the number changes."""
global _root_applied
if _base_level == logging.NOTSET:
return # not configured: the root keeps whatever the process set up
now = datetime.now(timezone.utc)
with _elevations_lock:
for user_id in [u for u, (_, until) in _elevations.items() if until <= now]:
del _elevations[user_id]
lowest = min([_base_level, *(lvl for lvl, _ in _elevations.values())])
if force or lowest != _root_applied:
logging.getLogger().setLevel(lowest)
_root_applied = lowest
def current_source() -> str:
@@ -397,6 +497,8 @@ async def log_requests(request, call_next):
incoming = request.headers.get("x-request-id", "")
rid = incoming if _REQUEST_ID_RE.match(incoming) else uuid.uuid4().hex[:16]
token = _request_id.set(rid)
scope = _RequestScope()
scope_token = _scope.set(scope)
request.state.request_id = rid
started = time.perf_counter()
try:
@@ -417,6 +519,7 @@ async def log_requests(request, call_next):
**_request_owner(request, logging.ERROR),
},
)
_scope.reset(scope_token)
_request_id.reset(token)
raise
duration_ms = round((time.perf_counter() - started) * 1000, 1)
@@ -428,22 +531,23 @@ async def log_requests(request, call_next):
level = logging.WARNING
else:
level = logging.DEBUG if quiet else logging.INFO
attrs: dict[str, Any] = {
"method": request.method,
"path": _route_template(request),
"status": status,
"duration_ms": duration_ms,
}
# Teljes (TRACE, the user's or the whole process's): the query string too — redacted, and in
# attrs only, never in the message.
if request.url.query and _traced(scope):
attrs["query"] = redact_text(request.url.query)
_request_log.log(
level,
"%s %s %s",
request.method,
_route_template(request),
status,
extra={
**event(
"http.request",
method=request.method,
path=_route_template(request),
status=status,
duration_ms=duration_ms,
),
**_request_owner(request, level),
},
extra={**event("http.request", **attrs), **_request_owner(request, level)},
)
response.headers["X-Request-Id"] = rid
# The template, so the browser's copy of this line groups the same way (frontend lib/http.ts).
@@ -453,10 +557,18 @@ async def log_requests(request, call_next):
matched = _matched_route(request)
if matched:
response.headers["X-Route"] = matched
_scope.reset(scope_token)
_request_id.reset(token)
return response
def _traced(scope: _RequestScope) -> bool:
"""Whether this request is logged at TRACE — its user's consent, or the process's base level."""
if scope.level is not None and scope.level <= TRACE:
return True
return _base_level != logging.NOTSET and _base_level <= TRACE
# Anonymous request lines kept per client per minute. Signed-in traffic is not capped: it is a known
# account and exactly what an admin needs to see. Anonymous traffic is where the volume can come from
# nobody in particular — a scanner walking `/wp-login.php`, a bot on a share link — and each of its
+6 -1
View File
@@ -6,6 +6,8 @@ for `log_retention_days`:
* every WARNING and above,
* the `http.request` line of every request at INFO or above (not the DEBUG-level health checks and
static files),
* every line of a request whose user raised their diagnostics level (E22 S5 — `logs.LevelGate`
marks them), which is the detail they consented to have kept,
* nothing a caller marked `extra={logs.PERSIST: False}` — how the request log caps anonymous
traffic per client.
@@ -71,7 +73,7 @@ def should_store(record: logging.LogRecord) -> bool:
"""Whether this record belongs in `log_events` (see the module docstring)."""
if getattr(record, logs.PERSIST, True) is False:
return False
if record.levelno >= logging.WARNING:
if record.levelno >= logging.WARNING or getattr(record, "elevated", False):
return True
return getattr(record, "event", None) == "http.request" and record.levelno >= logging.INFO
@@ -255,6 +257,9 @@ def start() -> None:
if _sink is not None or not settings.log_db_sink:
return
_sink = LogSink()
# The same level gate as stdout: the root may be lowered for one user's elevated requests, and
# everyone else's detail must not reach the table through that gap either.
_sink.addFilter(logs.LevelGate())
logging.getLogger().addHandler(_sink)
_sink.start()
# A short-lived process (the download child) exits without a lifespan: flush on the way out.
+2
View File
@@ -36,6 +36,7 @@ from app.routes import (
auth as auth_routes,
channels,
config as config_routes,
diagnostics as diagnostics_routes,
discover as discover_routes,
downloads,
feed,
@@ -270,6 +271,7 @@ app.include_router(feed.router)
app.include_router(discover_routes.router)
app.include_router(search_routes.router)
app.include_router(me.router)
app.include_router(diagnostics_routes.router)
app.include_router(notifications.router)
app.include_router(messages.router)
app.include_router(channels.router)
+6
View File
@@ -107,6 +107,12 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
# routes/auth._establish_session. NULL = not since the column existed (0085; no backfill is
# possible). The shared demo account is never stamped — its "last login" would mean nobody.
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# The diagnostics level this user consented to (E22 S5) and when it lapses back to the base
# level. Both NULL = base. Written only by `PUT /api/me/diagnostics`, which computes the expiry
# itself; read through `app.diagnostics.effective_level`, never directly (an expired level is
# not cleared eagerly — it simply stops counting).
diagnostics_level: Mapped[str | None] = mapped_column(String(8))
diagnostics_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
display_name: Mapped[str | None] = mapped_column(String(255))
avatar_url: Mapped[str | None] = mapped_column(String(1024))
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
+135
View File
@@ -0,0 +1,135 @@
"""The browser's log, sent while its user has a raised diagnostics level (E22 S5).
The client keeps its log in memory (frontend `lib/log.ts`). Only after the user consented to Részletes
or Teljes in Settings → Diagnosztika does it stream that log here, in batches, and each batch lands in
`log_events` beside the server's own rows: `source="web"`, the account from the SESSION (never from
the body), and the request id each event carries, which is what joins a browser line to the server
line of the same request.
Nothing from the body is trusted as-is:
* the whole body is capped before it is parsed, and so are the number of events and each event's
attributes;
* everything is redacted AGAIN with the server's own rules — the client redacts at capture, but a
modified client would not;
* an event below the consented level is dropped (a Részletes consent does not store what only
Teljes may carry), and so is the whole batch once the level has lapsed;
* the timestamp is clamped to the consent window, so a row cannot dodge the retention job by
claiming to come from the future.
"""
import json
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import insert
from sqlalchemy.orm import Session
from app import diagnostics, logs, logsink
from app.auth import current_user
from app.db import get_db
from app.models import LogEvent, User
from app.ratelimit import RateLimiter
router = APIRouter(prefix="/api/diagnostics", tags=["diagnostics"])
BODY_MAX_BYTES = 512 * 1024
EVENTS_MAX = 200
ATTRS_MAX_BYTES = 16 * 1024
# The client flushes every few seconds and when its buffer fills; this is room for a burst, not a
# pace anything legitimate keeps up for a minute.
_batches = RateLimiter(max_events=30, window_seconds=60)
ClientLevel = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
class ClientEvent(BaseModel):
ts: datetime
level: ClientLevel
event: str = Field(max_length=64, pattern=r"^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$")
msg: str = Field(max_length=8_000)
attrs: dict[str, Any] | None = None
request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9._-]{8,64}$")
tab_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,32}$")
version: str | None = Field(default=None, max_length=64)
class ClientEventsIn(BaseModel):
events: list[ClientEvent] = Field(max_length=EVENTS_MAX)
async def _capped_body(request: Request) -> bytes:
"""The request body, refused the moment it passes BODY_MAX_BYTES while still arriving. Counted,
not taken from Content-Length: a declared length can be absent (chunked) or wrong, and counting
stops reading at the same point a declared-length check would have."""
body = bytearray()
async for chunk in request.stream():
body += chunk
if len(body) > BODY_MAX_BYTES:
raise HTTPException(status_code=413, detail={"code": "too_large"})
return bytes(body)
async def _parsed_events(request: Request) -> ClientEventsIn:
"""The body as the model — parsed only after the size cap, which is why this is a dependency and
not a plain body parameter (FastAPI reads a declared body in full before anything else runs)."""
raw = await _capped_body(request)
try:
return ClientEventsIn.model_validate_json(raw)
except ValidationError as exc:
raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc
def _row(item: ClientEvent, user: User, now: datetime, window_start: datetime) -> dict[str, Any]:
attrs = logs.redact(item.attrs) if item.attrs else None
if attrs is not None:
size = len(json.dumps(attrs, ensure_ascii=False, default=str).encode())
if size > ATTRS_MAX_BYTES:
attrs = {"attrs_dropped_bytes": size}
if item.tab_id:
attrs = {**(attrs or {}), "tab_id": item.tab_id}
ts = item.ts if item.ts.tzinfo else item.ts.replace(tzinfo=timezone.utc)
return logsink.to_row(
{
"ts": min(max(ts, window_start), now),
"level": item.level,
"sev": logs.SEVERITY[item.level],
"source": "web",
"logger": "web",
"event": item.event,
"msg": logs.redact_text(item.msg),
"attrs": attrs,
"request_id": item.request_id,
"user_id": user.id,
"version": item.version,
}
)
@router.post("/events")
def ingest_events(
# The account first: dependencies resolve in order, so an anonymous caller is refused before a
# byte of its body is read.
user: User = Depends(current_user),
body: ClientEventsIn = Depends(_parsed_events),
db: Session = Depends(get_db),
) -> dict:
level = diagnostics.effective_level(user)
if level == diagnostics.BASE:
# Lapsed or withdrawn: the client stops streaming when it sees this.
raise HTTPException(status_code=409, detail={"code": "diagnostics_off"})
if not _batches.allow(str(user.id)):
raise HTTPException(status_code=429, detail={"code": "rate_limited"})
now = datetime.now(timezone.utc)
window_start = now - timedelta(hours=diagnostics.ELEVATION_HOURS)
floor = logs.SEVERITY[level]
rows = [
_row(item, user, now, window_start)
for item in body.events
if logs.SEVERITY[item.level] >= floor
]
if rows:
db.execute(insert(LogEvent), rows)
db.commit()
return {"ok": True, "stored": len(rows)}
+39 -3
View File
@@ -1,5 +1,7 @@
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from pydantic import BaseModel
from pydantic import BaseModel, RootModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
@@ -15,7 +17,9 @@ from app.auth import (
optional_current_user,
purge_user,
)
from app import diagnostics
from app import email as email_mod
from app import logs
from app import sysconfig
from app.db import get_db
from app.models import Invite, User
@@ -147,6 +151,9 @@ def get_me(
"pending_activations": pending_activations,
"email_configured": email_configured,
"preferences": user.preferences or {},
# The consented diagnostics level in force and when it lapses (E22 S5). The client applies
# it to its own log and streams that log while it is raised.
"diagnostics": diagnostics.state(db, user),
}
@@ -188,16 +195,45 @@ def delete_my_account(
return {"deleted": True}
class PreferencesIn(RootModel[dict[str, Any]]):
"""Free-form UI preferences — a JSON object of any keys (theme, filters, …), merged into the
stored ones. A model rather than a bare `dict` parameter so a non-object body is a 422 at the
boundary like every other request body."""
@router.put("/preferences")
def update_preferences(
preferences: dict,
preferences: PreferencesIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
# Merge so partial updates don't wipe other keys.
merged = dict(user.preferences or {})
merged.update(preferences)
merged.update(preferences.root)
user.preferences = merged
db.add(user)
db.commit()
return {"preferences": merged}
class DiagnosticsIn(BaseModel):
level: diagnostics.DiagnosticsLevel
@router.put("/diagnostics")
def update_diagnostics(
body: DiagnosticsIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Raise the diagnostics level (the user consented in Settings → Diagnosztika) or return to the
base. The expiry is the server's: a raised level always lapses after
`diagnostics.ELEVATION_HOURS`, whatever the client asks. The shared demo account cannot raise
it — its log would interleave every visitor's session under one account."""
if user.is_demo and body.level != diagnostics.BASE:
raise HTTPException(status_code=403, detail={"code": "demo", "reason": "diagnostics"})
diagnostics.set_level(user, body.level)
db.commit()
# The rest of THIS request, and the process's elevation registry, follow the new level now.
logs.set_user(user.id, diagnostics.effective_level(user), user.diagnostics_until)
return {"ok": True, "diagnostics": diagnostics.state(db, user)}
+11 -9
View File
@@ -7,14 +7,14 @@ of the caller's already-translated fallback, so from that commit on the demo use
this gate is UI-gated on `is_demo` either — the comfort shelf is not, so the pin button is live.
A structured `{code: "demo"}` is what `localizeDetail` can turn into `errors.demo` in both locales.
Pinned at all FOUR sites: the shared dependency, the two auth routes that hand-roll the same check,
and the account-delete route in `routes/me.py` — which round 6 missed while its docstring claimed
Pinned at every site: the shared dependency, the two auth routes that hand-roll the same check,
the account-delete route in `routes/me.py` — which round 6 missed while its docstring claimed
all the sites were covered, so the next reader had no reason to look (round 7). An asymmetric fix is
the smell this repo bans, and a docstring asserting symmetry it does not have is worse than none.
The list is derived from the source, not typed out: `test_every_is_demo_refusal_is_on_the_list`
below fails when a fifth `is_demo` 403 appears anywhere in `app/`, so this file cannot fall behind
the code the way round 6's did. Needs the DB.
the code the way round 6's did. (E22 S5 added the fifth: raising the diagnostics level.) Needs the DB.
"""
import re
@@ -33,6 +33,8 @@ def test_every_demo_refusal_carries_the_same_structured_reason(db, make_user, as
("post", "/auth/logout-others", None),
("post", "/auth/set-password", {"password": "a long enough password"}),
("delete", "/api/me/account", None),
# E22 S5: the shared account's log would interleave every visitor's session.
("put", "/api/me/diagnostics", {"level": "DEBUG"}),
]:
r = getattr(client, method)(url, **({"json": body} if body is not None else {}))
assert r.status_code == 403, (url, r.text)
@@ -55,9 +57,9 @@ def test_every_is_demo_refusal_is_on_the_list():
for path in sorted(app_dir.rglob("*.py")):
lines = path.read_text(encoding="utf-8").splitlines()
for i, line in enumerate(lines):
# `if user.is_demo:` / `if u.is_demo:` — the hand-rolled gate, not the ORM filters
# (`User.is_demo.is_(False)`) and not the column definition.
if not re.search(r"^\s*if\s+\w+\.is_demo\s*:", line):
# `if user.is_demo:` / `if u.is_demo and …:` — the hand-rolled gate, not the ORM
# filters (`User.is_demo.is_(False)`) and not the column definition.
if not re.search(r"^\s*if\s+\w+\.is_demo\b[^:]*:", line):
continue
block = "\n".join(lines[i : i + 6])
if "403" not in block:
@@ -67,9 +69,9 @@ def test_every_is_demo_refusal_is_on_the_list():
f"{path.relative_to(app_dir)}:{i + 1} refuses the demo account with an "
f"unstructured detail — the frontend cannot localize it:\n{block}"
)
# auth.py's shared gate + routes/auth.py's two + routes/me.py's delete. A new one is welcome,
# but it has to be added to the loop above in the same commit.
assert len(sites) == 4, sites
# auth.py's shared gate + routes/auth.py's two + routes/me.py's delete and diagnostics level. A
# new one is welcome, but it has to be added to the loop above in the same commit.
assert len(sites) == 5, sites
def test_a_real_account_is_not_refused_by_the_gate(db, make_user, as_user):
+340
View File
@@ -0,0 +1,340 @@
"""E22 S5 — a user's consented diagnostics level, and the browser log it lets in.
Three parts:
* the level itself: the server computes its expiry, a lapsed one reads as the base, the demo
account cannot raise it (DB lane, through the real routes);
* `POST /api/diagnostics/events`: refused at the base level, and nothing in a batch is trusted —
the account comes from the session, everything is redacted again, events under the consented
level are dropped, sizes and pace are capped (DB lane);
* the server side of the elevation: ONE user's requests are logged in detail while everyone
else's stay at the base level, and the account reaches a handler's own lines at all (pure, an
in-process app).
"""
import json
import logging
from datetime import datetime, timedelta, timezone
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import select
from app import diagnostics, logs, logsink
from app.models import LogEvent, User
from app.routes import diagnostics as diagnostics_routes
_PW = "correct horse battery"
# --- the level ------------------------------------------------------------------------------
def test_a_raised_level_counts_until_it_lapses_and_not_a_moment_longer():
now = datetime.now(timezone.utc)
assert diagnostics.effective_level(User()) == "INFO"
raised = User(diagnostics_level="TRACE", diagnostics_until=now + timedelta(minutes=1))
assert diagnostics.effective_level(raised, now) == "TRACE"
assert diagnostics.effective_level(raised, now + timedelta(minutes=2)) == "INFO"
# A level without an expiry is not a level that never ends — it is no level at all.
assert diagnostics.effective_level(User(diagnostics_level="DEBUG")) == "INFO"
def _signed_in(client, make_user, **kw):
"""A real sign-in: `/api/me` resolves through `optional_current_user`, which the `as_user`
override does not cover (see test_me_email_flag)."""
user = make_user(email="diag@example.com", password=_PW, **kw)
r = client.post("/auth/password-login", json={"email": "diag@example.com", "password": _PW})
assert r.status_code == 200, r.text
return user
def test_raising_the_level_sets_the_servers_expiry_whatever_the_client_sends(db, client, make_user):
user = _signed_in(client, make_user)
r = client.put("/api/me/diagnostics", json={"level": "DEBUG", "until": "2099-01-01T00:00:00Z"})
assert r.status_code == 200, r.text
db.refresh(user)
expected = datetime.now(timezone.utc) + timedelta(hours=diagnostics.ELEVATION_HOURS)
assert abs((user.diagnostics_until - expected).total_seconds()) < 60
assert r.json()["diagnostics"]["level"] == "DEBUG"
assert client.get("/api/me").json()["diagnostics"]["level"] == "DEBUG"
assert client.put("/api/me/diagnostics", json={"level": "INFO"}).status_code == 200
db.refresh(user)
assert (user.diagnostics_level, user.diagnostics_until) == (None, None)
assert client.get("/api/me").json()["diagnostics"] == {
"level": "INFO",
"until": None,
"retention_days": 7,
}
def test_a_lapsed_level_reads_as_the_base_in_me(db, client, make_user):
_signed_in(
client,
make_user,
diagnostics_level="TRACE",
diagnostics_until=datetime.now(timezone.utc) - timedelta(minutes=1),
)
assert client.get("/api/me").json()["diagnostics"] == {
"level": "INFO",
"until": None,
"retention_days": 7,
}
def test_only_the_three_levels_can_be_chosen(db, make_user, as_user):
client = as_user(make_user())
for bogus in ("WARNING", "debug", "EVERYTHING"):
assert client.put("/api/me/diagnostics", json={"level": bogus}).status_code == 422
def test_the_demo_account_can_return_to_the_base_but_never_raise_it(db, make_user, as_user):
client = as_user(make_user(is_demo=True))
r = client.put("/api/me/diagnostics", json={"level": "TRACE"})
assert r.status_code == 403 and r.json()["detail"]["code"] == "demo"
assert client.put("/api/me/diagnostics", json={"level": "INFO"}).status_code == 200
def test_preferences_must_be_a_json_object(db, make_user, as_user):
client = as_user(make_user())
assert client.put("/api/me/preferences", json=["not", "an", "object"]).status_code == 422
r = client.put("/api/me/preferences", json={"theme": "dark"})
assert r.status_code == 200 and r.json()["preferences"]["theme"] == "dark"
# --- the browser's batches ------------------------------------------------------------------
def _event(**over) -> dict:
return {
"ts": datetime.now(timezone.utc).isoformat(),
"level": "INFO",
"event": "nav.view",
"msg": "view",
"tab_id": "a1b2c3",
"version": "0.88.0",
**over,
}
@pytest.fixture
def elevated(db, make_user, as_user):
"""A signed-in client whose user consented to `level` a minute ago, and a reader for its rows."""
diagnostics_routes._batches.reset()
def _make(level: str = "DEBUG"):
user = make_user(
diagnostics_level=level,
diagnostics_until=datetime.now(timezone.utc) + timedelta(hours=23),
)
def rows() -> list[LogEvent]:
db.expire_all()
return list(db.execute(select(LogEvent).order_by(LogEvent.id)).scalars())
return user, as_user(user), rows
yield _make
diagnostics_routes._batches.reset()
def test_nothing_is_accepted_at_the_base_level(db, make_user, as_user):
client = as_user(make_user())
r = client.post("/api/diagnostics/events", json={"events": [_event()]})
assert r.status_code == 409 and r.json()["detail"]["code"] == "diagnostics_off"
assert not db.execute(select(LogEvent)).first()
def test_an_anonymous_caller_is_refused(client):
assert client.post("/api/diagnostics/events", json={"events": [_event()]}).status_code == 401
def test_a_batch_is_stored_as_the_sessions_web_events_and_redacted_again(elevated):
user, client, rows = elevated("DEBUG")
r = client.post(
"/api/diagnostics/events",
json={
"events": [
_event(
level="WARNING",
event="ui.notify",
msg="sent to someone@example.com",
attrs={"password": "hunter2", "url": "/?token=abc123", "user_id": 999},
request_id="0123456789abcdef",
),
_event(level="DEBUG", event="ui.click", attrs={"control": "feed.refresh"}),
],
"user_id": 999, # ignored: the account is the session's
},
)
assert r.json() == {"ok": True, "stored": 2}
warning, click = rows()
assert (warning.source, warning.logger, warning.user_id) == ("web", "web", user.id)
assert warning.msg == "sent to [email]"
assert warning.attrs["password"] == logs.REDACTED
assert "abc123" not in json.dumps(warning.attrs)
assert warning.attrs["tab_id"] == "a1b2c3"
assert warning.request_id == "0123456789abcdef"
assert (warning.sev, click.level, click.version) == (13, "DEBUG", "0.88.0")
def test_an_event_under_the_consented_level_is_dropped(elevated):
_user, client, rows = elevated("DEBUG")
r = client.post(
"/api/diagnostics/events",
json={"events": [_event(level="TRACE", msg="typed: lofi"), _event(level="DEBUG")]},
)
assert r.json()["stored"] == 1
assert [row.level for row in rows()] == ["DEBUG"]
def test_a_timestamp_outside_the_consent_window_is_clamped(elevated):
_user, client, rows = elevated("DEBUG")
now = datetime.now(timezone.utc)
future = (now + timedelta(days=365)).isoformat()
past = (now - timedelta(days=30)).isoformat()
client.post("/api/diagnostics/events", json={"events": [_event(ts=future), _event(ts=past)]})
late, early = rows()
assert late.ts <= datetime.now(timezone.utc)
assert early.ts >= now - timedelta(hours=diagnostics.ELEVATION_HOURS, minutes=1)
def test_sizes_are_capped(elevated):
_user, client, rows = elevated("DEBUG")
too_many = [_event() for _ in range(diagnostics_routes.EVENTS_MAX + 1)]
assert client.post("/api/diagnostics/events", json={"events": too_many}).status_code == 422
huge = "x" * (diagnostics_routes.BODY_MAX_BYTES + 1)
r = client.post(
"/api/diagnostics/events",
content=json.dumps({"events": [_event(attrs={"blob": huge})]}),
headers={"content-type": "application/json"},
)
assert r.status_code == 413
big_attrs = {"blob": "y" * (diagnostics_routes.ATTRS_MAX_BYTES + 10)}
assert (
client.post("/api/diagnostics/events", json={"events": [_event(attrs=big_attrs)]}).json()[
"stored"
]
== 1
)
(row,) = rows()
assert "blob" not in row.attrs and row.attrs["attrs_dropped_bytes"] > 0
bad_code = client.post("/api/diagnostics/events", json={"events": [_event(event="Nav View")]})
assert bad_code.status_code == 422
def test_the_pace_is_capped_per_user(elevated):
_user, client, _rows = elevated("DEBUG")
codes = [
client.post("/api/diagnostics/events", json={"events": []}).status_code for _ in range(31)
]
assert codes[:30] == [200] * 30 and codes[30] == 429
# --- the server side of an elevation ---------------------------------------------------------
class _Capture(logging.Handler):
"""The stdout handler's filters (context + gate), capturing records."""
def __init__(self):
super().__init__(level=logging.NOTSET)
self.records: list[logging.LogRecord] = []
self.addFilter(logs.ContextFilter())
self.addFilter(logs.LevelGate())
def emit(self, record):
self.records.append(record)
@pytest.fixture
def gated():
"""A process configured at INFO with a gated handler on the ROOT (where the real one sits), and
every piece of module state the elevation touches restored afterwards."""
root = logging.getLogger()
saved = (logs._base_level, dict(logs._elevations), logs._root_applied, root.level)
handler = _Capture()
root.addHandler(handler)
logs._elevations.clear()
logs.set_level("INFO")
try:
yield handler
finally:
root.removeHandler(handler)
logs._base_level, logs._root_applied = saved[0], saved[2]
logs._elevations.clear()
logs._elevations.update(saved[1])
root.setLevel(saved[3])
def _app() -> TestClient:
"""What a real request does: the request middleware, then a SYNC auth dependency that resolves
the account (run by FastAPI in a threadpool, on a copy of the context), then a handler."""
app = FastAPI()
app.middleware("http")(logs.log_requests)
handler_log = logging.getLogger("siftlode.difftest")
def account(user_id: int, level: str = "INFO"):
until = datetime.now(timezone.utc) + timedelta(hours=1)
logs.set_user(user_id, level, until)
@app.get("/api/thing/{user_id}")
def _thing(user_id: int, _=Depends(account)):
handler_log.debug("detail only an elevated request keeps")
handler_log.info("an ordinary line")
return {}
return TestClient(app)
def test_the_account_reaches_the_handlers_own_lines(gated):
_app().get("/api/thing/41")
line = next(r for r in gated.records if r.getMessage() == "an ordinary line")
assert getattr(line, "user_id", None) == 41
def test_one_users_elevation_logs_their_requests_in_detail_and_nobody_elses(gated):
client = _app()
client.get("/api/thing/7", params={"level": "DEBUG"})
client.get("/api/thing/8")
detail = [r for r in gated.records if r.getMessage().startswith("detail only")]
assert [r.user_id for r in detail] == [7]
assert getattr(detail[0], "elevated", False) is True
# Every line of the elevated request is marked for storage — the sink keeps it at any level.
ordinary = [r for r in gated.records if r.getMessage() == "an ordinary line"]
assert [logsink.should_store(r) for r in ordinary] == [True, False]
# The process's configured level is still what it reports, however low the root went.
assert logs.current_level() == "INFO" and logging.getLogger().level == logging.DEBUG
def test_the_root_level_returns_to_the_base_when_the_elevation_ends(gated):
client = _app()
client.get("/api/thing/7", params={"level": "TRACE"})
assert logging.getLogger().level == logs.TRACE
client.get("/api/thing/7") # the same user, back at the base
assert logging.getLogger().level == logging.INFO
logs._track_elevation(9, logging.DEBUG, datetime.now(timezone.utc) - timedelta(seconds=1))
assert logging.getLogger().level == logging.INFO # an already-expired one is pruned at once
def test_the_query_string_is_logged_only_for_a_traced_request_and_redacted(gated):
client = _app()
client.get("/api/thing/7", params={"level": "DEBUG", "q": "lofi"})
client.get("/api/thing/7", params={"level": "TRACE", "q": "lofi", "token": "SECRET"})
lines = [r for r in gated.records if getattr(r, "event", None) == "http.request"]
assert "query" not in lines[0].attrs
assert "q=lofi" in lines[1].attrs["query"] and "SECRET" not in lines[1].attrs["query"]
def test_a_real_sign_in_carries_the_consented_level_into_the_process(gated, client, db, make_user):
user = _signed_in(client, make_user)
assert client.put("/api/me/diagnostics", json={"level": "DEBUG"}).status_code == 200
logs._elevations.clear() # only a request resolving the account through auth may put it back
client.get("/api/me")
assert logs._elevations[user.id][0] == logging.DEBUG
assert client.put("/api/me/diagnostics", json={"level": "INFO"}).status_code == 200
assert user.id not in logs._elevations
+9
View File
@@ -48,6 +48,7 @@ import { focusAccessRequestsTab } from "./lib/adminUsersTab";
// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes
// split. FRONTEND_VERSION equals RELEASE_NOTES[0].version by release convention (both bump together).
import { FRONTEND_VERSION } from "./lib/version";
import { applyDiagnostics } from "./lib/diagnostics";
// The page modules (and their side rails) are code-split per route via the module registry
// (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
@@ -151,6 +152,14 @@ export default function App() {
meQuery.data ? [...moduleOrder(meQuery.data), "settings"] : [],
);
// The account's consented diagnostics level (E22 S5), applied to this tab's log: raised, the log is
// kept in more detail and streamed to the server until the level lapses. A lapse (its own timer, or
// the server saying it is off) refetches `me`, so Settings shows the base level again.
const diagnostics = meQuery.data?.diagnostics;
useEffect(() => {
applyDiagnostics(diagnostics, () => void queryClient.invalidateQueries({ queryKey: qk.me() }));
}, [diagnostics?.level, diagnostics?.until]); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch the code of every module this account can open — and the channel page — once the first page
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
// Idle-scheduled so it never competes with the first page's own requests (Safari has no
@@ -0,0 +1,130 @@
// @vitest-environment jsdom
//
// E22 S5: Settings → Diagnosztika. Raising the level is a consent, so choosing Részletes or Teljes
// must change NOTHING until the consent box's own button is pressed; going back to Alap needs no
// consent. i18n is a key-passthrough (with the count, so the retention days are visible); the level
// endpoint is stubbed, and `me` lives in a real query cache so a saved level re-renders like in the app.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DiagnosticsLevel, DiagnosticsState, Me } from "../lib/api";
import { qk } from "../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: { count?: number }) =>
opts?.count != null ? `${key}:${opts.count}` : key,
i18n: { language: "en" },
}),
};
});
const setLevel = vi.fn(
(level: DiagnosticsLevel): Promise<{ ok: true; diagnostics: DiagnosticsState }> =>
Promise.resolve({
ok: true,
diagnostics: {
level,
until: level === "INFO" ? null : new Date(Date.now() + 86_400_000).toISOString(),
retention_days: 7,
},
}),
);
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return {
...actual,
api: { ...actual.api, setDiagnosticsLevel: (l: DiagnosticsLevel) => setLevel(l) },
};
});
import DiagnosticsSettings from "./DiagnosticsSettings";
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
function mount(me: Partial<Me>) {
const qc = new QueryClient();
qc.setQueryData(qk.me(), {
id: 1,
is_demo: false,
preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7 },
...me,
});
render(
<QueryClientProvider client={qc}>
<DiagnosticsSettings />
</QueryClientProvider>,
);
}
const levelButton = (name: string) =>
within(screen.getByRole("group", { name: "settings.diagnostics.level" })).getByRole("button", {
name,
});
describe("DiagnosticsSettings", () => {
it("choosing a detailed level asks first and changes nothing until confirmed", async () => {
mount({});
fireEvent.click(levelButton("settings.diagnostics.levels.DEBUG"));
const consent = screen.getByTestId("diagnostics-consent");
expect(consent.textContent).toContain("settings.diagnostics.consent.what.DEBUG");
expect(consent.textContent).toContain("settings.diagnostics.consent.server:7");
expect(setLevel).not.toHaveBeenCalled();
fireEvent.click(
within(consent).getByRole("button", { name: "settings.diagnostics.consent.cancel" }),
);
expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
expect(levelButton("settings.diagnostics.levels.INFO").getAttribute("aria-pressed")).toBe(
"true",
);
expect(setLevel).not.toHaveBeenCalled();
});
it("the consent button raises the level, and the status line offers the way back", async () => {
mount({});
fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
fireEvent.click(screen.getByTestId("diagnostics-consent-confirm"));
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
expect(setLevel).toHaveBeenCalledWith("TRACE");
expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
expect(levelButton("settings.diagnostics.levels.TRACE").getAttribute("aria-pressed")).toBe(
"true",
);
fireEvent.click(screen.getByRole("button", { name: "settings.diagnostics.revert" }));
await waitFor(() => expect(screen.queryByTestId("diagnostics-status")).toBeNull());
expect(setLevel).toHaveBeenLastCalledWith("INFO");
});
it("going back to the base level needs no consent", async () => {
const until = new Date(Date.now() + 3_600_000).toISOString();
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } });
fireEvent.click(levelButton("settings.diagnostics.levels.INFO"));
expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
await waitFor(() => expect(setLevel).toHaveBeenCalledWith("INFO"));
});
it("a lapsed level shows as the base level", () => {
const until = new Date(Date.now() - 60_000).toISOString();
mount({ diagnostics: { level: "TRACE", until, retention_days: 7 } });
expect(levelButton("settings.diagnostics.levels.INFO").getAttribute("aria-pressed")).toBe(
"true",
);
expect(screen.queryByTestId("diagnostics-status")).toBeNull();
});
it("the shared demo account gets an explanation instead of the picker", () => {
mount({ is_demo: true });
expect(screen.queryByRole("group", { name: "settings.diagnostics.level" })).toBeNull();
expect(screen.getByText("settings.diagnostics.demo")).toBeTruthy();
});
});
@@ -0,0 +1,159 @@
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type DiagnosticsLevel, type Me } from "../lib/api";
import { levelInForce } from "../lib/diagnostics";
import { qk } from "../lib/queryKeys";
import { useMe } from "../lib/useMe";
import { Button } from "./ui/Button";
import InlineNotice from "./ui/InlineNotice";
import SegmentedToggle from "./ui/SegmentedToggle";
import { Section, SettingRow } from "./ui/form";
import MyLogViewer from "./MyLogViewer";
const LEVELS: readonly DiagnosticsLevel[] = ["INFO", "DEBUG", "TRACE"];
/** Hours and minutes until `until`, refreshed every 30 s — the status line counts down. */
function useRemaining(until: string | null): { h: number; m: number } | null {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!until) return;
const id = window.setInterval(() => setNow(Date.now()), 30_000);
return () => window.clearInterval(id);
}, [until]);
if (!until) return null;
const minutes = Math.max(0, Math.ceil((Date.parse(until) - now) / 60_000));
return { h: Math.floor(minutes / 60), m: minutes % 60 };
}
// Settings → Diagnosztika (E22 S5): the consented diagnostics level and the tab's own log.
//
// There is no separate on/off switch: Alap IS off. Choosing Részletes or Teljes does not change
// anything yet — it opens the consent box under the picker, which says what will be recorded, that
// the server keeps it and for how long, and that it lapses; only its button raises the level.
// Returning to Alap needs no consent and happens at once.
export default function DiagnosticsSettings() {
const { t } = useTranslation();
const me = useMe();
const qc = useQueryClient();
const state = me.diagnostics;
const current = levelInForce(state);
const [pending, setPending] = useState<DiagnosticsLevel | null>(null);
const remaining = useRemaining(current === "INFO" ? null : (state?.until ?? null));
const change = useMutation({
mutationFn: api.setDiagnosticsLevel,
meta: { failTitle: "settings.diagnostics.failed" },
onSuccess: (result) => {
qc.setQueryData<Me | null>(qk.me(), (prev) =>
prev ? { ...prev, diagnostics: result.diagnostics } : prev,
);
setPending(null);
},
});
const choose = (level: DiagnosticsLevel) => {
if (level === "INFO") {
setPending(null);
if (current !== "INFO") change.mutate("INFO");
} else {
setPending(level === current ? null : level);
}
};
const levelName = (level: DiagnosticsLevel) => t(`settings.diagnostics.levels.${level}`);
return (
<>
<Section title={t("settings.diagnostics.title")}>
{me.is_demo ? (
<InlineNotice>{t("settings.diagnostics.demo")}</InlineNotice>
) : (
<SettingRow
label={t("settings.diagnostics.level")}
hint={t("settings.diagnostics.levelHint")}
>
<SegmentedToggle
size="md"
ariaLabel={t("settings.diagnostics.level")}
value={pending ?? current}
onChange={choose}
options={LEVELS.map((level) => ({ value: level, label: levelName(level) }))}
/>
</SettingRow>
)}
{pending && (
<div
data-testid="diagnostics-consent"
className="mt-2 rounded-xl border border-border bg-accent/5 p-3 text-sm"
>
<p className="font-semibold">{t(`settings.diagnostics.consent.title.${pending}`)}</p>
<ul className="mt-1.5 mb-3 list-disc space-y-1 pl-5 text-muted">
<li>{t(`settings.diagnostics.consent.what.${pending}`)}</li>
<li>
{t("settings.diagnostics.consent.server", {
count: state?.retention_days ?? 7,
})}
</li>
<li>{t("settings.diagnostics.consent.never")}</li>
<li>
{t("settings.diagnostics.consent.lapse")}{" "}
<a
href="/privacy#diagnostics"
target="_blank"
rel="noreferrer"
className="text-accent hover:underline"
>
{t("settings.diagnostics.consent.policy")}
</a>
</li>
</ul>
<div className="flex flex-wrap justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setPending(null)}>
{t("settings.diagnostics.consent.cancel")}
</Button>
<Button
size="sm"
data-testid="diagnostics-consent-confirm"
loading={change.isPending}
onClick={() => change.mutate(pending)}
>
{t("settings.diagnostics.consent.confirm")}
</Button>
</div>
</div>
)}
{current !== "INFO" && !pending && remaining && (
<div
data-testid="diagnostics-status"
role="status"
className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-warn/40 bg-warn/10 px-3 py-2 text-sm"
>
<span className="inline-flex items-center gap-2">
<span className="h-2 w-2 shrink-0 rounded-full bg-warn" aria-hidden />
<span>
{t("settings.diagnostics.status", {
level: levelName(current),
time:
remaining.h > 0
? t("settings.diagnostics.hoursMinutes", remaining)
: t("settings.diagnostics.minutes", remaining),
})}
</span>
</span>
<Button variant="link" loading={change.isPending} onClick={() => change.mutate("INFO")}>
{t("settings.diagnostics.revert")}
</Button>
</div>
)}
</Section>
<Section title={t("settings.myLog.title")}>
<MyLogViewer />
</Section>
</>
);
}
@@ -20,6 +20,7 @@ import {
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
import { useUrlMirror } from "../lib/useUrlMirror";
import { accountKey, LS, readJSON, writeJSON } from "../lib/storage";
import { logFilterChange } from "../lib/filterLog";
import { useNavigation } from "./NavigationProvider";
// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App
@@ -123,6 +124,7 @@ export function FeedFiltersProvider({ children }: { children: ReactNode }) {
const setFilters = useCallback((next: FeedFilters) => {
setFiltersState(next);
logFilterChange("feed", next, next.q);
// Persist under THIS tab's active account so filters don't bleed between accounts.
const key = accountKey(FILTERS_KEY, getActiveAccount());
if (key) writeJSON(key, next);
+12 -3
View File
@@ -1,9 +1,10 @@
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./ui/Modal";
import { api, type DownloadJob } from "../lib/api";
import { jobTitle } from "./downloadRow";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { useMediaLogRef } from "../lib/mediaLog";
// E7 S4b — in-app player for a completed library item. We do NOT reuse PlayerModal (a cross-origin
// YouTube IFrame shell) or PlexPlayer (a Plex HLS/session shell): both are bound to their own domain
@@ -30,6 +31,14 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
// Our self-hosted poster (always same-origin, never expires) is the primary; the source thumbnail is
// the fallback. Same precedence as the library-row thumbnail.
const poster = job.poster_url ? api.posterImageUrl(job.id) : (asset?.thumbnail_url ?? undefined);
const logMedia = useMediaLogRef("library");
const videoRef = useCallback(
(el: HTMLVideoElement | null) => {
exposeVideoToBackgroundPlay(el);
logMedia(el);
},
[logMedia],
);
return (
<Modal title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
@@ -42,13 +51,13 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
className="w-40 h-40 rounded-xl object-cover border border-border"
/>
) : null}
<audio src={fileUrl} controls autoPlay className="w-full">
<audio ref={logMedia} src={fileUrl} controls autoPlay className="w-full">
{t("downloads.player.unsupported")}
</audio>
</div>
) : (
<video
ref={exposeVideoToBackgroundPlay}
ref={videoRef}
src={fileUrl}
poster={poster}
controls
@@ -0,0 +1,95 @@
// @vitest-environment jsdom
//
// E22 S5: "Saját napló" shows this tab's own log live, newest first, filters it by level group and
// text, and copies exactly what is filtered (oldest first, one JSON event per line).
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { clear, log, setLevel } from "../lib/log";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: { count?: number }) =>
opts?.count != null ? `${key}:${opts.count}` : key,
}),
};
});
const copied: string[] = [];
vi.mock("../lib/clipboard", () => ({
copyText: (text: string) => {
copied.push(text);
return Promise.resolve(true);
},
}));
import MyLogViewer from "./MyLogViewer";
beforeEach(() => {
setLevel("DEBUG");
log.info("nav.view", "push feed", { page: "feed" });
log.warn("http.request", "GET /api/feed 503", { status: 503 });
log.debug("ui.click", "click feed-refresh", { control: "feed-refresh" });
});
afterEach(() => {
cleanup();
clear();
setLevel("INFO");
copied.length = 0;
});
const rows = () =>
within(screen.getByTestId("my-log-lines"))
.queryAllByRole("button")
.map((b) => b.textContent);
describe("MyLogViewer", () => {
it("lists the tab's events newest first and follows new ones", async () => {
render(<MyLogViewer />);
expect(rows().map((r) => r!.includes("ui.click"))).toEqual([true, false, false]);
await act(async () => {
log.error("app.uncaught", "TypeError: x is undefined");
await new Promise((r) => requestAnimationFrame(() => r(null)));
});
expect(rows()[0]).toContain("app.uncaught");
});
it("filters by level group and by text, and copies only what is shown", async () => {
render(<MyLogViewer />);
fireEvent.click(screen.getByRole("button", { name: /settings\.myLog\.filters\.warning/ }));
expect(rows()).toHaveLength(1);
expect(rows()[0]).toContain("GET /api/feed 503");
fireEvent.click(screen.getByRole("button", { name: /settings\.myLog\.filters\.all/ }));
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "FEED-REFRESH" } });
expect(rows()).toHaveLength(1);
const copy = async () => {
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "settings.myLog.copy" }));
});
return copied[copied.length - 1]!.split("\n").map((l) => JSON.parse(l).event);
};
expect(await copy()).toEqual(["ui.click"]); // only what the search left
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "" } });
expect(await copy()).toEqual(["nav.view", "http.request", "ui.click"]); // oldest first
});
it("opens a row to its details and clears the log", async () => {
render(<MyLogViewer />);
const first = within(screen.getByTestId("my-log-lines")).getAllByRole("button")[0]!;
fireEvent.click(first);
expect(first.getAttribute("aria-expanded")).toBe("true");
expect(screen.getByTestId("my-log-lines").textContent).toContain('"control": "feed-refresh"');
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "settings.myLog.clear" }));
await new Promise((r) => requestAnimationFrame(() => r(null)));
});
expect(rows()).toEqual([]);
expect(screen.getByText("settings.myLog.emptyAll")).toBeTruthy();
});
});
+229
View File
@@ -0,0 +1,229 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Copy, Search } from "lucide-react";
import { copyText } from "../lib/clipboard";
import { clear, events, subscribe, type Level, type LogEvent } from "../lib/log";
import { notify } from "../lib/notifications";
import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup";
type Filter = "all" | "error" | "warning" | "info" | "detail";
const FILTER_LEVELS: Record<Exclude<Filter, "all">, readonly Level[]> = {
error: ["ERROR", "CRITICAL"],
warning: ["WARNING"],
info: ["INFO"],
detail: ["DEBUG", "TRACE"],
};
// Rows rendered at once. The ring holds up to 2000 events; drawing all of them on every new event
// would make the settings page the slowest thing in the app while it is watching the app.
const ROWS_SHOWN = 500;
const LEVEL_TAG: Record<Level, { text: string; className: string }> = {
TRACE: { text: "TRACE", className: "text-muted opacity-80" },
DEBUG: { text: "DEBUG", className: "text-muted" },
INFO: { text: "INFO", className: "text-info" },
WARNING: { text: "WARN", className: "text-warn" },
ERROR: { text: "ERROR", className: "text-danger" },
CRITICAL: { text: "FATAL", className: "text-danger" },
};
/** The ring, live: re-read at most once per animation frame however fast events arrive. */
function useLogEvents(): LogEvent[] {
const [list, setList] = useState(events);
useEffect(() => {
let frame: number | null = null;
const unsubscribe = subscribe(() => {
if (frame !== null) return;
frame = window.requestAnimationFrame(() => {
frame = null;
setList(events());
});
});
setList(events()); // whatever arrived between the first render and the subscription
return () => {
unsubscribe();
if (frame !== null) window.cancelAnimationFrame(frame);
};
}, []);
return list;
}
const matches = (event: LogEvent, needle: string) =>
!needle ||
[event.event, event.msg, event.request_id ?? "", event.attrs ? JSON.stringify(event.attrs) : ""]
.join(" ")
.toLowerCase()
.includes(needle);
const clock = (ts: string) => {
const d = new Date(ts);
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
};
// "Saját napló" — this tab's own client log (lib/log.ts), exactly what it would send while a raised
// diagnostics level is on: newest first, filtered by level group and free text, a row opens to its
// correlation id and attributes, and "Copy" puts the FILTERED events on the clipboard as JSON lines
// (one event per line) for a bug report.
export default function MyLogViewer() {
const { t } = useTranslation();
const all = useLogEvents();
const [filter, setFilter] = useState<Filter>("all");
const [query, setQuery] = useState("");
const [open, setOpen] = useState<LogEvent | null>(null);
const counts = useMemo(() => {
const out: Record<Filter, number> = {
all: all.length,
error: 0,
warning: 0,
info: 0,
detail: 0,
};
for (const e of all) {
for (const [group, levels] of Object.entries(FILTER_LEVELS)) {
if (levels.includes(e.level)) out[group as Filter] += 1;
}
}
return out;
}, [all]);
const shown = useMemo(() => {
const needle = query.trim().toLowerCase();
const levels = filter === "all" ? null : FILTER_LEVELS[filter];
const out: LogEvent[] = [];
for (let i = all.length - 1; i >= 0; i--) {
const e = all[i]!;
if ((!levels || levels.includes(e.level)) && matches(e, needle)) out.push(e);
}
return out;
}, [all, filter, query]);
const copy = async () => {
// Oldest first, as a log reads — the list shows newest first only for watching it live.
const text = [...shown]
.reverse()
.map((e) => JSON.stringify(e))
.join("\n");
const ok = await copyText(text);
notify(
ok
? { level: "success", message: t("settings.myLog.copied", { count: shown.length }) }
: { level: "error", message: t("settings.myLog.copyFailed") },
);
};
const filters: readonly Filter[] = ["all", "error", "warning", "info", "detail"];
return (
<div data-testid="my-log" className="overflow-hidden rounded-xl border border-border bg-bg">
<p className="px-3 pt-2 text-xs text-muted">{t("settings.myLog.hint")}</p>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<label className="flex min-w-[10rem] flex-1 items-center gap-1.5 rounded-lg border border-border bg-card px-2 py-1 text-sm focus-within:border-accent">
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("settings.myLog.search")}
aria-label={t("settings.myLog.search")}
className="min-w-0 flex-1 bg-transparent outline-none"
/>
</label>
<FilterChipGroup
ariaLabel={t("settings.myLog.filterLabel")}
value={filter}
onChange={setFilter}
options={filters.map((f) => ({
value: f,
label: t(`settings.myLog.filters.${f}`),
count: counts[f],
}))}
/>
</div>
<div className="max-h-96 overflow-auto font-mono text-2xs" data-testid="my-log-lines">
{shown.length === 0 ? (
<p className="px-3 py-6 text-center font-sans text-sm text-muted">
{all.length === 0 ? t("settings.myLog.emptyAll") : t("settings.myLog.empty")}
</p>
) : (
shown.slice(0, ROWS_SHOWN).map((e, i) => {
const tag = LEVEL_TAG[e.level];
const isOpen = open === e;
return (
<div key={`${e.ts}-${i}`} className="border-b border-border/50 last:border-b-0">
<button
type="button"
aria-expanded={isOpen}
onClick={() => setOpen(isOpen ? null : e)}
className={`grid w-full grid-cols-[6.5em_3.5em_minmax(0,1fr)] items-baseline sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] gap-2 px-3 py-1 text-left hover:bg-card ${
isOpen ? "bg-accent/10" : ""
}`}
>
<span className="tabular-nums text-muted">{clock(e.ts)}</span>
<span className={`font-semibold ${tag.className}`}>{tag.text}</span>
{/* A phone has no room for a fourth column: the code leads the message there. */}
<span className="hidden truncate sm:block">{e.event}</span>
<span className="truncate">
<span className="sm:hidden">{e.event} · </span>
{e.msg}
</span>
</button>
{isOpen && (
<pre className="overflow-x-auto whitespace-pre bg-accent/5 px-3 py-2 text-muted">
{JSON.stringify(
{
event: e.event,
msg: e.msg,
request_id: e.request_id,
ts: e.ts,
tab_id: e.tab_id,
attrs: e.attrs,
},
null,
2,
)}
</pre>
)}
</div>
);
})
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border px-3 py-1.5 text-xs text-muted">
<span>
{t("settings.myLog.count", { count: all.length })}
{shown.length > ROWS_SHOWN &&
` · ${t("settings.myLog.capped", { shown: ROWS_SHOWN, count: shown.length })}`}
</span>
<span className="flex gap-1.5">
<Button
variant="outline"
size="xs"
icon={<Copy className="h-3.5 w-3.5" />}
disabled={shown.length === 0}
onClick={() => void copy()}
>
{t("settings.myLog.copy")}
</Button>
<Button
variant="outline"
size="xs"
disabled={all.length === 0}
onClick={() => {
clear();
setOpen(null);
}}
>
{t("settings.myLog.clear")}
</Button>
</span>
</div>
</div>
);
}
+20
View File
@@ -44,6 +44,8 @@ import { Ctrl, PanelHead, Skip10 } from "./plexPlayerControls";
import { PlexPlayerSettings } from "./PlexPlayerSettings";
import { PlexStatsOverlay } from "./PlexStatsOverlay";
import { createFragMeter } from "../lib/plexStats";
import { enabled, log } from "../lib/log";
import { logMediaElement } from "../lib/mediaLog";
import { UpNextCard } from "./UpNextCard";
import Toaster, { useOverlayOrder } from "./ui/Toaster";
import {
@@ -432,6 +434,22 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// that never became an error (deep-review round 5). A couple of reclaims is what a genuine
// reap needs; past that the honest answer is that playback failed.
hls.on(Hls.Events.ERROR, (_e, data) => {
// Fatal ones are what the user saw fail; the recoverable ones hls.js retries by itself
// only matter to a detailed diagnosis.
if (data.fatal || enabled("DEBUG")) {
log.at(
data.fatal ? "WARNING" : "DEBUG",
"player.hls_error",
`plex hls ${data.details}`,
{
player: "plex",
fatal: data.fatal,
type: data.type,
details: data.details,
status: data.response?.code ?? null,
},
);
}
if (!data.fatal || !current()) return;
if (parsed && data.type === Hls.ErrorTypes.NETWORK_ERROR && data.response?.code === 404) {
const now = Date.now();
@@ -611,6 +629,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// deliberate pause isn't a stall, and would otherwise leave the spinner stuck).
const onWaiting = () => setBuffering(true);
const onResumed = () => setBuffering(false);
const detachLog = logMediaElement(video, "plex");
video.addEventListener("timeupdate", onTime);
video.addEventListener("play", onPlay);
video.addEventListener("pause", onPause);
@@ -625,6 +644,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
video.addEventListener("seeked", onResumed);
video.addEventListener("pause", onResumed);
return () => {
detachLog();
video.removeEventListener("timeupdate", onTime);
video.removeEventListener("play", onPlay);
video.removeEventListener("pause", onPause);
+7
View File
@@ -18,6 +18,7 @@ import {
type PlexUrlState,
} from "../lib/plexUrlState";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { logFilterChange } from "../lib/filterLog";
import { useUrlMirror } from "../lib/useUrlMirror";
import { useNavigation } from "./NavigationProvider";
@@ -219,27 +220,33 @@ export function PlexProvider({ children }: { children: ReactNode }) {
}, [urlSeed]);
const setScope = useCallback((v: string) => {
logFilterChange("plex", { scope: v });
setScopeState(v);
setAccountRaw(LS.plexLibrary, v);
}, []);
const setShow = useCallback((v: string) => {
logFilterChange("plex", { show: v });
setShowState(v);
setAccountRaw(LS.plexShow, v);
}, []);
const setSort = useCallback((v: SortState) => {
logFilterChange("plex", { sort: v });
setSortState(v);
setAccountRaw(LS.plexSort, JSON.stringify(v));
}, []);
const setFilters = useCallback((f: PlexFilters) => {
logFilterChange("plex", { filters: f });
const s = JSON.stringify(f);
setFiltersRaw(s);
setAccountRaw(LS.plexFilters, s);
}, []);
const setQ = useCallback((v: string) => {
logFilterChange("plex", {}, v);
setQState(v);
setAccountRaw(LS.plexQ, v);
}, []);
const setField = useCallback((v: PlexSearchField) => {
logFilterChange("plex", { field: v });
setFieldState(v);
setAccountRaw(LS.plexField, v);
}, []);
+9 -5
View File
@@ -3,7 +3,8 @@ import { withActiveAccount } from "../lib/activeAccount";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
import { Activity, Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
import DiagnosticsSettings from "./DiagnosticsSettings";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, HttpError, type Me } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
@@ -39,11 +40,12 @@ export interface PrefsController {
saveState: PrefsSaveState;
}
type TabId = "appearance" | "notifications" | "account";
type TabId = "appearance" | "notifications" | "account" | "diagnostics";
const TABS: { id: TabId; icon: typeof Monitor }[] = [
{ id: "appearance", icon: Monitor },
{ id: "notifications", icon: Bell },
{ id: "account", icon: User },
{ id: "diagnostics", icon: Activity },
];
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
@@ -65,11 +67,12 @@ export default function SettingsPanel() {
return (
<div className="p-4 max-w-3xl w-full mx-auto">
<div className="glass rounded-2xl overflow-hidden">
<div className="flex">
{/* Vertical tab rail — never wraps; active is a clear accent pill. */}
<div className="flex flex-col sm:flex-row">
{/* Tab rail: a scrolling row above the content on a phone, the side rail from `sm` up (see
Tabs). Active is a clear accent pill. */}
<Tabs
orientation="vertical"
className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1"
className="flex gap-1 overflow-x-auto border-b border-border/60 p-2 sm:w-36 sm:shrink-0 sm:flex-col sm:border-b-0 sm:border-r"
active={tab}
onChange={setTab}
tabs={TABS.map((tabItem) => ({
@@ -87,6 +90,7 @@ export default function SettingsPanel() {
{tab === "appearance" && <Appearance prefs={prefs} />}
{tab === "notifications" && <Notifications prefs={prefs} />}
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
{tab === "diagnostics" && <DiagnosticsSettings />}
</div>
</div>
+54 -19
View File
@@ -2,10 +2,19 @@
// reachable unauthenticated — Google's OAuth consent screen links to them and reviewers
// fetch them directly — so they render outside the authenticated App tree (see main.tsx),
// with no react-query provider: the operator contact is fetched with a bare `send` below.
import { useEffect, useState } from "react";
//
// Both pages exist in Hungarian and English, like the rest of the UI. The prose is a document, not
// a set of UI strings, so each language's body is its own component (see PrivacyPolicy/Terms) and
// `legalBodies.contract.dom.test.tsx` holds the two to the same sections; the chrome around them is i18n.
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { LANGUAGES, setLanguage } from "../../i18n";
import { formatDate } from "../../lib/format";
import { send } from "../../lib/http";
const LAST_UPDATED = "June 14, 2026";
// The day the text last changed materially — bump it with every edit of either body.
const LAST_UPDATED = "2026-09-17";
// Contact shown on the legal pages: the instance operator's configured admin email, from the
// public /auth/config. One shared fetch across the page; null when the operator set none.
@@ -34,8 +43,9 @@ function useOperatorContact(): string | null {
// The operator's contact rendered as a mailto link, or a neutral fallback phrase when unset.
export function ContactEmail() {
const { t } = useTranslation();
const email = useOperatorContact();
if (!email) return <>your instance&apos;s administrator</>;
if (!email) return <>{t("legal.operatorFallback")}</>;
return (
<a href={`mailto:${email}`} className="text-accent hover:underline">
{email}
@@ -43,7 +53,7 @@ export function ContactEmail() {
);
}
export function LegalLink({ href, children }: { href: string; children: React.ReactNode }) {
export function LegalLink({ href, children }: { href: string; children: ReactNode }) {
return (
<a href={href} target="_blank" rel="noreferrer" className="text-accent hover:underline">
{children}
@@ -51,38 +61,57 @@ export function LegalLink({ href, children }: { href: string; children: React.Re
);
}
export default function LegalLayout({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
export default function LegalLayout({ title, children }: { title: string; children: ReactNode }) {
const { t, i18n } = useTranslation();
const contact = useOperatorContact();
// A link to a section (`/privacy#diagnostics`): the browser tries to scroll to the fragment before
// this lazily loaded page has rendered it, so jump there once the section exists.
useEffect(() => {
const id = decodeURIComponent(window.location.hash.slice(1));
if (id) document.getElementById(id)?.scrollIntoView();
}, []);
return (
<div className="min-h-screen bg-bg text-fg">
<div className="mx-auto w-[min(92vw,720px)] py-12">
<a href="/" className="inline-block text-2xl font-bold tracking-tight mb-8">
<div className="mb-8 flex items-center justify-between gap-4">
<a href="/" className="inline-block text-2xl font-bold tracking-tight">
Sift<span className="text-accent">lode</span>
</a>
<div role="group" aria-label={t("legal.languageLabel")} className="flex gap-1 text-xs">
{LANGUAGES.map((lang) => (
<button
key={lang.code}
type="button"
lang={lang.code}
aria-pressed={i18n.language?.startsWith(lang.code) ?? false}
onClick={() => setLanguage(lang.code)}
className="rounded-md px-2 py-1 text-muted hover:text-fg aria-pressed:bg-card aria-pressed:text-fg"
>
{lang.label}
</button>
))}
</div>
</div>
<div className="glass rounded-2xl p-8">
<h1 className="text-2xl font-semibold">{title}</h1>
<p className="text-xs text-muted mt-1 mb-6">Last updated: {LAST_UPDATED}</p>
<p className="text-xs text-muted mt-1 mb-6">
{t("legal.lastUpdated", { date: formatDate(LAST_UPDATED, i18n.language) })}
</p>
<div className="space-y-5 text-sm leading-relaxed text-fg/90">{children}</div>
</div>
<footer className="mt-6 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted">
<a href="/" className="hover:text-fg transition">
Home
{t("legal.home")}
</a>
<a href="/privacy" className="hover:text-fg transition">
Privacy Policy
{t("common.privacyPolicy")}
</a>
<a href="/terms" className="hover:text-fg transition">
Terms of Service
{t("common.termsOfService")}
</a>
{contact && (
<a href={`mailto:${contact}`} className="hover:text-fg transition">
Contact
{t("legal.contact")}
</a>
)}
</footer>
@@ -91,6 +120,12 @@ export default function LegalLayout({
);
}
export function H2({ children }: { children: React.ReactNode }) {
return <h2 className="text-base font-semibold text-fg pt-2">{children}</h2>;
/** A section heading. `id` is required: it is the anchor a link can point at (Settings →
* Diagnosztika links `/privacy#diagnostics`), and what holds the two languages to the same sections. */
export function H2({ id, children }: { id: string; children: ReactNode }) {
return (
<h2 id={id} className="text-base font-semibold text-fg pt-2 scroll-mt-6">
{children}
</h2>
);
}
+229 -26
View File
@@ -1,8 +1,26 @@
import { useTranslation } from "react-i18next";
import LegalLayout, { ContactEmail, H2, LegalLink } from "./LegalLayout";
import { useHungarian } from "./useHungarian";
// Bold lead-in for a list item.
const Lead = ({ children }: { children: React.ReactNode }) => (
<span className="text-fg font-medium">{children}</span>
);
export default function PrivacyPolicy() {
const { t } = useTranslation();
const hu = useHungarian();
return (
<LegalLayout title="Privacy Policy">
<LegalLayout title={t("common.privacyPolicy")}>
{hu ? <PrivacyHu /> : <PrivacyEn />}
</LegalLayout>
);
}
export function PrivacyEn() {
return (
<>
<p>
Siftlode is a personal, non-commercial project operated by an individual in Hungary ("we",
"us"). It lets you view and organize your own YouTube subscriptions as a filterable feed.
@@ -10,22 +28,23 @@ export default function PrivacyPolicy() {
By using Siftlode you agree to this policy.
</p>
<H2>What we access</H2>
<p>Siftlode only accesses data you explicitly authorize, in two separate steps:</p>
<H2 id="access">What we access</H2>
<p>Siftlode only accesses data you explicitly authorize:</p>
<ul className="list-disc pl-5 space-y-1">
<li>
<span className="text-fg font-medium">Sign-in (always):</span> your basic Google profile —
name, email address, profile picture, and Google account identifier — used solely to
create and identify your account.
<Lead>Sign-in (always):</Lead> your email address — and, when you sign in with Google,
your basic Google profile (name, profile picture, Google account identifier) — used solely
to create and identify your account. Signing in with an email and password stores only a
one-way hash of the password.
</li>
<li>
<span className="text-fg font-medium">YouTube read (optional, opt-in):</span> with the{" "}
<Lead>YouTube read (optional, opt-in):</Lead> with the{" "}
<code className="text-xs">youtube.readonly</code> scope, your list of YouTube
subscriptions and the public metadata of those channels and their videos (titles,
thumbnails, durations, view counts, publish dates), used to build your feed.
</li>
<li>
<span className="text-fg font-medium">YouTube manage (optional, opt-in):</span> with the{" "}
<Lead>YouTube manage (optional, opt-in):</Lead> with the{" "}
<code className="text-xs">youtube</code> scope, the ability to unsubscribe from a channel
on YouTube — performed only when you click to do so.
</li>
@@ -35,7 +54,7 @@ export default function PrivacyPolicy() {
independently.
</p>
<H2>How we use it</H2>
<H2 id="use">How we use it</H2>
<p>
Your data is used exclusively to provide Siftlode's features to you: building and displaying
your personalized subscription feed, and carrying out actions (such as unsubscribing) that
@@ -43,7 +62,42 @@ export default function PrivacyPolicy() {
app.
</p>
<H2>Limited Use disclosure</H2>
<H2 id="logs">Server logs</H2>
<p>
To keep the service running and to find faults, the server records each request it answers:
which part of the app was called (for example <code className="text-xs">/api/feed</code>,
never the search terms or other values in the address), whether it succeeded, how long it
took, and which account made it. Warnings and errors are recorded too. Your IP address is
not stored in these records. They are kept for the retention period the operator sets — 7
days by default, at most 90 — and then deleted automatically.
</p>
<H2 id="diagnostics">Diagnostic logging (optional, with your consent)</H2>
<p>
In Settings → Diagnostics you can see the log your own browser tab keeps. By default it
stays in that tab and is gone when the page reloads. If you choose the <Lead>Detailed</Lead>{" "}
or <Lead>Full</Lead> level and confirm, for the next 24 hours:
</p>
<ul className="list-disc pl-5 space-y-1">
<li>
your browser also records which buttons, filters and player controls you use and the
values in the address bar — and, at Full, the text you type into search;
</li>
<li>
that log is sent to the server and stored with your account alongside the server's more
detailed record of your requests, for the same retention period as the server logs;
</li>
<li>
passwords, tokens, email addresses and the content of your messages are never included, at
any level.
</li>
</ul>
<p>
After 24 hours the level returns to the default by itself, and you can return to it earlier
at any time. It is used only to diagnose problems with Siftlode.
</p>
<H2 id="limited-use">Limited Use disclosure</H2>
<p>
Siftlode's use and transfer of information received from Google APIs to any other app will
adhere to the{" "}
@@ -55,32 +109,35 @@ export default function PrivacyPolicy() {
control), and do not use it for advertising.
</p>
<H2>Storage &amp; security</H2>
<H2 id="storage">Storage &amp; security</H2>
<p>
Data is stored in a private PostgreSQL database on a server we control, accessible only to
the operator. Google OAuth refresh tokens are encrypted at rest. Access is restricted to the
emails explicitly invited to the app. No system is perfectly secure, but we take reasonable
measures to protect your data.
the operator. Google OAuth refresh tokens are encrypted at rest. Private messages between
users are end-to-end encrypted in the browser: the server stores only the encrypted text and
cannot read it. Access is restricted to the email addresses admitted to the app. No system
is perfectly secure, but we take reasonable measures to protect your data.
</p>
<H2>Retention &amp; deletion</H2>
<H2 id="retention">Retention &amp; deletion</H2>
<p>
We keep your data while your account is active. You can revoke Siftlode's access to your
Google data at any time at{" "}
We keep your data while your account is active. You can delete your account and its data
yourself at any time in Settings → Account, which also removes the stored log records of
your account. You can revoke Siftlode's access to your Google data at{" "}
<LegalLink href="https://myaccount.google.com/permissions">
myaccount.google.com/permissions
</LegalLink>
; once revoked, the stored tokens can no longer be used. To have your account and associated
data deleted, contact <ContactEmail />.
; once revoked, the stored tokens can no longer be used. For anything else, contact{" "}
<ContactEmail />.
</p>
<H2>Cookies</H2>
<H2 id="cookies">Cookies &amp; browser storage</H2>
<p>
Siftlode sets a single, signed session cookie to keep you logged in. It is not used for
tracking or advertising, and there are no third-party analytics cookies.
Siftlode sets a single, signed session cookie to keep you logged in, and keeps your display
preferences in your browser's local storage. Neither is used for tracking or advertising,
and there are no third-party analytics cookies.
</p>
<H2>YouTube &amp; Google</H2>
<H2 id="google">YouTube &amp; Google</H2>
<p>
Siftlode uses YouTube API Services. By using Siftlode you also agree to the{" "}
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>, and
@@ -88,16 +145,162 @@ export default function PrivacyPolicy() {
<LegalLink href="https://policies.google.com/privacy">Google Privacy Policy</LegalLink>.
</p>
<H2>Changes</H2>
<H2 id="changes">Changes</H2>
<p>
We may update this policy; material changes will be reflected by the "Last updated" date
above. Continued use after a change constitutes acceptance.
</p>
<H2>Contact</H2>
<H2 id="contact">Contact</H2>
<p>
Questions about this policy or your data: contact <ContactEmail />.
</p>
</LegalLayout>
</>
);
}
export function PrivacyHu() {
return (
<>
<p>
A Siftlode egy magánszemély által üzemeltetett, nem kereskedelmi célú személyes projekt
Magyarországon („mi”). Lehetővé teszi, hogy a saját YouTube-feliratkozásaidat szűrhető
hírfolyamként nézd és rendszerezd. Ez a tájékoztató leírja, milyen adatokhoz fér hozzá a
Siftlode, mire használja őket, és milyen lehetőségeid vannak. A Siftlode használatával
elfogadod ezt a tájékoztatót.
</p>
<H2 id="access">Mihez férünk hozzá</H2>
<p>A Siftlode csak azokhoz az adatokhoz fér hozzá, amelyekhez kifejezetten hozzájárulsz:</p>
<ul className="list-disc pl-5 space-y-1">
<li>
<Lead>Bejelentkezés (mindig):</Lead> az e-mail-címed, Google-bejelentkezésnél pedig az
alapvető Google-profilod is (név, profilkép, Google-fiókazonosító), kizárólag a fiókod
létrehozásához és azonosításához. E-mail-címmel és jelszóval bejelentkezve csak a jelszó
egyirányú lenyomatát tároljuk.
</li>
<li>
<Lead>YouTube olvasás (nem kötelező, külön engedélyezed):</Lead> a{" "}
<code className="text-xs">youtube.readonly</code> jogosultsággal a YouTube-feliratkozásaid
listája, valamint ezeknek a csatornáknak és videóiknak a nyilvános adatai (cím, bélyegkép,
hossz, megtekintésszám, megjelenés dátuma) a hírfolyamod felépítéséhez.
</li>
<li>
<Lead>YouTube kezelés (nem kötelező, külön engedélyezed):</Lead> a{" "}
<code className="text-xs">youtube</code> jogosultsággal leiratkozhatsz egy csatornáról a
YouTube-on — csak akkor történik meg, ha erre kattintasz.
</li>
</ul>
<p>
A Siftlode puszta bejelentkezéssel is használható, és a YouTube-jogosultságokat egymástól
függetlenül adhatod meg vagy utasíthatod el.
</p>
<H2 id="use">Mire használjuk</H2>
<p>
Az adataidat kizárólag a Siftlode funkcióinak nyújtására használjuk: a személyre szabott
hírfolyamod felépítésére és megjelenítésére, valamint az általad kezdeményezett műveletek
(például leiratkozás) végrehajtására. Nem használjuk hirdetésre, profilalkotásra vagy az
alkalmazástól független célra.
</p>
<H2 id="logs">Szervernapló</H2>
<p>
A szolgáltatás működtetéséhez és a hibák felderítéséhez a szerver minden megválaszolt
kérésről feljegyzi: az alkalmazás melyik részét hívták (például{" "}
<code className="text-xs">/api/feed</code>, soha nem a keresőszót vagy más értéket a
címből), sikerült-e, mennyi ideig tartott, és melyik fiók indította. A figyelmeztetések és a
hibák is bekerülnek. Az IP-címedet ezek a feljegyzések nem tárolják. Az üzemeltető által
beállított ideig őrizzük őket — alapból 7 napig, legfeljebb 90 napig —, utána automatikusan
törlődnek.
</p>
<H2 id="diagnostics">Diagnosztikai naplózás (nem kötelező, a beleegyezéseddel)</H2>
<p>
A Beállítások → Diagnosztika fülön láthatod a saját böngészőlapod naplóját. Alapból ez csak
abban a lapban marad, és az oldal újratöltésekor törlődik. Ha a <Lead>Részletes</Lead> vagy
a <Lead>Teljes</Lead> szintet választod és megerősíted, a következő 24 órában:
</p>
<ul className="list-disc pl-5 space-y-1">
<li>
a böngésződ azt is rögzíti, melyik gombot, szűrőt és lejátszóvezérlőt használod, és a
címsor paramétereinek értékét — a Teljes szinten a keresőbe beírt szöveget is;
</li>
<li>
ezt a naplót elküldi a szervernek, amely a fiókodhoz tárolja, a kéréseid részletesebb
szerveroldali naplójával együtt, a szervernapló megőrzési idejéig;
</li>
<li>jelszó, token, e-mail-cím és az üzeneteid tartalma egyik szinten sem kerül bele.</li>
</ul>
<p>
24 óra múlva a szint magától visszaáll az alapra, és bármikor korábban is visszaállíthatod.
Kizárólag a Siftlode hibáinak felderítésére használjuk.
</p>
<H2 id="limited-use">Korlátozott felhasználás (Limited Use)</H2>
<p>
A Siftlode a Google API-kból kapott adatok felhasználása és más alkalmazásnak történő
átadása során betartja a{" "}
<LegalLink href="https://developers.google.com/terms/api-services-user-data-policy">
Google API Services User Data Policy
</LegalLink>{" "}
előírásait, beleértve a Limited Use követelményeit. Nem adjuk el az adataidat, nem adjuk át
harmadik félnek (kivéve, ami az alkalmazás működtetéséhez szükséges, például az általunk
kezelt tárhely-infrastruktúra), és nem használjuk hirdetésre.
</p>
<H2 id="storage">Tárolás és biztonság</H2>
<p>
Az adatokat egy általunk kezelt szerveren, privát PostgreSQL-adatbázisban tároljuk, amelyhez
csak az üzemeltető fér hozzá. A Google OAuth frissítő tokeneket titkosítva tároljuk. A
felhasználók közötti privát üzenetek végponttól végpontig titkosítottak a böngészőben: a
szerver csak a titkosított szöveget tárolja, és nem tudja elolvasni. Csak az alkalmazásba
beengedett e-mail-címek férnek hozzá. Egyetlen rendszer sem tökéletesen biztonságos, de
észszerű intézkedésekkel védjük az adataidat.
</p>
<H2 id="retention">Megőrzés és törlés</H2>
<p>
Az adataidat addig őrizzük, amíg a fiókod aktív. A fiókodat és az adataidat bármikor magad
is törölheted a Beállítások → Fiók fülön; ez a fiókodhoz tárolt naplófeljegyzéseket is
törli. A Siftlode hozzáférését a Google-adataidhoz a{" "}
<LegalLink href="https://myaccount.google.com/permissions">
myaccount.google.com/permissions
</LegalLink>{" "}
oldalon vonhatod vissza; ezután a tárolt tokenek már nem használhatók. Minden más ügyben
fordulj ide: <ContactEmail />.
</p>
<H2 id="cookies">Sütik és böngészőtár</H2>
<p>
A Siftlode egyetlen aláírt munkamenet-sütit használ, hogy bejelentkezve maradj, a
megjelenítési beállításaidat pedig a böngésződ helyi tárában tartja. Egyiket sem használjuk
követésre vagy hirdetésre, és nincsenek harmadik féltől származó analitikai sütik.
</p>
<H2 id="google">YouTube és Google</H2>
<p>
A Siftlode a YouTube API szolgáltatásait használja. A Siftlode használatával elfogadod a{" "}
<LegalLink href="https://www.youtube.com/t/terms">
YouTube Általános Szerződési Feltételeit
</LegalLink>
, a Google-adataid kezelésére pedig a{" "}
<LegalLink href="https://policies.google.com/privacy">
Google Adatvédelmi irányelvei
</LegalLink>{" "}
vonatkoznak.
</p>
<H2 id="changes">Változások</H2>
<p>
Ezt a tájékoztatót módosíthatjuk; a lényeges változásokat a fenti „Utoljára frissítve” dátum
jelzi. Ha a változás után is használod a szolgáltatást, azzal elfogadod.
</p>
<H2 id="contact">Kapcsolat</H2>
<p>
Kérdés a tájékoztatóval vagy az adataiddal kapcsolatban: <ContactEmail />.
</p>
</>
);
}
+96 -13
View File
@@ -1,30 +1,41 @@
import { useTranslation } from "react-i18next";
import LegalLayout, { ContactEmail, H2, LegalLink } from "./LegalLayout";
import { useHungarian } from "./useHungarian";
export default function Terms() {
const { t } = useTranslation();
const hu = useHungarian();
return (
<LegalLayout title="Terms of Service">
<LegalLayout title={t("common.termsOfService")}>{hu ? <TermsHu /> : <TermsEn />}</LegalLayout>
);
}
export function TermsEn() {
return (
<>
<p>
Siftlode is a personal, non-commercial project operated by an individual in Hungary. It
provides a filterable view of your own YouTube subscriptions. By accessing or using
Siftlode, you agree to these Terms. If you do not agree, do not use the service.
</p>
<H2>The service</H2>
<H2 id="service">The service</H2>
<p>
Siftlode is offered on an invite-only basis, free of charge, and "as is", without warranties
of any kind. It is a hobby project: features may change, and the service may be slowed,
interrupted, or discontinued at any time without notice.
</p>
<H2>Your account &amp; acceptable use</H2>
<H2 id="account">Your account &amp; acceptable use</H2>
<p>
You sign in with your Google account and may only access Siftlode with an email that has
been invited. You agree to use the service lawfully, not to abuse, disrupt, probe, or
overload it, and not to attempt to access other users' data. We may suspend or revoke access
at our discretion, for example in case of misuse.
You sign in with your Google account or with an email address and password, and may only
access Siftlode with an email that has been admitted. You agree to use the service lawfully,
not to abuse, disrupt, probe, or overload it, and not to attempt to access other users'
data. We may suspend or revoke access at our discretion, for example in case of misuse.
</p>
<H2>Third-party services</H2>
<H2 id="third-party">Third-party services</H2>
<p>
Siftlode uses YouTube API Services. By using Siftlode you agree to be bound by the{" "}
<LegalLink href="https://www.youtube.com/t/terms">YouTube Terms of Service</LegalLink>. Your
@@ -36,7 +47,7 @@ export default function Terms() {
<LegalLink href="https://policies.google.com/privacy">Google Privacy Policy</LegalLink>.
</p>
<H2>No warranty &amp; limitation of liability</H2>
<H2 id="liability">No warranty &amp; limitation of liability</H2>
<p>
To the maximum extent permitted by law, the service is provided without warranty, and the
operator is not liable for any indirect, incidental, or consequential damages, or for any
@@ -44,22 +55,94 @@ export default function Terms() {
to stop using the service and revoke its access to your account.
</p>
<H2>Governing law</H2>
<H2 id="law">Governing law</H2>
<p>
These Terms are governed by the laws of Hungary and applicable European Union law, without
regard to conflict-of-law rules.
</p>
<H2>Changes</H2>
<H2 id="changes">Changes</H2>
<p>
We may update these Terms; the "Last updated" date above reflects the latest version.
Continued use after a change constitutes acceptance.
</p>
<H2>Contact</H2>
<H2 id="contact">Contact</H2>
<p>
Questions: contact <ContactEmail />.
</p>
</LegalLayout>
</>
);
}
export function TermsHu() {
return (
<>
<p>
A Siftlode egy magánszemély által üzemeltetett, nem kereskedelmi célú személyes projekt
Magyarországon. A saját YouTube-feliratkozásaid szűrhető nézetét nyújtja. A Siftlode
elérésével vagy használatával elfogadod ezeket a feltételeket. Ha nem fogadod el őket, ne
használd a szolgáltatást.
</p>
<H2 id="service">A szolgáltatás</H2>
<p>
A Siftlode csak meghívással, ingyenesen és „ahogy van” alapon érhető el, mindenféle
szavatosság nélkül. Hobbiprojekt: a funkciói változhatnak, és a szolgáltatás bármikor,
előzetes értesítés nélkül lelassulhat, szünetelhet vagy megszűnhet.
</p>
<H2 id="account">A fiókod és az elfogadható használat</H2>
<p>
Google-fiókkal vagy e-mail-címmel és jelszóval jelentkezel be, és csak beengedett
e-mail-címmel használhatod a Siftlode-ot. Vállalod, hogy a szolgáltatást jogszerűen
használod, nem éled vissza, nem zavarod meg, nem próbálod ki a gyenge pontjait és nem
terheled túl, és nem próbálsz más felhasználók adataihoz hozzáférni. A hozzáférést saját
belátásunk szerint felfüggeszthetjük vagy visszavonhatjuk, például visszaélés esetén.
</p>
<H2 id="third-party">Harmadik fél szolgáltatásai</H2>
<p>
A Siftlode a YouTube API szolgáltatásait használja. A Siftlode használatával elfogadod a{" "}
<LegalLink href="https://www.youtube.com/t/terms">
YouTube Általános Szerződési Feltételeit
</LegalLink>
. Az adataidat az{" "}
<a href="/privacy" className="text-accent hover:underline">
Adatvédelmi irányelvekben
</a>{" "}
leírtak szerint kezeljük, a Google-adatokra pedig a{" "}
<LegalLink href="https://policies.google.com/privacy">
Google Adatvédelmi irányelvei
</LegalLink>{" "}
is vonatkoznak.
</p>
<H2 id="liability">Szavatosság kizárása és a felelősség korlátozása</H2>
<p>
A jogszabályok által megengedett legteljesebb mértékben a szolgáltatás szavatosság nélkül
érhető el, és az üzemeltető nem felel a Siftlode használatából eredő közvetett, járulékos
vagy következményes károkért, sem adatvesztésért. Ha elégedetlen vagy, az egyetlen
lehetőséged, hogy abbahagyod a szolgáltatás használatát, és visszavonod a hozzáférését a
fiókodhoz.
</p>
<H2 id="law">Irányadó jog</H2>
<p>
Ezekre a feltételekre Magyarország jogszabályai és az alkalmazandó európai uniós jog
irányadók, a kollíziós szabályok figyelmen kívül hagyásával.
</p>
<H2 id="changes">Változások</H2>
<p>
Ezeket a feltételeket módosíthatjuk; a fenti „Utoljára frissítve” dátum a legfrissebb
változatot jelzi. Ha a változás után is használod a szolgáltatást, azzal elfogadod.
</p>
<H2 id="contact">Kapcsolat</H2>
<p>
Kérdés esetén: <ContactEmail />.
</p>
</>
);
}
@@ -0,0 +1,36 @@
// @vitest-environment jsdom
//
// The legal pages are written out once per language (a document, not UI strings — see LegalLayout).
// What keeps the two from drifting apart is this: both languages must have the SAME sections, in the
// same order, by anchor — so a section added to one (E22 S5 added the log sections) cannot be
// missing from the other, and a link to `/privacy#diagnostics` lands in either language.
import { describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import type { ComponentType } from "react";
vi.mock("../../lib/http", () => ({ send: () => Promise.resolve({ ok: false }) }));
import { PrivacyEn, PrivacyHu } from "./PrivacyPolicy";
import { TermsEn, TermsHu } from "./Terms";
const sections = (Body: ComponentType) => {
const { container } = render(<Body />);
const ids = [...container.querySelectorAll("h2")].map((h) => h.id);
cleanup();
return ids;
};
describe("legal pages", () => {
it.each([
["privacy", PrivacyEn, PrivacyHu],
["terms", TermsEn, TermsHu],
])("%s has the same sections in Hungarian and English", (_name, En, Hu) => {
const en = sections(En);
expect(en.every(Boolean)).toBe(true);
expect(sections(Hu)).toEqual(en);
});
it("the privacy policy has the section the diagnostics consent links to", () => {
expect(sections(PrivacyEn)).toContain("diagnostics");
});
});
@@ -0,0 +1,7 @@
import { useTranslation } from "react-i18next";
/** Whether a legal page renders its Hungarian body (see LegalLayout). */
export function useHungarian(): boolean {
const { i18n } = useTranslation();
return i18n.language?.startsWith("hu") ?? false;
}
+9 -4
View File
@@ -4,8 +4,11 @@ import CountBadge from "./CountBadge";
// Reusable pill tab-rail for in-page sub-navigation (Configuration, Users & roles, the Settings
// sections, the Stats overview/system split…). Horizontal by default; `orientation="vertical"` gives
// the Settings-style side rail (full-width, left-aligned, icon + label). The active tab is persisted
// to localStorage so a reload keeps the user where they were.
// the Settings-style side rail (full-width, left-aligned, icon + label) — from the `sm` breakpoint up.
// Below it a side rail leaves a phone too little width for the content beside it (measured at 390 px:
// the rail clipped its own labels and Settings → Account pushed its buttons over their text), so there
// it is a horizontal row that scrolls. The active tab is persisted to localStorage so a reload keeps
// the user where they were.
export interface TabDef {
id: string;
@@ -36,7 +39,9 @@ export default function Tabs({
className?: string;
}) {
const vertical = orientation === "vertical";
const wrapCls = className ?? (vertical ? "flex flex-col gap-1" : "flex flex-wrap gap-1 mb-4");
const wrapCls =
className ??
(vertical ? "flex gap-1 overflow-x-auto sm:flex-col" : "flex flex-wrap gap-1 mb-4");
return (
<div className={wrapCls}>
{tabs.map((tb) => {
@@ -50,7 +55,7 @@ export default function Tabs({
aria-pressed={on}
className={`pill items-center rounded-lg px-3 text-sm ${
vertical
? "flex w-full justify-start gap-2 py-2 text-left"
? "inline-flex shrink-0 gap-1.5 py-1.5 sm:flex sm:w-full sm:justify-start sm:gap-2 sm:py-2 sm:text-left"
: "inline-flex gap-1.5 py-1.5"
} ${on ? "font-medium shadow-md shadow-accent/20" : "hover:bg-card/60"}`}
>
+26
View File
@@ -0,0 +1,26 @@
// @vitest-environment jsdom
//
// E22 S5: a clicked control's label is logged only when it is the app's own copy — as its English
// wording — so nothing a user wrote (a title, a tag, a channel name) can come out of the click log.
import { describe, expect, it } from "vitest";
import { appCopyOf } from "./appCopy";
describe("appCopyOf", () => {
it("finds app copy in either language and reads it in English, whatever the whitespace", () => {
expect(appCopyOf("Turn on for 24 hours")).toBe("Turn on for 24 hours");
expect(appCopyOf(" Bekapcsolom\n 24 órára ")).toBe("Turn on for 24 hours");
});
it("a wording several keys share still names the wording, not one arbitrary key", () => {
// "Diagnosztika" is both the Settings tab and the Configuration group.
expect(appCopyOf("Diagnosztika")).toBe("Diagnostics");
});
it("finds nothing in what a user wrote, and never matches an interpolated string", () => {
expect(appCopyOf("My holiday video 2024")).toBeNull();
expect(appCopyOf("")).toBeNull();
expect(appCopyOf(null)).toBeNull();
expect(appCopyOf("{{count}} events · newest first")).toBeNull();
});
});
+43
View File
@@ -0,0 +1,43 @@
import i18n from "./index";
// The translation KEY a piece of on-screen text is, or null (E22 S5 click capture).
//
// A click is logged by what the control IS, and its visible label is the most telling part — but a
// label can be the user's own content (a video title, a tag, a channel name), which the log must not
// carry. Text that is exactly one of the app's own translated strings is app copy, not user content,
// so it can be logged — as its ENGLISH wording, whatever the UI language: the same click reads the
// same in every log, and nothing a user typed comes out of it (a user's tag that happens to equal a
// string of ours is logged as our string, which says nothing about the user). Not as a key: one
// wording is often several keys ("Diagnostics" is a settings tab AND a config group), so a key would
// name a control the user may never have touched. Interpolated strings (`{{n}} items`) never match.
let index: Map<string, string> | null = null;
const normalize = (text: string) => text.replace(/\s+/g, " ").trim();
/** Every app string (any language) → its English wording. */
function build(): Map<string, string> {
const map = new Map<string, string>();
const resources = i18n.options.resources ?? {};
const english = (path: string[]): string | null => {
let node: unknown = resources.en?.translation;
for (const key of path) node = (node as Record<string, unknown> | undefined)?.[key];
return typeof node === "string" ? normalize(node) : null;
};
const walk = (node: unknown, path: string[]) => {
if (typeof node === "string") {
const text = normalize(node);
const en = english(path);
if (en && text.length >= 2 && !text.includes("{{") && !map.has(text)) map.set(text, en);
} else if (node && typeof node === "object") {
for (const [k, v] of Object.entries(node)) walk(v, [...path, k]);
}
};
for (const lang of Object.keys(resources)) walk(resources[lang]?.translation, []);
return map;
}
export function appCopyOf(text: string | null | undefined): string | null {
if (!text) return null;
index ??= build();
return index.get(normalize(text)) ?? null;
}
+7
View File
@@ -0,0 +1,7 @@
{
"lastUpdated": "Last updated: {{date}}",
"home": "Home",
"contact": "Contact",
"operatorFallback": "your instance's administrator",
"languageLabel": "Language"
}
+58 -1
View File
@@ -3,7 +3,8 @@
"tabs": {
"appearance": "Appearance",
"notifications": "Notifications",
"account": "Account"
"account": "Account",
"diagnostics": "Diagnostics"
},
"plexSync": {
"title": "Plex watch sync",
@@ -190,5 +191,61 @@
"emailOff": "Email sending is not configured, so nobody gets a verification link and no request reaches you by email. Watch this list and your inbox instead, and fix it under Configuration → Email / SMTP.",
"deletedStillAllowed": "Entry removed, but this address is still allow-listed by the server's configuration, so it keeps access. Remove it from ALLOWED_EMAILS / ADMIN_EMAILS to change that.",
"deleteConfirmStillAllowed": "Forget {{email}}? Careful: this address is also allow-listed in the server's configuration (ALLOWED_EMAILS / ADMIN_EMAILS), so removing this row will NOT take its access away — and an ADMIN_EMAILS address is made an admin again on its next Google sign-in."
},
"diagnostics": {
"title": "Logging level",
"level": "Level",
"levelHint": "At Basic, the log lives only in this browser tab — errors, warnings, page changes and requests — and nothing is sent to the server. Detailed and Full record more and send it to the server for 24 hours, only with your consent.",
"levels": {
"INFO": "Basic",
"DEBUG": "Detailed",
"TRACE": "Full"
},
"consent": {
"title": {
"DEBUG": "Turn on detailed logging for 24 hours",
"TRACE": "Turn on full logging for 24 hours"
},
"what": {
"DEBUG": "We record which buttons, filters and player controls you use, and the values in the address bar.",
"TRACE": "We record which buttons, filters and player controls you use, the values in the address bar, and the text you type into search."
},
"server_one": "The Siftlode server receives this too and keeps it for {{count}} day, so the problem can be tracked down.",
"server_other": "The Siftlode server receives this too and keeps it for {{count}} days, so the problem can be tracked down.",
"never": "Passwords, tokens, email addresses and the content of your messages are never included.",
"lapse": "After 24 hours it goes back to Basic by itself.",
"policy": "Privacy policy",
"cancel": "Cancel",
"confirm": "Turn on for 24 hours"
},
"status": "{{level}} logging · back to Basic in {{time}}",
"hoursMinutes": "{{h}} h {{m}} min",
"minutes": "{{m}} min",
"revert": "Back to Basic now",
"demo": "The demo account is shared by several people, so detailed logging can't be turned on here.",
"failed": "Couldn't change the logging level"
},
"myLog": {
"title": "My log",
"hint": "What this browser tab has recorded. It is gone when the page reloads.",
"search": "Search the events…",
"filterLabel": "Filter by level",
"filters": {
"all": "All",
"error": "Errors",
"warning": "Warnings",
"info": "Info",
"detail": "Detailed"
},
"count_one": "{{count}} event · newest first",
"count_other": "{{count}} events · newest first",
"capped": "showing the newest {{shown}} of {{count}}",
"copy": "Copy",
"copied_one": "Copied {{count}} event",
"copied_other": "Copied {{count}} events",
"copyFailed": "Couldn't copy to the clipboard",
"clear": "Clear",
"empty": "No event matches the filter.",
"emptyAll": "No events yet."
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"lastUpdated": "Utoljára frissítve: {{date}}",
"home": "Kezdőlap",
"contact": "Kapcsolat",
"operatorFallback": "a példány adminisztrátora",
"languageLabel": "Nyelv"
}
+58 -1
View File
@@ -3,7 +3,8 @@
"tabs": {
"appearance": "Megjelenés",
"notifications": "Értesítések",
"account": "Fiók"
"account": "Fiók",
"diagnostics": "Diagnosztika"
},
"plexSync": {
"title": "Plex nézettség-szinkron",
@@ -190,5 +191,61 @@
"emailOff": "Az e-mail-küldés nincs beállítva, így senki nem kap megerősítő linket, és e-mailben egy kérelem sem jut el hozzád. Ezt a listát és az értesítéseidet figyeld helyette, a javítás pedig a Konfiguráció → E-mail / SMTP fülön van.",
"deletedStillAllowed": "A bejegyzés törölve, de ezt a címet a szerver konfigurációja továbbra is engedélyezi, tehát megmarad a hozzáférése. Vedd ki az ALLOWED_EMAILS / ADMIN_EMAILS listából, ha ezen változtatnál.",
"deleteConfirmStillAllowed": "Elfelejted {{email}} címet? Figyelem: ezt a címet a szerver konfigurációja is engedélyezi (ALLOWED_EMAILS / ADMIN_EMAILS), így a bejegyzés törlése NEM veszi el a hozzáférését — sőt egy ADMIN_EMAILS cím a következő Google-belépéskor újra adminná válik."
},
"diagnostics": {
"title": "Naplózási szint",
"level": "Szint",
"levelHint": "Alap szinten a napló csak ebben a böngészőlapban él (hibák, figyelmeztetések, oldalváltások, kérések), és semmi nem megy a szerverre. A Részletes és a Teljes többet rögzít, és 24 óráig a szervernek is elküldi, csak a beleegyezéseddel.",
"levels": {
"INFO": "Alap",
"DEBUG": "Részletes",
"TRACE": "Teljes"
},
"consent": {
"title": {
"DEBUG": "Részletes naplózás bekapcsolása 24 órára",
"TRACE": "Teljes naplózás bekapcsolása 24 órára"
},
"what": {
"DEBUG": "Rögzítjük, melyik gombot, szűrőt és lejátszóvezérlőt használod, és a címsor paramétereinek értékét.",
"TRACE": "Rögzítjük, melyik gombot, szűrőt és lejátszóvezérlőt használod, a címsor paramétereinek értékét, és a keresőbe beírt szöveget is."
},
"server_one": "Ezt a Siftlode szervere is megkapja, és {{count}} napig őrzi, hogy a hibát ki lehessen nyomozni.",
"server_other": "Ezt a Siftlode szervere is megkapja, és {{count}} napig őrzi, hogy a hibát ki lehessen nyomozni.",
"never": "Jelszó, token, e-mail-cím és az üzeneteid tartalma soha nem kerül bele.",
"lapse": "24 óra múlva magától visszaáll Alapra.",
"policy": "Adatvédelmi irányelvek",
"cancel": "Mégse",
"confirm": "Bekapcsolom 24 órára"
},
"status": "{{level}} naplózás · visszaáll Alapra {{time}} múlva",
"hoursMinutes": "{{h}} ó {{m}} p",
"minutes": "{{m}} p",
"revert": "Visszaállítás most",
"demo": "A demo fiókot többen használják, ezért itt nem kapcsolható be részletes naplózás.",
"failed": "Nem sikerült módosítani a naplózási szintet"
},
"myLog": {
"title": "Saját napló",
"hint": "Amit ez a böngészőlap rögzített. Az oldal újratöltésekor törlődik.",
"search": "Keresés az eseményekben…",
"filterLabel": "Szűrés szintre",
"filters": {
"all": "Mind",
"error": "Hibák",
"warning": "Figyelmeztetések",
"info": "Info",
"detail": "Részletes"
},
"count_one": "{{count}} esemény · a legújabb felül",
"count_other": "{{count}} esemény · a legújabb felül",
"capped": "a legújabb {{shown}} látszik a(z) {{count}} közül",
"copy": "Másolás",
"copied_one": "Kimásolva: {{count}} esemény",
"copied_other": "Kimásolva: {{count}} esemény",
"copyFailed": "Nem sikerült a vágólapra másolni",
"clear": "Ürítés",
"empty": "Nincs a szűrésnek megfelelő esemény.",
"emptyAll": "Még nincs esemény."
}
}
+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
+2 -1
View File
@@ -21,9 +21,10 @@ import "./index.css";
import "flag-icons/css/flag-icons.min.css";
import { lazyWithPreload } from "./lib/lazyWithPreload";
import { installClientLog } from "./lib/logHooks";
import { appCopyOf } from "./i18n/appCopy";
// First, before any render: a crash or a navigation during boot belongs in the log too.
installClientLog();
installClientLog(appCopyOf);
// Split by top-level route so each entry point is its own chunk: a public /watch share link or a
// legal page never downloads the authenticated app bundle, and the app never carries them either.