From 4e52bdd4ca405b42f781c7b268f490075dc91943 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Tue, 15 Sep 2026 23:45:13 +0200
Subject: [PATCH 01/26] fix: a signed-out tab no longer asks for saved views
The providers mount for the public landing too, and the saved-views query had no
enabled gate, so every signed-out load logged a 401 for /api/saved-views. The query
now waits for a signed-in account in BootGate's me cache, like WizardProvider.
---
.../SavedViewsProvider.dom.test.tsx | 34 +++++++++++++++++--
.../src/components/SavedViewsProvider.tsx | 5 +++
2 files changed, 37 insertions(+), 2 deletions(-)
diff --git a/frontend/src/components/SavedViewsProvider.dom.test.tsx b/frontend/src/components/SavedViewsProvider.dom.test.tsx
index ea9d1459..da28f489 100644
--- a/frontend/src/components/SavedViewsProvider.dom.test.tsx
+++ b/frontend/src/components/SavedViewsProvider.dom.test.tsx
@@ -67,12 +67,19 @@ function Probe() {
);
}
+// The views query waits for a signed-in account in the cache (BootGate's `me`).
+function signedInClient() {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ qc.setQueryData(qk.me(), { id: ID, preferences: {} });
+ return qc;
+}
+
async function mountResolved(views: SavedView[]) {
// Fresh array + object copies per call, like a real fetch — so a refetch changes viewsQuery.data's
// identity and re-runs the seed effect (which is what makes the per-source guard actually load-bearing
// in the re-seed test; a shared reference would skip the effect and hide a missing guard).
vi.spyOn(api, "savedViews").mockImplementation(async () => views.map((v) => ({ ...v })));
- const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const qc = signedInClient();
render(
@@ -141,7 +148,7 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
calls += 1;
return calls === 1 ? [def] : [def, view({ id: 9, name: "added-later" })];
});
- const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const qc = signedInClient();
render(
@@ -174,3 +181,26 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
expect(h.setPlexFilters).not.toHaveBeenCalled();
});
});
+
+describe("SavedViewsProvider — signed-out tab", () => {
+ it("does NOT ask for saved views while no account is signed in (the landing logged a 401)", async () => {
+ const spy = vi.spyOn(api, "savedViews").mockResolvedValue([]);
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ qc.setQueryData(qk.me(), null); // BootGate's "nobody"
+ render(
+
+
+
+
+ ,
+ );
+ // Let any fetch the mount would start get going before asserting it never happened.
+ await act(async () => {
+ await new Promise((r) => setTimeout(r, 20));
+ });
+ expect(spy).not.toHaveBeenCalled();
+ // …and it asks as soon as an account signs in.
+ act(() => qc.setQueryData(qk.me(), { id: ID, preferences: {} }));
+ await waitFor(() => expect(spy).toHaveBeenCalledWith("feed"));
+ });
+});
diff --git a/frontend/src/components/SavedViewsProvider.tsx b/frontend/src/components/SavedViewsProvider.tsx
index 47ae71db..6b2ab11f 100644
--- a/frontend/src/components/SavedViewsProvider.tsx
+++ b/frontend/src/components/SavedViewsProvider.tsx
@@ -155,9 +155,14 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
}, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]);
const source = cfg.source;
+ // Passive observer of the account BootGate fetches: the providers mount for a signed-out tab too
+ // (the public landing), and saved views are per-account, so asking for "nobody's" views only logs
+ // a 401 on the landing page. Enabled once a signed-in account is cached.
+ const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
const viewsQuery = useQuery({
queryKey: qk.savedViews(source),
queryFn: () => api.savedViews(source),
+ enabled: !!me,
});
const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]);
From 11aef4ab7aef97c71e5205a5bbf1cebe7fdb5e79 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Wed, 16 Sep 2026 00:34:50 +0200
Subject: [PATCH 02/26] feat: record when an account last signed in
users.last_login_at (migration 0085, no backfill) is stamped by _establish_session,
which the Google callback now shares with password login instead of repeating its
session setup inline. The demo entry does not stamp its shared account. The admin
user list carries the field.
---
.../versions/0085_users_last_login_at.py | 26 +++++
backend/app/models.py | 4 +
backend/app/routes/admin.py | 2 +
backend/app/routes/auth.py | 20 ++--
backend/tests/test_last_login.py | 97 +++++++++++++++++++
frontend/src/lib/api/admin.ts | 2 +
6 files changed, 140 insertions(+), 11 deletions(-)
create mode 100644 backend/alembic/versions/0085_users_last_login_at.py
create mode 100644 backend/tests/test_last_login.py
diff --git a/backend/alembic/versions/0085_users_last_login_at.py b/backend/alembic/versions/0085_users_last_login_at.py
new file mode 100644
index 00000000..fb050e91
--- /dev/null
+++ b/backend/alembic/versions/0085_users_last_login_at.py
@@ -0,0 +1,26 @@
+"""add users.last_login_at — when the account last signed in (E22 S1)
+
+Revision ID: 0085_users_last_login_at
+Revises: 0084_dl_job_error_detail
+Create Date: 2026-09-16
+
+Stamped by the one sign-in path (`routes/auth._establish_session`), which the Google callback and
+password login share. No backfill is possible: the signal exists only at sign-in, so existing rows
+read NULL until each account signs in again, and the admin UI says so rather than inventing a date.
+"""
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0085_users_last_login_at"
+down_revision: str | None = "0084_dl_job_error_detail"
+branch_labels: str | None = None
+depends_on: str | None = None
+
+
+def upgrade() -> None:
+ op.add_column("users", sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column("users", "last_login_at")
diff --git a/backend/app/models.py b/backend/app/models.py
index f29695d9..94203c9b 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -102,6 +102,10 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
# current_user rejects any cookie whose epoch is stale, so a stolen/copied cookie dies on those
# events (client-side signed cookies otherwise stay valid until they expire).
session_epoch: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+ # When this account last signed in (Google or password), stamped by the one sign-in path,
+ # 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))
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")
diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py
index 86ad5ade..63e070da 100644
--- a/backend/app/routes/admin.py
+++ b/backend/app/routes/admin.py
@@ -408,6 +408,8 @@ def _serialize_user(u: User) -> dict:
# confusing sign-in.
"google_email": u.google_email if u.google_email != u.email else None,
"created_at": iso(u.created_at),
+ # NULL = no sign-in since the column existed (0085), or the shared demo account.
+ "last_login_at": iso(u.last_login_at),
}
diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py
index 18c25e67..97bae871 100644
--- a/backend/app/routes/auth.py
+++ b/backend/app/routes/auth.py
@@ -479,16 +479,8 @@ async def callback(
db.flush()
_store_token(db, user, token)
+ _establish_session(request, user)
db.commit()
-
- # Multi-session: remember every account that has authenticated in this browser so the
- # user can switch between them without going through Google again. Only accounts that
- # actually completed OAuth here land in this list (it's the proof they may be switched to).
- accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id]
- accounts.append(user.id)
- request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
- request.session["user_id"] = user.id
- _remember_epoch(request, user)
log.info("Login: %s (id=%s, role=%s)", email, user.id, user.role)
return RedirectResponse(url="/")
@@ -675,13 +667,18 @@ def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -
def _establish_session(request: Request, user: User) -> None:
- """Sign `user` in for this browser session, mirroring the Google callback's multi-account
- handling so password sign-in joins the same account switcher."""
+ """Sign `user` in for this browser session — the ONE sign-in path, shared by the Google callback
+ and password login, so both join the same account switcher and both stamp `last_login_at` (the
+ caller commits). Multi-session: every account that authenticated in this browser is remembered so
+ the user can switch between them; only accounts that actually proved themselves HERE land in that
+ wallet (it is the proof they may be switched to). The demo entry deliberately does not come
+ through here: it clears the wallet instead, and its shared account has no meaningful last login."""
accounts = [a for a in (request.session.get("account_ids") or []) if a != user.id]
accounts.append(user.id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
request.session["user_id"] = user.id
_remember_epoch(request, user)
+ user.last_login_at = datetime.now(timezone.utc)
def _app_base() -> str:
@@ -1051,6 +1048,7 @@ def password_login(
if not user.is_active:
raise _login_refused(403, "pending_approval", user)
_establish_session(request, user)
+ db.commit()
log.info("Password login: %s (id=%s, role=%s)", email, user.id, user.role)
return {"ok": True}
diff --git a/backend/tests/test_last_login.py b/backend/tests/test_last_login.py
new file mode 100644
index 00000000..b2a48d6d
--- /dev/null
+++ b/backend/tests/test_last_login.py
@@ -0,0 +1,97 @@
+"""`users.last_login_at` (E22 S1), DB-lane.
+
+The roadmap assumed stamping it in `_establish_session` would cover Google and password alike. It
+would not have: only password login called it, and the Google callback carried its own copy of the
+same four lines. So these pin BOTH doors through the real routes — plus the two that must not stamp
+(a refused password, the shared demo account) and the admin list that shows it.
+"""
+
+from datetime import datetime, timedelta, timezone
+
+from app.config import settings
+from app.models import DemoWhitelist
+from app.routes import auth as auth_routes
+
+_PW = "correct horse battery"
+
+
+def _recent(ts: datetime | None) -> bool:
+ return ts is not None and datetime.now(timezone.utc) - ts < timedelta(minutes=1)
+
+
+def test_a_password_sign_in_stamps_last_login(client, db, make_user):
+ user = make_user(email="pw@example.com", password=_PW)
+ assert user.last_login_at is None
+
+ r = client.post("/auth/password-login", json={"email": "pw@example.com", "password": _PW})
+
+ assert r.status_code == 200
+ db.refresh(user)
+ assert _recent(user.last_login_at)
+
+
+def test_a_refused_password_does_not_stamp(client, db, make_user):
+ user = make_user(email="wrong@example.com", password=_PW)
+
+ r = client.post(
+ "/auth/password-login", json={"email": "wrong@example.com", "password": "not it"}
+ )
+
+ assert r.status_code == 401
+ db.refresh(user)
+ assert user.last_login_at is None
+
+
+class _FakeGoogle:
+ """authlib's client, reduced to the one call the callback makes after Google redirects back."""
+
+ def __init__(self, userinfo: dict):
+ self._userinfo = userinfo
+
+ async def authorize_access_token(self, _request):
+ return {"userinfo": self._userinfo, "access_token": "at", "scope": "openid email profile"}
+
+
+def test_a_google_sign_in_stamps_last_login(client, db, make_user, monkeypatch):
+ from cryptography.fernet import Fernet
+
+ from app import security
+
+ # The callback stores the OAuth grant encrypted; a throwaway key stands in for TOKEN_ENCRYPTION_KEY.
+ monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
+ user = make_user(email="g@example.com", password=None, google_sub="sub-g")
+ monkeypatch.setattr(settings, "admin_emails", "g@example.com") # the bootstrap allow-list
+ fake = _FakeGoogle({"sub": "sub-g", "email": "g@example.com", "email_verified": True})
+ monkeypatch.setattr(auth_routes, "google_oauth", lambda _db: fake)
+
+ r = client.get("/auth/callback", follow_redirects=False)
+
+ assert r.status_code in (302, 307) and r.headers["location"] == "/"
+ db.refresh(user)
+ assert _recent(user.last_login_at)
+ # …and it is a real sign-in, not only a stamp: the session now answers as this account.
+ assert client.get("/api/me").json()["id"] == user.id
+
+
+def test_the_demo_entry_does_not_stamp_the_shared_account(client, db):
+ db.add(DemoWhitelist(email="visitor@example.com"))
+ db.commit()
+
+ r = client.post("/auth/demo", json={"email": "visitor@example.com"})
+
+ assert r.json() == {"authenticated": True}
+ demo = auth_routes.get_or_create_demo_user(db)
+ db.refresh(demo)
+ assert demo.last_login_at is None
+
+
+def test_the_admin_user_list_carries_last_login(as_user, db, make_user):
+ admin = make_user(email="boss@example.com", role="admin")
+ never = make_user(email="never@example.com")
+ admin.last_login_at = datetime(2026, 9, 16, 8, 30, tzinfo=timezone.utc)
+ db.commit()
+
+ rows = {u["email"]: u for u in as_user(admin).get("/api/admin/users").json()}
+
+ assert rows["boss@example.com"]["last_login_at"].startswith("2026-09-16T08:30")
+ assert rows[never.email]["last_login_at"] is None
diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts
index a9ae05e7..8b9b82b9 100644
--- a/frontend/src/lib/api/admin.ts
+++ b/frontend/src/lib/api/admin.ts
@@ -82,6 +82,8 @@ export interface AdminUserRow {
/** The Google account's address, ONLY when it differs from the account address (E9.5 S3). */
google_email: string | null;
created_at: string | null;
+ /** Last sign-in (Google or password). Null = not since 0.88 (no backfill), or the demo account. */
+ last_login_at: string | null;
}
/** The account panel's view of why someone can or cannot get in (E12 S5). */
From e299c6612194f7c67196227b9c95a4abe4f413c3 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Wed, 16 Sep 2026 00:45:03 +0200
Subject: [PATCH 03/26] feat: show each account's last sign-in in Users
A right-aligned column at the end of every Users & roles row says how long ago the
account signed in, with the exact moment as the hint and as a line in the account
panel; a dash where nothing is recorded, and the demo account says it is not recorded.
formatDateTime formats that moment in the UI language rather than the browser's.
---
frontend/src/components/AccountPanel.tsx | 17 +++++++++++++-
.../src/components/AdminUsers.dom.test.tsx | 22 +++++++++++++++++++
frontend/src/components/AdminUsers.tsx | 18 +++++++++++++++
frontend/src/i18n/locales/en/users.json | 6 +++++
frontend/src/i18n/locales/hu/users.json | 6 +++++
frontend/src/lib/format.ts | 6 +++++
6 files changed, 74 insertions(+), 1 deletion(-)
diff --git a/frontend/src/components/AccountPanel.tsx b/frontend/src/components/AccountPanel.tsx
index 897099cc..b93377e4 100644
--- a/frontend/src/components/AccountPanel.tsx
+++ b/frontend/src/components/AccountPanel.tsx
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { Check, Clock, Copy, Globe, KeyRound, Mail, Send, ShieldAlert } from "lucide-react";
+import { Check, Clock, Copy, Globe, KeyRound, LogIn, Mail, Send, ShieldAlert } from "lucide-react";
import Modal from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { api } from "../lib/api";
@@ -9,6 +9,7 @@ import { qk } from "../lib/queryKeys";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
+import { formatDateTime } from "../lib/format";
import { invalidateUserAccess } from "../lib/adminUsers";
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
@@ -168,6 +169,20 @@ export default function AccountPanel({
}
tone={a.has_google ? "ok" : "muted"}
/>
+ {/* The exact moment; the list row beside it only says how long ago. Null has two different
+ meanings, so it says which: nothing recorded yet, or the shared demo account, which is
+ deliberately never stamped. */}
+
{/* The gate that explains a Google sign-in silently becoming a fresh access request. */}
{!a.is_allowed && (
({ reportError: vi.fn() }));
import AdminUsers from "./AdminUsers";
import { api, type AdminUserRow } from "../lib/api";
+import { relativeTime } from "../lib/format";
import i18n from "../i18n";
const row = {
@@ -76,6 +77,27 @@ function mount() {
return users;
}
+describe("the last sign-in column (E22 S1)", () => {
+ it("says how long ago each account signed in, and a dash where nothing is recorded", async () => {
+ const at = new Date(Date.now() - 3 * 86_400_000).toISOString();
+ vi.spyOn(api, "adminUsers").mockResolvedValue([
+ { ...row, id: 3, email: "seen@x", last_login_at: at },
+ { ...row, id: 4, email: "never@x", last_login_at: null },
+ { ...row, id: 5, email: "demo@x", is_demo: true, last_login_at: null },
+ ] as never);
+ render(
+
+
+ ,
+ );
+
+ expect(await screen.findByText(relativeTime(at))).toBeTruthy();
+ expect(screen.getAllByText("—")).toHaveLength(2);
+ });
+});
+
describe("a user action that fails", () => {
it("says a user who is gone, and refetches the list", async () => {
const users = mount();
diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx
index 916d5508..833b8052 100644
--- a/frontend/src/components/AdminUsers.tsx
+++ b/frontend/src/components/AdminUsers.tsx
@@ -24,6 +24,7 @@ import {
type PendingAccessRow,
} from "../lib/api";
import { notify } from "../lib/notifications";
+import { formatDateTime, relativeTime } from "../lib/format";
import { invalidateUserAccess } from "../lib/adminUsers";
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
import { Button } from "./ui/Button";
@@ -310,6 +311,23 @@ function UsersRoles({ me }: { me: Me }) {
icon={}
/>
+ {/* How long ago this account last signed in. LAST in the row on purpose: the badges and
+ the role button before it vary in width row to row, so only the row's own right
+ edge lines the values up into one column. The exact moment is the hint here and a
+ line in the account panel; below `sm` the row has no room, and the panel still says it. */}
+
+
+ {u.last_login_at ? relativeTime(u.last_login_at) : "—"}
+
+
);
})}
diff --git a/frontend/src/i18n/locales/en/users.json b/frontend/src/i18n/locales/en/users.json
index 8d2d2a34..da41c7fb 100644
--- a/frontend/src/i18n/locales/en/users.json
+++ b/frontend/src/i18n/locales/en/users.json
@@ -30,6 +30,9 @@
"suspended": "Suspended",
"pending": "Pending approval",
"unverified": "Unverified email",
+ "lastLogin": "Last sign-in: {{time}}",
+ "lastLoginNone": "No sign-in recorded yet",
+ "lastLoginDemo": "Not recorded for the demo account",
"demoLocked": "The demo account's role can't be changed.",
"selfLocked": "You can't change your own role.",
"makeAdmin": "Make admin",
@@ -81,6 +84,9 @@
"googleConnected": "connected",
"googleNone": "not connected",
"invite": "Invite",
+ "lastLogin": "Last sign-in",
+ "lastLoginNone": "none recorded yet",
+ "lastLoginDemo": "not recorded for the demo account",
"inviteMissing": "this address is not on the allow-list",
"linkNote": "Valid 24 hours, works once. Opening it lets this person set a password and signs out anyone already using the account. It does not confirm their address.",
"mailOff": "no mail configured",
diff --git a/frontend/src/i18n/locales/hu/users.json b/frontend/src/i18n/locales/hu/users.json
index 48d6e7ef..3e41e878 100644
--- a/frontend/src/i18n/locales/hu/users.json
+++ b/frontend/src/i18n/locales/hu/users.json
@@ -30,6 +30,9 @@
"suspended": "Felfüggesztve",
"pending": "Jóváhagyásra vár",
"unverified": "Nem igazolt e-mail",
+ "lastLogin": "Utolsó belépés: {{time}}",
+ "lastLoginNone": "Nincs még rögzített belépés",
+ "lastLoginDemo": "A demo fióknál nem rögzítjük",
"demoLocked": "A demo fiók szerepe nem módosítható.",
"selfLocked": "A saját szerepedet nem módosíthatod.",
"makeAdmin": "Adminná tesz",
@@ -81,6 +84,9 @@
"googleConnected": "összekötve",
"googleNone": "nincs összekötve",
"invite": "Meghívó",
+ "lastLogin": "Utolsó belépés",
+ "lastLoginNone": "még nincs rögzítve",
+ "lastLoginDemo": "a demo fióknál nem rögzítjük",
"inviteMissing": "ez a cím nincs az engedélyezési listán",
"linkNote": "24 óráig érvényes, egyszer használható. Aki megnyitja, beállíthat egy jelszót, és ezzel kilép mindenki, aki eddig használta a fiókot. A címet nem erősíti meg.",
"mailOff": "nincs beállítva levelezés",
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
index 2e2a0958..16e7bf9d 100644
--- a/frontend/src/lib/format.ts
+++ b/frontend/src/lib/format.ts
@@ -148,6 +148,12 @@ export function formatCountOrDash(n: number | null | undefined): string {
return n != null ? formatViews(n) : "—";
}
+// Absolute date AND time in the UI language (e.g. "16 Sept 2026, 08:30:14"). A bare `toLocaleString()`
+// formats in the browser's locale instead, so an English UI showed a Hungarian-shaped date. Empty for null.
+export function formatDateTime(iso: string | null | undefined): string {
+ return iso ? new Date(iso).toLocaleString(i18n.language) : "";
+}
+
// Absolute day-precision date (e.g. "12 Jan 2017"), shown alongside a relative time. Empty for null.
export function formatDate(iso: string | null | undefined, lang: string): string {
return iso
From 618ef812e633c379b206494b8a313797142fb412 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Wed, 16 Sep 2026 00:49:23 +0200
Subject: [PATCH 04/26] fix: dates follow the UI language, not the browser's
Seven sites formatted with a bare toLocaleString()/toLocaleDateString(), so an
English UI on a Hungarian machine printed Hungarian-shaped dates (and the reverse).
They now go through formatDateTime/formatDate, which take the i18n language.
---
frontend/src/components/About.tsx | 3 ++-
frontend/src/components/AdminUsers.tsx | 11 +++++++----
frontend/src/components/ShareDialog.tsx | 5 +++--
frontend/src/components/downloadRow.tsx | 11 +++++++++--
4 files changed, 21 insertions(+), 9 deletions(-)
diff --git a/frontend/src/components/About.tsx b/frontend/src/components/About.tsx
index 9da74868..7eb4078e 100644
--- a/frontend/src/components/About.tsx
+++ b/frontend/src/components/About.tsx
@@ -4,13 +4,14 @@ import { useTranslation } from "react-i18next";
import { Info, Sparkles } from "lucide-react";
import { api } from "../lib/api";
import { FRONTEND_VERSION, FRONTEND_SHA, FRONTEND_BUILD_DATE } from "../lib/version";
+import { formatDateTime } from "../lib/format";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
function fmtDate(d?: string | null): string {
if (!d) return "—";
const t = new Date(d);
- return isNaN(t.getTime()) ? d : t.toLocaleString();
+ return isNaN(t.getTime()) ? d : formatDateTime(d);
}
// About dialog: app + build + schema versions (frontend/backend/database), plus a
diff --git a/frontend/src/components/AdminUsers.tsx b/frontend/src/components/AdminUsers.tsx
index 833b8052..28495f6e 100644
--- a/frontend/src/components/AdminUsers.tsx
+++ b/frontend/src/components/AdminUsers.tsx
@@ -431,7 +431,7 @@ function PendingAccessRowView({
{row.created_at && (
{t("users.pendingAccess.signedUp", {
- time: new Date(row.created_at).toLocaleString(),
+ time: formatDateTime(row.created_at),
})}
)}
@@ -619,7 +619,10 @@ function AdminInvites() {
inv.invited_at
? "settings.invites.inviteConfirmAgain"
: "settings.invites.inviteConfirm",
- { email: inv.email, time: new Date(inv.invited_at ?? 0).toLocaleString() },
+ {
+ email: inv.email,
+ time: formatDateTime(inv.invited_at ?? new Date(0).toISOString()),
+ },
)}
{t("settings.invites.requested", {
- time: new Date(inv.requested_at).toLocaleString(),
+ time: formatDateTime(inv.requested_at),
})}
)}
diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx
index bae70fc6..a9caf014 100644
--- a/frontend/src/components/ShareDialog.tsx
+++ b/frontend/src/components/ShareDialog.tsx
@@ -9,6 +9,7 @@ import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard";
+import { formatDate } from "../lib/format";
import { api, type DownloadJob, type ShareLink } from "../lib/api";
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
@@ -88,7 +89,7 @@ function UserShare({ job }: { job: DownloadJob }) {
// --- share by public link -------------------------------------------------------------------
function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) {
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
const confirm = useConfirm();
const [copied, setCopied] = useState(false);
const [editPass, setEditPass] = useState(false);
@@ -130,7 +131,7 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
if (link.allow_download) badges.push(t("downloads.share.downloadable"));
if (link.expires_at)
badges.push(
- t("downloads.share.expiresOn", { date: new Date(link.expires_at).toLocaleDateString() }),
+ t("downloads.share.expiresOn", { date: formatDate(link.expires_at, i18n.language) }),
);
return (
diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx
index 23699847..60570395 100644
--- a/frontend/src/components/downloadRow.tsx
+++ b/frontend/src/components/downloadRow.tsx
@@ -7,7 +7,14 @@ import { useLiveQuery } from "../lib/useLiveQuery";
import { api, type DownloadJob } from "../lib/api";
import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard";
-import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format";
+import {
+ formatBytes,
+ formatDateTime,
+ formatDuration,
+ formatEta,
+ formatSpeed,
+ relativeTime,
+} from "../lib/format";
import { navItemAttrs } from "../lib/listNav";
import { failCopyKeys } from "../lib/downloadFailure";
@@ -250,7 +257,7 @@ export function DownloadRow({
{job.status === "done" && job.created_at ? (
· {relativeTime(job.created_at)}
From a5bcd9fca2674ab8b40b1c0a1ef09248c2678917 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Wed, 16 Sep 2026 01:05:53 +0200
Subject: [PATCH 05/26] fix: numbers follow the UI language, not the browser's
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`n.toLocaleString()` groups in the BROWSER's locale, so an English UI in a
Hungarian browser printed "1 234 567" while a Hungarian UI in an English
browser printed "1,234,567" — the number half of the date defect fixed in
618ef812. One `formatNumber` in lib/format.ts (a per-language cached
Intl.NumberFormat, like the relative-time formatters beside it) now serves
every grouped figure: Stats, Scheduler, the channel columns and the stats
popover, VideoCard's view tooltip, the ItemCount pill, and formatTotalHours.
ReleaseNotes' `toLocaleDateString(undefined, …)` was the one DATE site the
previous sweep missed; it moves onto formatDate.
The new cases assert both languages and that the two differ, instead of
comparing against the runtime's own default: that default is Hungarian on the
dev box and English on a CI image, so a one-language assertion would pass
under the old bug on half the machines. Verified in Chromium on :5173 with the
browser forced to hu-HU and the UI in English — Stats, Scheduler and the
channel table all keep comma grouping.
---
.../ChannelStatsPopover.dom.test.tsx | 4 +-
.../src/components/ChannelStatsPopover.tsx | 6 +--
frontend/src/components/ReleaseNotes.tsx | 15 +++---
frontend/src/components/Scheduler.tsx | 24 ++++-----
frontend/src/components/Stats.tsx | 24 ++++-----
frontend/src/components/VideoCard.tsx | 4 +-
frontend/src/components/channelColumns.tsx | 4 +-
.../src/components/ui/ItemCount.dom.test.tsx | 32 +++++++++---
frontend/src/components/ui/ItemCount.tsx | 5 +-
frontend/src/lib/format.test.ts | 51 ++++++++++++++++++-
frontend/src/lib/format.ts | 23 ++++++++-
11 files changed, 141 insertions(+), 51 deletions(-)
diff --git a/frontend/src/components/ChannelStatsPopover.dom.test.tsx b/frontend/src/components/ChannelStatsPopover.dom.test.tsx
index f94f6ebb..b477c91f 100644
--- a/frontend/src/components/ChannelStatsPopover.dom.test.tsx
+++ b/frontend/src/components/ChannelStatsPopover.dom.test.tsx
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => {
import ChannelStatsPopover from "./ChannelStatsPopover";
import { type MyStatus } from "../lib/api";
-import { formatEta } from "../lib/format";
+import { formatEta, formatNumber } from "../lib/format";
beforeAll(() => {
class Stub {
@@ -74,7 +74,7 @@ describe("ChannelStatsPopover", () => {
expect(screen.getByText("channels.stats.recentSynced")).toBeTruthy();
expect(screen.getByText("3/10")).toBeTruthy(); // recent-synced ratio
expect(screen.getByText("1/4")).toBeTruthy(); // full-history ratio
- expect(screen.getByText((1234).toLocaleString())).toBeTruthy(); // my_videos, locale-agnostic
+ expect(screen.getByText(formatNumber(1234))).toBeTruthy(); // my_videos, locale-agnostic
});
it("shows the 'left' ETA row only while a backfill is pending, with the formatted ETA value", () => {
diff --git a/frontend/src/components/ChannelStatsPopover.tsx b/frontend/src/components/ChannelStatsPopover.tsx
index 3d20d933..7b0b9b8e 100644
--- a/frontend/src/components/ChannelStatsPopover.tsx
+++ b/frontend/src/components/ChannelStatsPopover.tsx
@@ -11,7 +11,7 @@ import {
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { type MyStatus } from "../lib/api";
-import { formatEta } from "../lib/format";
+import { formatEta, formatNumber } from "../lib/format";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
// The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip
@@ -63,13 +63,13 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
{
icon: Film,
label: t("channels.stats.myVideos"),
- value: status.my_videos.toLocaleString(),
+ value: formatNumber(status.my_videos),
hint: t("channels.stats.myVideosHint"),
},
{
icon: Zap,
label: t("channels.stats.quotaLeft"),
- value: status.quota_remaining_today.toLocaleString(),
+ value: formatNumber(status.quota_remaining_today),
hint: t("channels.stats.quotaLeftHint"),
},
];
diff --git a/frontend/src/components/ReleaseNotes.tsx b/frontend/src/components/ReleaseNotes.tsx
index 70e0631f..e0dc3120 100644
--- a/frontend/src/components/ReleaseNotes.tsx
+++ b/frontend/src/components/ReleaseNotes.tsx
@@ -1,13 +1,14 @@
import { useTranslation } from "react-i18next";
import { Bug, Sparkles } from "lucide-react";
import { RELEASE_NOTES } from "../lib/releaseNotes";
+import { formatDate } from "../lib/format";
import Modal from "./ui/Modal";
-function fmtDate(d: string): string {
- const t = new Date(d);
- return isNaN(t.getTime())
- ? d
- : t.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
+// The release date in the UI language — `toLocaleDateString(undefined, …)` formats in the BROWSER's
+// locale, which put Hungarian-shaped dates in an English release-notes list. Unparseable input falls
+// back to the raw string (a hand-written note could carry anything).
+function fmtDate(d: string, lang: string): string {
+ return isNaN(new Date(d).getTime()) ? d : formatDate(d, lang);
}
// Public release notes (chores are intentionally omitted here). `highlight` rings the
@@ -19,7 +20,7 @@ export default function ReleaseNotes({
onClose: () => void;
highlight?: string;
}) {
- const { t } = useTranslation();
+ const { t, i18n } = useTranslation();
// Ring the newest note if `highlight` isn't one of the listed versions — e.g. a Vite dev build
// where the caller's FRONTEND_VERSION is "dev", or a VERSION bump whose note hasn't landed yet —
// so the banner always rings *something* current instead of nothing.
@@ -36,7 +37,7 @@ export default function ReleaseNotes({
>
v{r.version}
- {fmtDate(r.date)}
+ {fmtDate(r.date, i18n.language)}
{r.sha && (
{r.sha}
diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx
index 5f0e62be..a586bd95 100644
--- a/frontend/src/components/Scheduler.tsx
+++ b/frontend/src/components/Scheduler.tsx
@@ -22,7 +22,7 @@ import { useLiveQuery } from "../lib/useLiveQuery";
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useConfirm } from "./ConfirmProvider";
-import { relativeTime } from "../lib/format";
+import { formatNumber, relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import Tooltip from "./ui/Tooltip";
import { notifyActionError } from "../lib/actionErrors";
@@ -103,9 +103,9 @@ function JobProgress({ p }: { p: SchedulerJob["progress"] }) {
{phase}
{p?.total != null
- ? `${p.current.toLocaleString()} / ${p.total.toLocaleString()}`
+ ? `${formatNumber(p.current)} / ${formatNumber(p.total)}`
: p
- ? p.current.toLocaleString()
+ ? formatNumber(p.current)
: ""}
@@ -291,7 +291,7 @@ function MaintenanceSettings({
diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx
index b24f3d89..12bd40b2 100644
--- a/frontend/src/components/VideoCard.tsx
+++ b/frontend/src/components/VideoCard.tsx
@@ -22,7 +22,7 @@ import { navItemAttrs } from "../lib/listNav";
import clsx from "clsx";
import type { Video, VideoStatus } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
-import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
+import { formatDate, formatDuration, formatNumber, formatViews, relativeTime } from "../lib/format";
import { isPlainLeftClick } from "../lib/nav";
import ChannelNameLink from "./ChannelNameLink";
@@ -423,7 +423,7 @@ function VideoCard({
// on every line and the tooltip carries the figure regardless.
const viewsCell = (withWord: boolean) =>
video.view_count != null && (
-
+
{formatViews(video.view_count)}
{withWord && ` ${t("card.views")}`}
diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx
index b8b831db..f8b3b0dc 100644
--- a/frontend/src/components/channelColumns.tsx
+++ b/frontend/src/components/channelColumns.tsx
@@ -3,7 +3,7 @@ import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react";
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
-import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format";
+import { formatCountOrDash, formatNumber, formatTotalHours, relativeTime } from "../lib/format";
import type { Column } from "./ui/DataTable";
import Tooltip from "./ui/Tooltip";
import { PriorityStepper } from "./ui/PriorityStepper";
@@ -66,7 +66,7 @@ function StoredCell({ c }: { c: ManagedChannel }) {
/>
)}
- {c.stored_videos.toLocaleString()}
+ {formatNumber(c.stored_videos)}
);
}
diff --git a/frontend/src/components/ui/ItemCount.dom.test.tsx b/frontend/src/components/ui/ItemCount.dom.test.tsx
index 5295edb4..8fd0c962 100644
--- a/frontend/src/components/ui/ItemCount.dom.test.tsx
+++ b/frontend/src/components/ui/ItemCount.dom.test.tsx
@@ -8,11 +8,15 @@ import { render, screen, cleanup } from "@testing-library/react";
import "../../i18n"; // side-effect: initialise the shared i18next instance
import i18n from "i18next";
import ItemCount from "./ItemCount";
+import { formatNumber } from "../../lib/format";
beforeAll(async () => {
await i18n.changeLanguage("en");
});
-afterEach(cleanup);
+afterEach(async () => {
+ cleanup();
+ await i18n.changeLanguage("en"); // a case that switches language must not leak it to the next
+});
const text = () => screen.getByTestId("item-count").textContent;
@@ -24,17 +28,31 @@ describe("ItemCount", () => {
it("pluralises and thousands-separates a normal count", () => {
render();
- // Separators come from toLocaleString (runtime-locale dependent) — compare against it, not a fixed
- // en-US string, so the test asserts ItemCount's formatting path rather than the CI locale.
- expect(text()).toBe(`${(192045).toLocaleString()} items`);
+ // Separators come from the shared formatNumber — compare against it, not a fixed en-US string,
+ // so the test asserts ItemCount's formatting path rather than the CI locale.
+ expect(text()).toBe(`${formatNumber(192045)} items`);
+ });
+
+ // The pill used `count.toLocaleString()`, which groups in the BROWSER's locale: a Hungarian UI on
+ // an English browser showed "192,045" instead of "192 045". The title carries the pure number (no
+ // plural string), and both languages are asserted — comparing against the runtime's own default
+ // would pass under the old bug on a machine whose locale happens to match the UI language.
+ it("groups in the UI language, not the browser's", async () => {
+ const title = () => screen.getByTestId("item-count").getAttribute("title");
+ render();
+ const asEnglish = title();
+ cleanup();
+ await i18n.changeLanguage("hu");
+ render();
+ expect(asEnglish).toBe(new Intl.NumberFormat("en").format(192045));
+ expect(title()).toBe(new Intl.NumberFormat("hu").format(192045));
+ expect(title()).not.toBe(asEnglish);
});
it("collapses a count of a million or more to 1M+, with the exact figure in the tooltip", () => {
render();
expect(text()).toBe("1M+ items"); // the "1M+" collapse is locale-independent
- expect(screen.getByTestId("item-count").getAttribute("title")).toBe(
- (1_000_000).toLocaleString(),
- );
+ expect(screen.getByTestId("item-count").getAttribute("title")).toBe(formatNumber(1_000_000));
});
it("shows the icon-only fixed box (empty label) while the count is undefined", () => {
diff --git a/frontend/src/components/ui/ItemCount.tsx b/frontend/src/components/ui/ItemCount.tsx
index 58f07710..f185ce5d 100644
--- a/frontend/src/components/ui/ItemCount.tsx
+++ b/frontend/src/components/ui/ItemCount.tsx
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { Layers } from "lucide-react";
+import { formatNumber } from "../../lib/format";
// At a million and above we stop printing the exact figure — six digits is the readable ceiling, so
// the pill shows "1M+" and the tooltip carries the precise number.
@@ -20,12 +21,12 @@ export default function ItemCount({ count }: { count?: number }) {
? ""
: t("common.itemCount", {
count,
- formattedCount: count >= OVERFLOW_AT ? "1M+" : count.toLocaleString(),
+ formattedCount: count >= OVERFLOW_AT ? "1M+" : formatNumber(count),
});
return (
diff --git a/frontend/src/lib/format.test.ts b/frontend/src/lib/format.test.ts
index e9d8dcb4..ab28ce42 100644
--- a/frontend/src/lib/format.test.ts
+++ b/frontend/src/lib/format.test.ts
@@ -1,5 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { RELATIVE_UNITS, formatRuntime, relativeTime, relativeFromMs } from "./format";
+import {
+ RELATIVE_UNITS,
+ formatNumber,
+ formatRuntime,
+ formatTotalHours,
+ relativeTime,
+ relativeFromMs,
+} from "./format";
import en from "../i18n/locales/en/time.json";
import hu from "../i18n/locales/hu/time.json";
@@ -114,6 +121,48 @@ describe("relativeFromMs", () => {
// implementation: 45s floored to "0m", so a playlist of Shorts reported no runtime while the rail
// beside it read "0:45". The table pins every tier boundary, so the sub-minute branch cannot be
// dropped again as "dead code" — it is the one branch a whole-playlist figure reaches.
+// A bare `n.toLocaleString()` groups in the BROWSER's locale, so an English UI in a Hungarian browser
+// printed "1 234 567" (and a Hungarian UI in an English browser "1,234,567"). The case below asserts
+// BOTH languages and that the two differ, rather than comparing against the runtime's own default:
+// the default is whatever locale the machine running the suite has (Hungarian on the dev box, English
+// on a CI image), so a one-language assertion passes under the old bug on half the machines.
+describe("formatNumber", () => {
+ afterEach(() => {
+ i18nStub.language = "en";
+ });
+
+ it("groups in the UI language, not in the runtime's", () => {
+ const asEnglish = formatNumber(1234567);
+ i18nStub.language = "hu";
+ const asHungarian = formatNumber(1234567);
+ expect(asEnglish).toBe(new Intl.NumberFormat("en").format(1234567));
+ expect(asHungarian).toBe(new Intl.NumberFormat("hu").format(1234567));
+ // The load-bearing half: under `toLocaleString()` both come out in the runtime's one locale.
+ expect(asEnglish).not.toBe(asHungarian);
+ });
+
+ it("follows a language switch at runtime (the per-language cache is keyed, not frozen)", () => {
+ expect(formatNumber(1000)).toBe(new Intl.NumberFormat("en").format(1000));
+ i18nStub.language = "hu";
+ expect(formatNumber(1000)).toBe(new Intl.NumberFormat("hu").format(1000));
+ i18nStub.language = "en";
+ expect(formatNumber(1000)).toBe(new Intl.NumberFormat("en").format(1000));
+ });
+
+ it("renders nothing for a value it cannot read, so a caller's dash fallback covers it", () => {
+ expect(formatNumber(null)).toBe("");
+ expect(formatNumber(undefined)).toBe("");
+ expect(formatNumber(NaN)).toBe("");
+ expect(formatNumber(Infinity)).toBe("");
+ expect(formatNumber(0)).toBe("0"); // a real zero is a value, not a missing one
+ });
+
+ it("carries the UI language into the channel total-hours label", () => {
+ i18nStub.language = "hu";
+ expect(formatTotalHours(12345 * 3600)).toBe(`${new Intl.NumberFormat("hu").format(12345)} h`);
+ });
+});
+
describe("formatRuntime", () => {
const CASES: [number | null | undefined, string][] = [
[null, ""],
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
index 16e7bf9d..0dde20e3 100644
--- a/frontend/src/lib/format.ts
+++ b/frontend/src/lib/format.ts
@@ -132,7 +132,28 @@ export function formatEta(seconds: number): string {
export function formatTotalHours(sec: number): string {
if (!sec) return "—";
const h = Math.round(sec / 3600);
- return h >= 1 ? `${h.toLocaleString()} h` : `${Math.max(1, Math.round(sec / 60))} m`;
+ return h >= 1 ? `${formatNumber(h)} h` : `${Math.max(1, Math.round(sec / 60))} m`;
+}
+
+// One grouped-number formatter per language, built lazily — the same reason as the relative-time
+// formatters above: the stats tables and the feed format a number per row, and constructing an Intl
+// formatter is the expensive part. Keyed by language because the user can switch it at runtime.
+const numberFormatters = new Map();
+
+/** A grouped number in the UI language ("1,234,567" in English, "1 234 567" in Hungarian). A bare
+ * `n.toLocaleString()` groups in the BROWSER's locale instead, so an English UI in a Hungarian
+ * browser showed space-grouped figures and vice versa — the number half of the same defect
+ * `formatDateTime` fixes for dates. Empty string for a missing/non-finite value, so a caller's
+ * `|| "—"` fallback covers it like a missing one. */
+export function formatNumber(n: number | null | undefined): string {
+ if (n == null || !Number.isFinite(n)) return "";
+ const lang = i18n.language;
+ let formatter = numberFormatters.get(lang);
+ if (!formatter) {
+ formatter = new Intl.NumberFormat(lang);
+ numberFormatters.set(lang, formatter);
+ }
+ return formatter.format(n);
}
export function formatViews(n: number | null): string {
From 6c63df67047e08aa145a7772f4bb8d56eec724ad Mon Sep 17 00:00:00 2001
From: npeter83
Date: Wed, 16 Sep 2026 01:37:02 +0200
Subject: [PATCH 06/26] feat: one structured log for every process, with a
request id
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A deployment printed four different log formats and none of them could be
joined up: uvicorn's log_config.json for the API, a hand-rolled StreamHandler in
main.py for anything not started by uvicorn, logging.basicConfig() in the worker
and in the download child (no timestamp at all), and alembic's fileConfig.
app/logs.py replaces all four with one JSON line per event (OpenTelemetry's log
data model, no SDK): ts, level, severity, source, logger, event code, message,
attributes, plus the correlation the context carries — request id, account,
download job.
Redaction is applied at the HANDLER, not at the call sites: every message, every
attribute and every traceback is scrubbed on the way out, so a secret cannot
leak by being forgotten at one of ~125 call sites. Passwords, tokens, cookies,
secret config values and email addresses never reach a line.
The access log is ours now (`http.request`): the route template instead of the
concrete path, the status, the duration and the account — and never the query
string, which uvicorn's access log printed on every single request (`?account=`,
`?source=`, the `?token=` of a share link). A well-formed client request id is
accepted and echoed in X-Request-Id, so a browser line and a server line join up.
The account comes from `request.state`, filled by the auth dependency: the
middleware is the outermost one, so it runs outside SessionMiddleware and in a
different task context.
`log_level` becomes an admin knob (sysconfig), applied without a restart and
re-applied at startup — best-effort on purpose, since a logging knob must never
be what stops the app from booting. Every compose service gets bounded json-file
logs (3×50 MB): Docker keeps every line forever by default, and the API now
writes one line per request.
Verified on the localdev stack: JSON everywhere including alembic and the
worker, the query strings gone, a supplied X-Request-Id echoed and logged, and
three /healthz hits of which only the one made while the level was DEBUG
produced a line.
---
.env.example | 8 +
backend/alembic/env.py | 8 +-
backend/app/auth.py | 7 +
backend/app/config.py | 10 +
backend/app/downloads/runner.py | 6 +-
backend/app/logs.py | 434 +++++++++++++++++++++++
backend/app/main.py | 37 +-
backend/app/routes/config.py | 11 +
backend/app/sysconfig.py | 5 +
backend/app/worker.py | 8 +-
backend/entrypoint.sh | 6 +-
backend/log_config.json | 40 ---
backend/tests/test_logs.py | 256 +++++++++++++
docker-compose.localdev.yml | 13 +
docker-compose.selfhost.yml | 13 +
docker-compose.yml | 13 +
frontend/src/i18n/locales/en/config.json | 7 +-
frontend/src/i18n/locales/hu/config.json | 7 +-
18 files changed, 828 insertions(+), 61 deletions(-)
create mode 100644 backend/app/logs.py
delete mode 100644 backend/log_config.json
create mode 100644 backend/tests/test_logs.py
diff --git a/.env.example b/.env.example
index 3e4dae17..cc5503de 100644
--- a/.env.example
+++ b/.env.example
@@ -28,6 +28,14 @@ ALLOWED_EMAILS=
# Admin emails (subset of the above) get the admin role.
ADMIN_EMAILS=
+# Logging (E22). LOG_LEVEL is the BOOTSTRAP level — the level a process starts at and the only one
+# the worker/download child read; the API's running level is the admin Configuration page's
+# `log_level` knob, which takes effect without a restart. TRACE | DEBUG | INFO | WARNING | ERROR
+# (an unknown value falls back to INFO). LOG_FORMAT is "json" (one object per line — what a log
+# collector ingests) or "text" (a readable line for a local terminal).
+LOG_LEVEL=INFO
+LOG_FORMAT=json
+
# Optional: origin of a separately-served frontend dev server (enables CORS). Leave empty in production.
FRONTEND_ORIGIN=
diff --git a/backend/alembic/env.py b/backend/alembic/env.py
index 095c8cd8..2870589f 100644
--- a/backend/alembic/env.py
+++ b/backend/alembic/env.py
@@ -1,17 +1,17 @@
-from logging.config import fileConfig
-
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.config import settings
from app.db import Base
+from app.logs import configure_logging
import app.models # noqa: F401 (import so models register on Base.metadata)
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
-if config.config_file_name is not None:
- fileConfig(config.config_file_name)
+# Migrations run at container start (entrypoint.sh), so their output is part of the API's log
+# stream — one format with everything else, not alembic.ini's own fileConfig shape.
+configure_logging("alembic")
target_metadata = Base.metadata
diff --git a/backend/app/auth.py b/backend/app/auth.py
index 225f3305..e2f6390d 100644
--- a/backend/app/auth.py
+++ b/backend/app/auth.py
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
from starlette.requests import HTTPConnection
from app import email as email_mod
+from app import logs
from app import mailtext
from app import sysconfig
from app.config import settings
@@ -348,6 +349,12 @@ def _session_user(request: Request, db: Session, user_id: int | None, is_default
if default_id and default_id not in accounts:
accounts.append(default_id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
+ # 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)
+ request.state.user_id = user.id
return user
diff --git a/backend/app/config.py b/backend/app/config.py
index 0483011c..fd59dfbc 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -225,6 +225,16 @@ class Settings(BaseSettings):
# Default SponsorBlock (skip sponsor segments) for new custom profiles.
sponsorblock_default: bool = False
+ # --- Diagnostics / logging (E22) ---
+ # BOOTSTRAP level only: the running level is the `log_level` admin knob (sysconfig), applied at
+ # startup and on change without a restart. This is what a process logs at before it can reach
+ # the database — and the only knob the worker/runner children read, since they are short-lived.
+ # TRACE (below DEBUG) is the "Teljes" diagnostics level; see app/logs.py.
+ log_level: str = "INFO"
+ # "json" (one object per line — what Loki and the log_events sink ingest) or "text" (a readable
+ # line for a local terminal). Redaction applies to both.
+ log_format: str = "json"
+
# --- Plex integration (optional) ---
# The Plex module plays the LOCAL physical media file directly (our own ffmpeg/direct-serve);
# Plex is used only for metadata + optional watch-state sync. Requires the Plex server to be
diff --git a/backend/app/downloads/runner.py b/backend/app/downloads/runner.py
index 8b1d777a..9c69c56e 100644
--- a/backend/app/downloads/runner.py
+++ b/backend/app/downloads/runner.py
@@ -17,6 +17,7 @@ import shutil
import subprocess
import sys
+from app import logs
from app.config import settings
from app.db import SessionLocal
from app.downloads import edit as editmod
@@ -52,12 +53,15 @@ def _deprioritize() -> None:
def main() -> None:
- logging.basicConfig(level=logging.INFO)
+ logs.configure_logging("runner")
_deprioritize()
if len(sys.argv) != 2:
print("usage: python -m app.downloads.runner ", file=sys.stderr)
raise SystemExit(2)
job_id = int(sys.argv[1])
+ # This whole process exists to run ONE job — tag every line it prints with it, so the child's
+ # output joins up with the worker's and the API's view of the same download.
+ logs.set_job(job_id)
with SessionLocal() as db:
job = db.get(DownloadJob, job_id)
diff --git a/backend/app/logs.py b/backend/app/logs.py
new file mode 100644
index 00000000..9ef7b27f
--- /dev/null
+++ b/backend/app/logs.py
@@ -0,0 +1,434 @@
+"""Structured logging for every Siftlode process — the ONE place logging is configured.
+
+Before this module a single deployment printed FOUR different log formats: uvicorn's
+`log_config.json` for the API, a hand-rolled StreamHandler in `main.py` for everything that did not
+start under uvicorn (tests, scripts), `logging.basicConfig()` in the worker and in the download
+child (no timestamp at all), and alembic's `fileConfig`. None of them carried a correlation id, and
+uvicorn's access log printed the query string of every request (`?account=1`, `?source=feed`) — so
+the logs were both unjoinable and leaky. `configure_logging()` replaces all four.
+
+Event shape — the OpenTelemetry log data model, without the SDK; one JSON object per line:
+ ts · level · sev · source · logger · event · msg · attrs
+plus whatever correlation the context carries (`request_id`, `user_id`, `debug_session_id`,
+`job_id`) and the app version. That shape is what E22.6's admin console filters on and what a Gitea
+issue attachment will carry, so it is deliberately flat and stable.
+
+Call sites stay ordinary stdlib logging:
+
+ log = logging.getLogger("siftlode.downloads")
+ log.info("job finished", extra=logs.event("download.job.done", job_id=7, bytes=n))
+
+`event()` is optional — a bare `log.info("…")` still comes out well-formed — but the dotted code is
+what makes a line queryable later, so add one wherever the line matters.
+
+REDACTION IS APPLIED AT THE HANDLER, not at the call site: every message, every attr value and
+every traceback passes `redact()` on the way out. That is the whole point — a secret cannot be
+leaked by forgetting to redact it at one of ~125 call sites, and a new call site is safe by
+default. Never logged, at any level: passwords, tokens (session, reset/verify, setup, share,
+OAuth), cookie blobs, secret config values, and email addresses.
+"""
+
+import contextlib
+import contextvars
+import json
+import logging
+import logging.config
+import re
+import sys
+import time
+import uuid
+from datetime import datetime, timezone
+from typing import Any
+
+from app.config import settings
+
+# One level below DEBUG for the "Teljes / TRACE" diagnostics level (search strings, redacted request
+# bodies). Registered on import so `logging.getLogger(...).log(logs.TRACE, …)` and a `LOG_LEVEL=TRACE`
+# both work in every process, including one that never calls configure_logging (a test).
+TRACE = 5
+logging.addLevelName(TRACE, "TRACE")
+
+# OpenTelemetry severity numbers for our level names. Kept beside the level because the number is
+# what a log backend sorts and filters on; the name is for humans reading a terminal.
+SEVERITY: dict[str, int] = {
+ "TRACE": 1,
+ "DEBUG": 5,
+ "INFO": 9,
+ "WARNING": 13,
+ "ERROR": 17,
+ "CRITICAL": 21,
+}
+
+# Which process this is. Set by configure_logging; every line carries it so one Loki/DB query can
+# separate the API's view of a job from the worker's.
+_source = "api"
+
+
+# --- correlation context -------------------------------------------------------------------
+# contextvars, like quota's actor attribution (app/quota.py): set at an entry point, read by the
+# formatter, never threaded through call signatures. A child task copies the context at creation,
+# so anything set before `await call_next(...)` is visible to the whole request.
+_request_id: contextvars.ContextVar[str | None] = contextvars.ContextVar(
+ "log_request_id", default=None
+)
+_user_id: contextvars.ContextVar[int | None] = contextvars.ContextVar("log_user_id", default=None)
+_debug_session: contextvars.ContextVar[str | None] = contextvars.ContextVar(
+ "log_debug_session", default=None
+)
+_job_id: contextvars.ContextVar[int | None] = contextvars.ContextVar("log_job_id", default=None)
+
+_CONTEXT_VARS = {
+ "request_id": _request_id,
+ "user_id": _user_id,
+ "debug_session_id": _debug_session,
+ "job_id": _job_id,
+}
+
+
+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."""
+ _user_id.set(user_id)
+
+
+def set_job(job_id: int | None) -> None:
+ """Attribute this process's log lines to a download job — for the download CHILD, whose whole
+ life is one job (the worker uses `bind()` per job instead, since it juggles several)."""
+ _job_id.set(job_id)
+
+
+@contextlib.contextmanager
+def bind(**fields: Any):
+ """Bind correlation fields for the duration of a block (a worker job, a scheduler run):
+
+ with logs.bind(job_id=job.id):
+ ...
+
+ Unknown field names are ignored rather than raising — a caller binding a field this module does
+ not know about is a typo, not a reason to take down a download."""
+ tokens = [(_CONTEXT_VARS[k].set(v)) for k, v in fields.items() if k in _CONTEXT_VARS]
+ keys = [k for k in fields if k in _CONTEXT_VARS]
+ try:
+ yield
+ finally:
+ for key, token in zip(keys, tokens):
+ _CONTEXT_VARS[key].reset(token)
+
+
+def event(code: str, **attrs: Any) -> dict[str, Any]:
+ """`extra=` payload for a log call: a stable dotted event code plus structured attributes.
+
+ Returns a plain dict so it composes with stdlib logging and costs nothing when the level is off
+ (the dict is built either way; keep the attrs cheap — no DB reads inside the call)."""
+ return {"event": code, "attrs": attrs}
+
+
+# --- redaction -----------------------------------------------------------------------------
+# Substring matching, not exact keys: the same secret travels as `password`, `current_password`,
+# `new_password`, `smtp_password`, `plex_server_token`, `access_token`, … Matching on the PART is
+# what makes a key we have never seen safe by default.
+_SECRET_KEY_PARTS = (
+ "password",
+ "passphrase",
+ "token",
+ "secret",
+ "cookie",
+ "authorization",
+ "auth_header",
+ "ciphertext",
+ "private_key",
+ "api_key",
+ "apikey",
+ "credential",
+ "session_key",
+)
+
+# Query/fragment parameters that carry a credential in a URL we log (OAuth `code`/`state`, our own
+# `?token=` setup and share links, `#verify=` / `#reset=` fragments).
+_SECRET_PARAM_RE = re.compile(
+ r"\b((?:access_token|refresh_token|id_token|token|code|state|key|secret|password|pwd|sig|"
+ r"signature|verify|reset|setup)=)([^&\s\"']+)",
+ re.IGNORECASE,
+)
+
+# Deliberately loose: over-matching redacts a non-address that looks like one (harmless), while
+# under-matching puts a real address in a log we attach to an issue (not harmless).
+_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
+
+REDACTED = "[redacted]"
+REDACTED_EMAIL = "[email]"
+
+
+def redact_text(text: str) -> str:
+ """Scrub a free-text string: credential-bearing URL params and email addresses."""
+ scrubbed = _SECRET_PARAM_RE.sub(lambda m: f"{m.group(1)}{REDACTED}", text)
+ return _EMAIL_RE.sub(REDACTED_EMAIL, scrubbed)
+
+
+def redact(value: Any, *, key: str = "") -> Any:
+ """Recursively scrub a structure destined for the log.
+
+ A value whose KEY names a secret is dropped whole (we never log a prefix of a token — a prefix
+ is still a leak and still useless). Everything else is scrubbed as text. Depth is bounded by
+ the structures we actually log (attrs dicts), and non-JSON types fall through to `str`."""
+ lowered = key.lower()
+ if key and any(part in lowered for part in _SECRET_KEY_PARTS):
+ return REDACTED
+ if isinstance(value, dict):
+ return {k: redact(v, key=str(k)) for k, v in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [redact(v, key=key) for v in value]
+ if isinstance(value, str):
+ return redact_text(value)
+ return value
+
+
+# --- formatters ----------------------------------------------------------------------------
+# Attributes every LogRecord carries; anything else a caller passed via `extra=` is a structured
+# 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"}
+)
+
+
+class ContextFilter(logging.Filter):
+ """Copies the correlation contextvars onto the record. A Filter (not the formatter) so the
+ values are captured at emit time, before a QueueHandler can move the record to another
+ thread — the DB sink in S3 depends on that."""
+
+ def filter(self, record: logging.LogRecord) -> bool:
+ for name, var in _CONTEXT_VARS.items():
+ value = var.get()
+ if value is not None and not hasattr(record, name):
+ setattr(record, name, value)
+ return True
+
+
+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 {})
+ attrs.update({k: v for k, v in extra.items() if k not in _CONTEXT_VARS})
+ return attrs
+
+
+def _safe_message(record: logging.LogRecord) -> str:
+ """`record.getMessage()` raises if the args don't match the format string. A broken log call
+ must not take down the caller, so the failure is reported IN the line instead."""
+ try:
+ return record.getMessage()
+ except Exception as exc: # noqa: BLE001 — any formatting error, reported not raised
+ return f"{record.msg!r} (log formatting failed: {exc})"
+
+
+class JsonFormatter(logging.Formatter):
+ """One JSON object per line — the shape Loki/the `log_events` table both ingest."""
+
+ def format(self, record: logging.LogRecord) -> str:
+ payload: dict[str, Any] = {
+ "ts": datetime.fromtimestamp(record.created, timezone.utc).isoformat(
+ timespec="milliseconds"
+ ),
+ "level": record.levelname,
+ "sev": SEVERITY.get(record.levelname, 9),
+ "source": _source,
+ "logger": record.name,
+ "msg": redact_text(_safe_message(record)),
+ }
+ code = getattr(record, "event", None)
+ if code:
+ payload["event"] = code
+ for name in _CONTEXT_VARS:
+ value = getattr(record, name, None)
+ if value is not None:
+ payload[name] = value
+ attrs = _record_attrs(record)
+ if attrs:
+ payload["attrs"] = redact(attrs)
+ if record.exc_info:
+ payload["exc"] = redact_text(self.formatException(record.exc_info))
+ payload["version"] = settings.app_version
+ return json.dumps(payload, ensure_ascii=False, default=str)
+
+
+class TextFormatter(logging.Formatter):
+ """Human-readable single line for a terminal (`LOG_FORMAT=text`): the same content, ordered for
+ reading. Redaction applies here too — a dev log is still a log someone pastes into an issue."""
+
+ def format(self, record: logging.LogRecord) -> str:
+ stamp = datetime.fromtimestamp(record.created, timezone.utc).strftime("%H:%M:%S.%f")[:-3]
+ head = f"{stamp} {record.levelname:<5} [{record.name}]"
+ rid = getattr(record, "request_id", None)
+ if rid:
+ head += f" {rid}"
+ code = getattr(record, "event", None)
+ line = f"{head} {code + ' — ' if code else ''}{redact_text(_safe_message(record))}"
+ attrs = _record_attrs(record)
+ if attrs:
+ line += f" {json.dumps(redact(attrs), ensure_ascii=False, default=str)}"
+ if record.exc_info:
+ line += "\n" + redact_text(self.formatException(record.exc_info))
+ return line
+
+
+# --- configuration -------------------------------------------------------------------------
+# Third-party loggers that are noisy at our own level. Pinned explicitly so the diagnostics knob can
+# lower the ROOT level to DEBUG/TRACE without drowning the output in SQL statements and HTTP chatter
+# — raising THESE is a deliberate, separate act (E22.6's console).
+_THIRD_PARTY_LEVELS = {
+ "sqlalchemy": "WARNING",
+ "sqlalchemy.engine": "WARNING",
+ "apscheduler": "WARNING",
+ "urllib3": "WARNING",
+ "httpx": "WARNING",
+ "httpcore": "WARNING",
+ "asyncio": "WARNING",
+ "multipart": "WARNING",
+ "PIL": "WARNING",
+ "alembic": "INFO",
+ # uvicorn's own startup/shutdown/WebSocket lines are worth keeping; its ACCESS log is not —
+ # it is one line per request with the query string in it, replaced by `log_requests` below.
+ "uvicorn": "INFO",
+ "uvicorn.error": "INFO",
+ "uvicorn.access": "CRITICAL",
+}
+
+
+def resolve_level(raw: str | None) -> int:
+ """Level name (case-insensitive) → number, falling back to INFO for an unknown value. A typo in
+ a config knob must not silence the app."""
+ if not raw:
+ return logging.INFO
+ value = logging.getLevelName(raw.strip().upper())
+ return value if isinstance(value, int) else logging.INFO
+
+
+def configure_logging(source: str, *, level: str | None = None) -> None:
+ """Configure logging for THIS process. Call once, first thing, in every entry point:
+ `main.py` (api), `worker.main`, `downloads/runner.main`, `alembic/env.py`.
+
+ `source` names the process in every line. `level` overrides `LOG_LEVEL` (the bootstrap default);
+ the runtime admin knob goes through `set_level()` instead, so changing it needs no restart.
+
+ Our loggers deliberately keep `propagate=True` and own no handler: everything reaches the single
+ root StreamHandler. That is what keeps `caplog` working in tests, and it means a new logger name
+ is configured the moment it is created."""
+ global _source
+ _source = source
+ formatter = "json" if (settings.log_format or "json").lower() != "text" else "text"
+ logging.config.dictConfig(
+ {
+ "version": 1,
+ # Never disable loggers created at import time (module-level `getLogger` calls run
+ # before this function does).
+ "disable_existing_loggers": False,
+ "formatters": {
+ "json": {"()": f"{__name__}.JsonFormatter"},
+ "text": {"()": f"{__name__}.TextFormatter"},
+ },
+ "filters": {"context": {"()": f"{__name__}.ContextFilter"}},
+ "handlers": {
+ "stdout": {
+ "class": "logging.StreamHandler",
+ "stream": sys.stdout,
+ "formatter": formatter,
+ "filters": ["context"],
+ }
+ },
+ "root": {"level": resolve_level(level or settings.log_level), "handlers": ["stdout"]},
+ "loggers": {name: {"level": lvl} for name, lvl in _THIRD_PARTY_LEVELS.items()},
+ }
+ )
+
+
+def set_level(raw: str | None) -> 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(raw))
+
+
+def current_level() -> str:
+ return logging.getLevelName(logging.getLogger().getEffectiveLevel())
+
+
+# --- request logging -----------------------------------------------------------------------
+# A client may supply its own request id so a browser log line and a server line join up (E22 S4).
+# Accepted only in this shape: it ends up in a log field, and an unbounded header would be a way to
+# write arbitrary text into the log.
+_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
+
+# Paths whose success is logged at DEBUG rather than INFO: the container healthcheck runs every 15s
+# (5.7k lines/day saying nothing) and hashed static assets are served by the hundred per page load.
+# A FAILURE on either still logs at WARNING/ERROR — this lowers the floor, it does not hide.
+_QUIET_PREFIXES = ("/healthz", "/assets/", "/static/")
+
+_request_log = logging.getLogger("siftlode.http")
+
+
+async def log_requests(request, call_next):
+ """The single access-log line, replacing uvicorn's.
+
+ What changes versus `uvicorn.access`: the ROUTE TEMPLATE instead of the concrete path (so
+ `/api/videos/{video_id}` groups), never the query string (it carried `?account=`, and at TRACE
+ the redacted query belongs in attrs, not in the URL), a request id echoed back in
+ `X-Request-Id`, the duration, and the account — read from `request.state`, which the auth
+ dependency fills, because this middleware sits outside both the session middleware and the
+ downstream task's context."""
+ 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)
+ request.state.request_id = rid
+ started = time.perf_counter()
+ try:
+ response = await call_next(request)
+ except Exception:
+ # The unhandled-exception path: FastAPI's ServerErrorMiddleware re-raises after its own
+ # 500, so without this the ONE request that most needs a log line is the one with no
+ # access-log entry at all.
+ _request_log.exception(
+ "request failed",
+ extra=event(
+ "http.request",
+ method=request.method,
+ path=_route_template(request),
+ duration_ms=round((time.perf_counter() - started) * 1000, 1),
+ user_id=getattr(request.state, "user_id", None),
+ ),
+ )
+ _request_id.reset(token)
+ raise
+ duration_ms = round((time.perf_counter() - started) * 1000, 1)
+ status = response.status_code
+ quiet = request.url.path.startswith(_QUIET_PREFIXES)
+ if status >= 500:
+ level = logging.ERROR
+ elif status >= 400:
+ level = logging.WARNING
+ else:
+ level = logging.DEBUG if quiet else logging.INFO
+ _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,
+ user_id=getattr(request.state, "user_id", None),
+ ),
+ )
+ response.headers["X-Request-Id"] = rid
+ _request_id.reset(token)
+ return response
+
+
+def _route_template(request) -> str:
+ """The matched route's template (`/api/videos/{video_id}`), else the raw path for a 404 or a
+ static file. Never the query string."""
+ route = request.scope.get("route")
+ return getattr(route, "path", None) or request.url.path
diff --git a/backend/app/main.py b/backend/app/main.py
index 792d9335..8a1423dd 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,7 +1,6 @@
import asyncio
import logging
import math
-import sys
from contextlib import asynccontextmanager, suppress
from pathlib import Path
@@ -9,16 +8,13 @@ from fastapi import FastAPI, HTTPException, Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
-# When started via uvicorn --log-config the "siftlode" logger is already configured
-# (see log_config.json). This block is a timestamped fallback for other entrypoints
-# (tests, scripts) so our logs are never silently dropped.
-_siftlode_logger = logging.getLogger("siftlode")
-if not _siftlode_logger.handlers:
- _handler = logging.StreamHandler(sys.stdout)
- _handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-5s [%(name)s] %(message)s"))
- _siftlode_logger.addHandler(_handler)
- _siftlode_logger.setLevel(logging.INFO)
- _siftlode_logger.propagate = False
+# Logging is configured HERE, before the route modules are imported, so every logger they create at
+# import time is already covered. ONE format for the api, the worker, the download child and alembic
+# alike (app/logs.py) — this replaces the old uvicorn --log-config + the hand-rolled fallback that
+# used to live here, and uvicorn's access log is replaced by the `http.request` middleware below.
+from app.logs import configure_logging, current_level, log_requests, set_level
+
+configure_logging("api")
log = logging.getLogger("siftlode.app")
from fastapi.middleware.cors import CORSMiddleware
@@ -28,6 +24,7 @@ from sqlalchemy.exc import DataError
from starlette.middleware.sessions import SessionMiddleware
from app import state
+from app import sysconfig
from app.config import settings
from app.db import SessionLocal
from app.plex import stream as plex_stream
@@ -66,6 +63,16 @@ async def lifespan(app: FastAPI):
# First-run: if the instance isn't configured yet, mint a fresh one-time setup token and print
# the wizard URL so the operator can open it (the app runs in setup mode until they finish).
with SessionLocal() as db:
+ # The admin's runtime level (sysconfig `log_level`) wins over the LOG_LEVEL bootstrap
+ # default, so a level chosen in the UI survives a restart without touching the environment.
+ # Best-effort on purpose: a LOGGING knob must never be the thing that stops the app from
+ # booting — an unreachable or not-yet-migrated database has to start into the env level.
+ try:
+ set_level(sysconfig.get_str(db, "log_level"))
+ except Exception as exc: # noqa: BLE001 — any DB/config failure keeps the bootstrap level
+ log.warning(
+ "could not read the stored log level (%s); staying at %s", exc, current_level()
+ )
if not state.is_configured(db):
token = state.rotate_setup_token(db)
url = f"{settings.app_base}/setup?token={token}"
@@ -243,6 +250,14 @@ async def security_headers(request, call_next):
return response
+# The access log (app/logs.py): request id, route template, status, duration — one line, never a
+# query string. Registered LAST so it is the OUTERMOST http middleware and therefore times the whole
+# request, including what the three above do. Being outermost also puts it OUTSIDE SessionMiddleware,
+# which is why the account comes from `request.state.user_id` (set by the auth dependency, the one
+# place a session is actually resolved into an account) rather than from `request.session`.
+app.middleware("http")(log_requests)
+
+
app.include_router(health.router)
app.include_router(auth_routes.router)
app.include_router(sync.router)
diff --git a/backend/app/routes/config.py b/backend/app/routes/config.py
index 59f2afd3..b850e6be 100644
--- a/backend/app/routes/config.py
+++ b/backend/app/routes/config.py
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from app import audit
from app import email as email_mod
+from app import logs
from app import mailtext
from app import sysconfig
from app.audit import AuditAction
@@ -29,6 +30,14 @@ def _redact(v):
return _URL_USERINFO.sub(r"\1***@", v) if isinstance(v, str) else v
+def _apply_runtime(db: Session, key: str) -> None:
+ """Some knobs change how THIS process behaves, not just what a later read returns — they have to
+ take effect now, without a restart. One dispatch for both call sites (set and reset), so the next
+ such key can't be wired into one of them and forgotten in the other."""
+ if key == "log_level":
+ logs.set_level(sysconfig.get_str(db, "log_level"))
+
+
@router.get("")
def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
return sysconfig.describe(db)
@@ -80,6 +89,7 @@ def set_config(
after=None if s.secret else {"value": _redact(new)},
)
db.commit()
+ _apply_runtime(db, key)
return sysconfig.describe(db)
@@ -104,6 +114,7 @@ def reset_config(
before=None if s.secret else {"value": _redact(old)},
)
db.commit()
+ _apply_runtime(db, key)
return sysconfig.describe(db)
diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py
index 5ade8ac2..3ca1e0e0 100644
--- a/backend/app/sysconfig.py
+++ b/backend/app/sysconfig.py
@@ -150,6 +150,11 @@ SPECS: tuple[ConfigSpec, ...] = (
# --- Audit log ---
# Days to keep admin audit-log rows before the audit_gc job prunes them (0 = keep forever).
ConfigSpec("audit_retention_days", "int", "audit", "audit_retention_days", min=0, max=3650),
+ # --- Diagnostics (E22) ---
+ # Runtime log level for the API process: TRACE | DEBUG | INFO | WARNING | ERROR. Free-text like
+ # search_source (an unknown value falls back to INFO in logs.resolve_level, so a typo can't
+ # silence the app). Applied without a restart — see routes/config.py and main.py's lifespan.
+ ConfigSpec("log_level", "str", "diagnostics", "log_level"),
# --- Plex integration (optional; local-file playback + Plex metadata) ---
ConfigSpec("plex_enabled", "bool", "plex", "plex_enabled"),
ConfigSpec("plex_server_url", "str", "plex", "plex_server_url"),
diff --git a/backend/app/worker.py b/backend/app/worker.py
index 359f5844..a90b5fd9 100644
--- a/backend/app/worker.py
+++ b/backend/app/worker.py
@@ -28,6 +28,7 @@ from datetime import datetime, timezone
from sqlalchemy import text
+from app import logs
from app import sysconfig
from app.config import settings
from app.db import SessionLocal
@@ -492,14 +493,17 @@ def _worker_loop(idx: int) -> None:
_stop.wait(settings.download_poll_seconds)
continue
try:
- _process_job(job_id)
+ # Tag every line this job produces (including the crash below) with the job id: a worker
+ # runs N jobs at once on N threads, so without it the interleaved output is unreadable.
+ with logs.bind(job_id=job_id):
+ _process_job(job_id)
except Exception: # noqa: BLE001
log.exception("processing job %s crashed", job_id)
_fail_running(job_id, error="Worker error", error_code=None)
def main() -> None:
- logging.basicConfig(level=logging.INFO)
+ logs.configure_logging("worker")
signal.signal(signal.SIGTERM, _handle_signal)
signal.signal(signal.SIGINT, _handle_signal)
diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh
index c2c52c1d..4621a2c1 100644
--- a/backend/entrypoint.sh
+++ b/backend/entrypoint.sh
@@ -15,4 +15,8 @@ echo "Starting Siftlode API..."
# X-Forwarded-For only when the TRUE socket peer is a configured proxy (TRUSTED_PROXY_IPS); those
# flags would make uvicorn overwrite request.client from the forgeable XFF, defeating that check and
# reopening the rate-limit bypass (SA3).
-exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --log-config log_config.json --timeout-keep-alive 75
+#
+# No --log-config: app/logs.py configures logging for every Siftlode process (api, worker, download
+# child, alembic) in one place, and it silences uvicorn's access log in favour of our own
+# `http.request` line (route template, no query string, request id).
+exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --timeout-keep-alive 75
diff --git a/backend/log_config.json b/backend/log_config.json
deleted file mode 100644
index 8b1a481b..00000000
--- a/backend/log_config.json
+++ /dev/null
@@ -1,40 +0,0 @@
-{
- "version": 1,
- "disable_existing_loggers": false,
- "formatters": {
- "default": {
- "()": "uvicorn.logging.DefaultFormatter",
- "fmt": "%(asctime)s %(levelprefix)s [%(name)s] %(message)s"
- },
- "access": {
- "()": "uvicorn.logging.AccessFormatter",
- "fmt": "%(asctime)s %(levelprefix)s %(client_addr)s - \"%(request_line)s\" %(status_code)s"
- },
- "plain": {
- "format": "%(asctime)s %(levelname)-5s [%(name)s] %(message)s"
- }
- },
- "handlers": {
- "default": {
- "class": "logging.StreamHandler",
- "formatter": "default",
- "stream": "ext://sys.stdout"
- },
- "access": {
- "class": "logging.StreamHandler",
- "formatter": "access",
- "stream": "ext://sys.stdout"
- },
- "plain": {
- "class": "logging.StreamHandler",
- "formatter": "plain",
- "stream": "ext://sys.stdout"
- }
- },
- "loggers": {
- "uvicorn": { "handlers": ["default"], "level": "INFO", "propagate": false },
- "uvicorn.error": { "handlers": ["default"], "level": "INFO", "propagate": false },
- "uvicorn.access": { "handlers": ["access"], "level": "INFO", "propagate": false },
- "siftlode": { "handlers": ["plain"], "level": "INFO", "propagate": false }
- }
-}
diff --git a/backend/tests/test_logs.py b/backend/tests/test_logs.py
new file mode 100644
index 00000000..2c19e185
--- /dev/null
+++ b/backend/tests/test_logs.py
@@ -0,0 +1,256 @@
+"""E22 S2 — the one logging configuration, its redaction, and the request line that replaced
+uvicorn's access log.
+
+Redaction is the part worth guarding hardest: it is applied at the HANDLER, so these cases are the
+only thing standing between a future call site and a token in a log we attach to a Gitea issue.
+The request-line cases pin what uvicorn's access log got wrong — the query string (`?account=1`,
+`?token=…`) was printed on every request, and nothing correlated two lines of the same request.
+
+Pure logic + an in-process ASGI app: no database.
+"""
+
+import json
+import logging
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from app import logs
+
+
+class _Sink(logging.Handler):
+ """Captures records AND their formatted JSON, with the same filter/formatter the real stdout
+ handler uses — so a test sees exactly the bytes a deployment would write."""
+
+ def __init__(self):
+ super().__init__(level=logging.NOTSET)
+ self.lines: list[str] = []
+ self.setFormatter(logs.JsonFormatter())
+ self.addFilter(logs.ContextFilter())
+
+ def emit(self, record):
+ self.lines.append(self.format(record))
+
+ def objects(self) -> list[dict]:
+ return [json.loads(line) for line in self.lines]
+
+
+@pytest.fixture
+def sink():
+ """Attached to `siftlode` (not root) so a `configure_logging` call inside a test — which
+ replaces the ROOT handlers — can't silently detach it mid-test."""
+ logger = logging.getLogger("siftlode")
+ handler = _Sink()
+ previous = logger.level
+ logger.addHandler(handler)
+ logger.setLevel(logs.TRACE)
+ try:
+ yield handler
+ finally:
+ logger.removeHandler(handler)
+ logger.setLevel(previous)
+
+
+# --- redaction ------------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ "text, must_not_contain",
+ [
+ ("user signed in: someone@example.com", "someone@example.com"),
+ ("callback https://app/auth/callback?code=4/0AY&state=xyz", "4/0AY"),
+ ("mail link https://app/#verify=abc123def", "abc123def"),
+ ("share https://app/s?token=SHARETOKEN", "SHARETOKEN"),
+ ("reset https://app/#reset=RESETTOKEN", "RESETTOKEN"),
+ ],
+)
+def test_free_text_secrets_never_reach_the_line(text, must_not_contain):
+ assert must_not_contain not in logs.redact_text(text)
+
+
+def test_a_key_that_names_a_secret_is_dropped_whole():
+ # Not truncated to a prefix: a prefix is still a leak and is useless for triage anyway.
+ out = logs.redact(
+ {
+ "password": "hunter2",
+ "current_password": "hunter2",
+ "plex_server_token": "abc",
+ "Cookie": "session=xyz",
+ "authorization": "Bearer abc",
+ "nested": {"refresh_token": "rt", "keep": "visible"},
+ "list": [{"api_key": "k"}],
+ "count": 7,
+ }
+ )
+ assert out["password"] == logs.REDACTED
+ assert out["current_password"] == logs.REDACTED
+ assert out["plex_server_token"] == logs.REDACTED
+ assert out["Cookie"] == logs.REDACTED
+ assert out["authorization"] == logs.REDACTED
+ assert out["nested"]["refresh_token"] == logs.REDACTED
+ assert out["nested"]["keep"] == "visible" # only the named keys go
+ assert out["list"][0]["api_key"] == logs.REDACTED
+ assert out["count"] == 7
+
+
+def test_an_email_in_an_attribute_value_is_scrubbed_too():
+ assert logs.redact({"to": "a.b+tag@mail.example.org"})["to"] == logs.REDACTED_EMAIL
+
+
+def test_redaction_runs_on_the_message_the_attrs_and_the_traceback(sink):
+ log = logging.getLogger("siftlode.test")
+ try:
+ raise RuntimeError("boom for someone@example.com")
+ except RuntimeError:
+ log.exception(
+ "failed for %s",
+ "someone@example.com",
+ extra=logs.event("test.failed", password="hunter2", url="https://x/?token=T0K3N"),
+ )
+ line = sink.lines[-1]
+ assert "someone@example.com" not in line
+ assert "hunter2" not in line
+ assert "T0K3N" not in line
+ payload = json.loads(line)
+ assert payload["attrs"]["password"] == logs.REDACTED
+ assert "RuntimeError" in payload["exc"] # the diagnosis survives the scrubbing
+
+
+# --- event shape ----------------------------------------------------------------------------
+
+
+def test_every_line_carries_the_agreed_fields(sink):
+ logging.getLogger("siftlode.test").info("hello", extra=logs.event("test.hello", n=1))
+ payload = sink.objects()[-1]
+ for field in ("ts", "level", "sev", "source", "logger", "msg", "event", "version"):
+ assert field in payload, field
+ assert payload["level"] == "INFO"
+ assert payload["sev"] == 9
+ assert payload["event"] == "test.hello"
+ assert payload["attrs"] == {"n": 1}
+
+
+def test_a_plain_log_call_still_produces_a_well_formed_line(sink):
+ """An event code is optional — 125 existing call sites have none yet (E22.5 adds them), and they
+ must not be worse off than before this module."""
+ logging.getLogger("siftlode.test").warning("just a message")
+ payload = sink.objects()[-1]
+ assert payload["msg"] == "just a message"
+ assert payload["level"] == "WARNING"
+ assert "event" not in payload
+
+
+def test_a_broken_format_call_reports_itself_instead_of_raising():
+ """A record is formatted directly rather than logged: a genuinely broken call ALSO breaks
+ pytest's own capture handler (stdlib formatting raises there too), which would fail the test for
+ the wrong reason. What is under test is our formatter's refusal to raise."""
+ record = logging.LogRecord(
+ "siftlode.test", logging.INFO, __file__, 0, "two args %s %s", ("only-one",), None
+ )
+ payload = json.loads(logs.JsonFormatter().format(record))
+ assert "log formatting failed" in payload["msg"]
+
+
+def test_bound_correlation_appears_and_is_released(sink):
+ log = logging.getLogger("siftlode.test")
+ with logs.bind(job_id=42):
+ log.info("inside")
+ log.info("outside")
+ assert sink.objects()[-2]["job_id"] == 42
+ assert "job_id" not in sink.objects()[-1]
+
+
+def test_an_unknown_level_name_falls_back_to_info_instead_of_silencing():
+ assert logs.resolve_level("nonsense") == logging.INFO
+ assert logs.resolve_level(None) == logging.INFO
+ assert logs.resolve_level("trace") == logs.TRACE
+ assert logs.resolve_level(" debug ") == logging.DEBUG
+
+
+def test_configure_logging_silences_the_uvicorn_access_log_and_keeps_ours_propagating():
+ logs.configure_logging("api")
+ assert logging.getLogger("uvicorn.access").getEffectiveLevel() >= logging.CRITICAL
+ # propagate stays on so every line reaches the ONE root handler — and so pytest's caplog works.
+ assert logging.getLogger("siftlode.anything").propagate is True
+ assert len(logging.getLogger().handlers) == 1
+
+
+# --- the request line -----------------------------------------------------------------------
+
+
+def _client() -> TestClient:
+ app = FastAPI()
+ app.middleware("http")(logs.log_requests)
+
+ @app.get("/api/videos/{video_id}")
+ def _video(video_id: str, q: str | None = None):
+ return {"ok": video_id}
+
+ @app.get("/healthz")
+ def _health():
+ return {"ok": True}
+
+ @app.get("/boom")
+ def _boom():
+ raise RuntimeError("kaboom")
+
+ return TestClient(app, raise_server_exceptions=False)
+
+
+def test_the_request_line_has_the_route_template_and_no_query_string(sink):
+ logging.getLogger("siftlode.http").setLevel(logs.TRACE)
+ r = _client().get("/api/videos/abc123?q=cats&token=SECRET")
+ assert r.status_code == 200
+ payload = sink.objects()[-1]
+ assert payload["event"] == "http.request"
+ # The TEMPLATE, so every video request groups into one queryable path...
+ assert payload["attrs"]["path"] == "/api/videos/{video_id}"
+ # ...and the query string — uvicorn's access log printed it, `?account=1` and all — is gone.
+ assert "SECRET" not in sink.lines[-1]
+ assert "cats" not in sink.lines[-1]
+ assert payload["attrs"]["status"] == 200
+ assert isinstance(payload["attrs"]["duration_ms"], float)
+
+
+def test_the_request_id_is_echoed_and_shared_by_every_line_of_that_request(sink):
+ client = _client()
+ r = client.get("/api/videos/abc123")
+ rid = r.headers["X-Request-Id"]
+ assert rid and sink.objects()[-1]["request_id"] == rid
+
+
+def test_a_client_supplied_id_is_reused_only_when_it_is_well_formed(sink):
+ client = _client()
+ good = client.get("/api/videos/x", headers={"X-Request-Id": "web-0123456789ab"})
+ assert good.headers["X-Request-Id"] == "web-0123456789ab"
+ # An unbounded header would be a way to write arbitrary text into the log; short/odd ones are
+ # replaced with our own id rather than trusted.
+ for bogus in ("short", "has spaces and \n newline", "x" * 200):
+ r = client.get("/api/videos/x", headers={"X-Request-Id": bogus})
+ assert r.headers["X-Request-Id"] != bogus
+
+
+def test_the_healthcheck_is_logged_at_debug_so_it_cannot_drown_the_log(sink):
+ http = logging.getLogger("siftlode.http")
+ http.setLevel(logs.TRACE)
+ _client().get("/healthz")
+ assert sink.objects()[-1]["level"] == "DEBUG"
+ # ...but it is still logged when something is wrong with it: the floor drops, nothing is hidden.
+ http.setLevel(logging.INFO)
+ before = len(sink.lines)
+ _client().get("/healthz")
+ assert len(sink.lines) == before
+
+
+def test_a_failing_request_is_logged_by_status_and_an_unhandled_error_is_logged_at_all(sink):
+ client = _client()
+ client.get("/api/videos/missing/deeper") # 404 from the router
+ assert sink.objects()[-1]["level"] == "WARNING"
+ client.get("/boom")
+ payload = sink.objects()[-1]
+ # The request that most needs a line is the one that raised: uvicorn's access log wrote it, but
+ # our middleware sees the exception instead of a response, so it has to log it explicitly.
+ assert payload["level"] == "ERROR"
+ assert payload["event"] == "http.request"
+ assert "RuntimeError" in payload["exc"]
diff --git a/docker-compose.localdev.yml b/docker-compose.localdev.yml
index 48560639..88dab27d 100644
--- a/docker-compose.localdev.yml
+++ b/docker-compose.localdev.yml
@@ -11,6 +11,15 @@
# applies local migrations to THIS local DB only. Seed the catalog once with a pg_dump from
# another instance if you want real data (see RUNBOOK).
+# Bounded container logs. Docker's json-file driver keeps every line forever by default, and the API
+# now writes one structured line per request (E22 S2) — 3×50 MB per service is days of history on a
+# busy instance and a hard ceiling on the disk it can take.
+x-logging: &logging
+ driver: json-file
+ options:
+ max-size: "50m"
+ max-file: "3"
+
services:
db:
image: postgres:16-alpine
@@ -27,6 +36,7 @@ services:
interval: 5s
timeout: 5s
retries: 12
+ logging: *logging
restart: unless-stopped
api:
@@ -63,6 +73,7 @@ services:
- ./downloads:/downloads
# Plex media, read-only (see the plex_media volume note below). Maps to plex_path_map.
- plex_media:/plex-media:ro
+ logging: *logging
restart: unless-stopped
# Dedicated download worker: same image, runs the yt-dlp job loop instead of the API.
@@ -96,6 +107,7 @@ services:
volumes:
- ./downloads:/downloads
- plex_media:/plex-media:ro
+ logging: *logging
restart: unless-stopped
# PO-token provider: mints YouTube Proof-of-Origin tokens on demand so the worker bypasses
@@ -106,6 +118,7 @@ services:
init: true
security_opt:
- apparmor:unconfined
+ logging: *logging
restart: unless-stopped
volumes:
diff --git a/docker-compose.selfhost.yml b/docker-compose.selfhost.yml
index 1c79fbd8..e1ba83c0 100644
--- a/docker-compose.selfhost.yml
+++ b/docker-compose.selfhost.yml
@@ -10,6 +10,15 @@
# Everything else — the admin account, Google sign-in, SMTP — is set in the web wizard on
# first run. See docs/self-hosting.md.
+# Bounded container logs. Docker's json-file driver keeps every line forever by default, and the API
+# now writes one structured line per request (E22 S2) — 3×50 MB per service is days of history on a
+# busy instance and a hard ceiling on the disk it can take.
+x-logging: &logging
+ driver: json-file
+ options:
+ max-size: "50m"
+ max-file: "3"
+
services:
db:
image: postgres:16-alpine
@@ -27,6 +36,7 @@ services:
interval: 10s
timeout: 5s
retries: 12
+ logging: *logging
restart: unless-stopped
api:
@@ -71,6 +81,7 @@ services:
timeout: 5s
retries: 5
start_period: 30s
+ logging: *logging
restart: unless-stopped
# Download worker: same image, runs the yt-dlp / ffmpeg job loop instead of the API. Shares the
@@ -109,6 +120,7 @@ services:
- ALL
tmpfs:
- /tmp
+ logging: *logging
restart: unless-stopped
# PO-token provider sidecar: mints YouTube Proof-of-Origin tokens so the worker beats bot-detection
@@ -119,6 +131,7 @@ services:
networks: [internal]
security_opt:
- no-new-privileges:true
+ logging: *logging
restart: unless-stopped
volumes:
diff --git a/docker-compose.yml b/docker-compose.yml
index 27cfd8cb..de724afe 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,3 +1,12 @@
+# Bounded container logs. Docker's json-file driver keeps every line forever by default, and the API
+# now writes one structured line per request (E22 S2) — 3×50 MB per service is days of history on a
+# busy instance and a hard ceiling on the disk it can take.
+x-logging: &logging
+ driver: json-file
+ options:
+ max-size: "50m"
+ max-file: "3"
+
services:
db:
image: postgres:16-alpine
@@ -12,6 +21,7 @@ services:
interval: 5s
timeout: 5s
retries: 12
+ logging: *logging
restart: unless-stopped
api:
@@ -49,6 +59,7 @@ services:
timeout: 5s
retries: 5
start_period: 25s
+ logging: *logging
restart: unless-stopped
# Download worker: same image, runs the yt-dlp / ffmpeg job loop instead of the API. Shares the
@@ -83,6 +94,7 @@ services:
- ${DOWNLOAD_HOST_PATH:-downloads}:/downloads
# Same read-only Plex library as the API — the worker resolves library paths too.
- ${PLEX_MEDIA_HOST_PATH:-plex_media}:/plex-media:ro
+ logging: *logging
restart: unless-stopped
# PO-token provider sidecar: mints YouTube Proof-of-Origin tokens so the worker beats bot-detection
@@ -90,6 +102,7 @@ services:
bgutil-pot:
image: brainicism/bgutil-ytdlp-pot-provider:1.3.1
init: true
+ logging: *logging
restart: unless-stopped
volumes:
diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json
index 72a4afdc..8007b981 100644
--- a/frontend/src/i18n/locales/en/config.json
+++ b/frontend/src/i18n/locales/en/config.json
@@ -15,7 +15,8 @@
"downloads": "Downloads",
"plex": "Plex",
"tmdb": "TMDB",
- "audit": "Audit log"
+ "audit": "Audit log",
+ "diagnostics": "Diagnostics"
},
"fields": {
"smtp_host": {
@@ -154,6 +155,10 @@
"label": "Retention (days)",
"hint": "How long admin audit-log entries are kept before the audit-log cleanup job prunes them. 0 = keep forever. Default 180."
},
+ "log_level": {
+ "label": "Log level",
+ "hint": "How much the server writes to its log. INFO is the normal setting; DEBUG and TRACE are for reproducing a problem and are far more detailed. Takes effect immediately, no restart."
+ },
"plex_enabled": {
"label": "Enable Plex",
"hint": "Expose the Plex library as a Feed source with a rich player. Requires the Plex server to be reachable from this host and its media on a local mount (see the path map)."
diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json
index 0e13dfda..28a61cd7 100644
--- a/frontend/src/i18n/locales/hu/config.json
+++ b/frontend/src/i18n/locales/hu/config.json
@@ -15,7 +15,8 @@
"downloads": "Letöltések",
"plex": "Plex",
"tmdb": "TMDB",
- "audit": "Naplózás"
+ "audit": "Naplózás",
+ "diagnostics": "Diagnosztika"
},
"fields": {
"smtp_host": {
@@ -154,6 +155,10 @@
"label": "Megőrzés (nap)",
"hint": "Meddig maradnak meg az admin-naplóbejegyzések, mielőtt a napló-karbantartó feladat törli őket. 0 = örökre. Alapérték 180."
},
+ "log_level": {
+ "label": "Naplózási szint",
+ "hint": "Mennyit naplózzon a szerver. Az INFO a szokásos; a DEBUG és a TRACE hibakereséshez való, sokkal részletesebb. Azonnal érvénybe lép, újraindítás nélkül."
+ },
"plex_enabled": {
"label": "Plex engedélyezése",
"hint": "A Plex-könyvtár megjelenítése forrásként, gazdag lejátszóval. Ehhez a Plex-szervernek elérhetőnek kell lennie erről a gépről, a média pedig helyi mounton (lásd az útvonal-leképezést)."
From 07766c43e829e1f7b716998183309d5838cb3307 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Thu, 17 Sep 2026 00:03:56 +0200
Subject: [PATCH 07/26] feat: closed-set admin knobs are pickers, not free text
log_level, search_source, download_layout and the transcode hw/preset
were typed by hand, and every consumer carried its own guess at what a
typo meant. The value set is now a Literal in config.py: pydantic
rejects a bad env value at boot (any letter case accepted), sysconfig
refuses a bad override and derives the admin picker's options from the
same Literal, and the consumers read the value as-is.
The Configuration page renders a choice key as a segmented bar, or a
dropdown past six options, with translated option names. Its tabs now
follow the registry's order instead of a hand-kept copy that had
already dropped the explore group.
A single-choice FilterSelect now opens on the option in use: in the
browser it could land on the first row instead.
---
.env.example | 6 +-
backend/app/config.py | 46 +++++--
backend/app/logs.py | 26 ++--
backend/app/plex/stream.py | 4 +-
backend/app/routes/search.py | 4 +-
backend/app/sysconfig.py | 68 ++++++----
backend/tests/test_logs.py | 17 ++-
backend/tests/test_sysconfig_registry.py | 93 ++++++++++++++
.../ConfigPanel.choice.dom.test.tsx | 118 ++++++++++++++++++
frontend/src/components/ConfigPanel.tsx | 80 ++++++++----
frontend/src/components/ui/FilterSelect.tsx | 14 ++-
frontend/src/i18n/backendIds.contract.test.ts | 28 ++++-
frontend/src/i18n/locales/en/config.json | 41 +++++-
frontend/src/i18n/locales/hu/config.json | 41 +++++-
frontend/src/lib/api/admin.ts | 3 +-
15 files changed, 496 insertions(+), 93 deletions(-)
create mode 100644 backend/tests/test_sysconfig_registry.py
create mode 100644 frontend/src/components/ConfigPanel.choice.dom.test.tsx
diff --git a/.env.example b/.env.example
index cc5503de..224dcecc 100644
--- a/.env.example
+++ b/.env.example
@@ -30,9 +30,9 @@ ADMIN_EMAILS=
# Logging (E22). LOG_LEVEL is the BOOTSTRAP level — the level a process starts at and the only one
# the worker/download child read; the API's running level is the admin Configuration page's
-# `log_level` knob, which takes effect without a restart. TRACE | DEBUG | INFO | WARNING | ERROR
-# (an unknown value falls back to INFO). LOG_FORMAT is "json" (one object per line — what a log
-# collector ingests) or "text" (a readable line for a local terminal).
+# `log_level` knob, which takes effect without a restart. TRACE | DEBUG | INFO | WARNING | ERROR, in
+# any letter case; any other value stops the boot with an error. LOG_FORMAT is "json" (one object per
+# line — what a log collector ingests) or "text" (a readable line for a local terminal).
LOG_LEVEL=INFO
LOG_FORMAT=json
diff --git a/backend/app/config.py b/backend/app/config.py
index fd59dfbc..5ba056ef 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -1,6 +1,7 @@
from pathlib import Path
+from typing import Literal, get_args, get_origin
-from pydantic import model_validator
+from pydantic import ValidationInfo, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Shipped placeholder; production must override it. Used by the startup guard below.
@@ -20,6 +21,25 @@ def _version_from_file() -> str:
return "dev"
+# Closed value sets. A setting typed with one of these is a CHOICE: pydantic rejects anything else at
+# boot, and sysconfig derives the admin picker's options from the same Literal — so the list lives in
+# exactly one place and a consumer never has to guess what a typo should fall back to.
+SearchSource = Literal["scrape", "api"]
+DownloadLayout = Literal["plex", "flat"]
+LogLevel = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"]
+LogFormat = Literal["json", "text"]
+TranscodeHw = Literal["auto", "on", "off"]
+# libx264's presets, fastest first (`placebo` left out on purpose: no measurable gain for its cost).
+TranscodePreset = Literal[
+ "ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"
+]
+
+
+def choices_of(annotation: object) -> tuple[str, ...]:
+ """The allowed values of a choice-typed setting, or () for any other type."""
+ return tuple(get_args(annotation)) if get_origin(annotation) is Literal else ()
+
+
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@@ -85,7 +105,7 @@ class Settings(BaseSettings):
search_daily_limit_per_user: int = 70
# Source for live YouTube search: "scrape" (InnerTube, no API quota) or "api" (search.list,
# 100 units/page). Scrape is the default; flip to "api" if scraping is blocked/breaks.
- search_source: str = "scrape"
+ search_source: SearchSource = "scrape"
# Recent-first backfill: how far back to fetch on the first pass per channel.
backfill_recent_max_videos: int = 100
backfill_recent_max_days: int = 365
@@ -221,7 +241,7 @@ class Settings(BaseSettings):
audit_retention_days: int = 180
audit_gc_minutes: int = 1440
# On-disk layout: "plex" (Channel/Season YYYY/… [id].ext + .nfo/poster) or "flat".
- download_layout: str = "plex"
+ download_layout: DownloadLayout = "plex"
# Default SponsorBlock (skip sponsor segments) for new custom profiles.
sponsorblock_default: bool = False
@@ -230,10 +250,10 @@ class Settings(BaseSettings):
# startup and on change without a restart. This is what a process logs at before it can reach
# the database — and the only knob the worker/runner children read, since they are short-lived.
# TRACE (below DEBUG) is the "Teljes" diagnostics level; see app/logs.py.
- log_level: str = "INFO"
+ log_level: LogLevel = "INFO"
# "json" (one object per line — what Loki and the log_events sink ingest) or "text" (a readable
# line for a local terminal). Redaction applies to both.
- log_format: str = "json"
+ log_format: LogFormat = "json"
# --- Plex integration (optional) ---
# The Plex module plays the LOCAL physical media file directly (our own ffmpeg/direct-serve);
@@ -276,7 +296,7 @@ class Settings(BaseSettings):
# Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the
# source codec is hardware-decodable, else libx264 software; "on" = force VAAPI wherever the render
# node exists (skip the decodability gate); "off" = always software. See plex/stream.py `_select_encode`.
- plex_transcode_hw: str = "auto"
+ plex_transcode_hw: TranscodeHw = "auto"
# Software-fallback libx264 tuning (IGNORED on the VAAPI path). `preset` trades CPU for size; `crf`
# trades quality for size (lower = better + bigger). `max_height` caps the SOFTWARE transcode's output
# height (0 = keep source) so a CPU re-encode of a tall source stays affordable — the GPU path always
@@ -286,7 +306,7 @@ class Settings(BaseSettings):
# call, not a fix. Only affects files that go through remux/transcode — a direct-played file is
# served untouched, but those are browser-native stereo anyway, not the 5.1 mixes this is for.
plex_audio_loudnorm: bool = False
- plex_transcode_preset: str = "veryfast"
+ plex_transcode_preset: TranscodePreset = "veryfast"
plex_transcode_crf: int = 20
plex_transcode_max_height: int = 1080
# Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls`
@@ -335,6 +355,18 @@ class Settings(BaseSettings):
https OAUTH_REDIRECT_URL as the signal for 'real deployment' vs local dev."""
return self.oauth_redirect_url.lower().startswith("https://")
+ @field_validator("*", mode="before")
+ @classmethod
+ def _canonical_choice(cls, value: object, info: ValidationInfo) -> object:
+ """A choice setting accepts its value in any letter case (`LOG_LEVEL=debug`) and stores the
+ canonical spelling; an unknown value still fails the boot with pydantic's own message."""
+ field = cls.model_fields.get(info.field_name or "")
+ options = choices_of(field.annotation) if field else ()
+ if options and isinstance(value, str):
+ folded = value.strip().casefold()
+ return next((o for o in options if o.casefold() == folded), value)
+ return value
+
@model_validator(mode="after")
def _enforce_production_secrets(self) -> "Settings":
"""Fail fast rather than boot a public instance with the shipped placeholder
diff --git a/backend/app/logs.py b/backend/app/logs.py
index 9ef7b27f..708a7fb5 100644
--- a/backend/app/logs.py
+++ b/backend/app/logs.py
@@ -295,20 +295,20 @@ _THIRD_PARTY_LEVELS = {
}
-def resolve_level(raw: str | None) -> int:
- """Level name (case-insensitive) → number, falling back to INFO for an unknown value. A typo in
- a config knob must not silence the app."""
- if not raw:
- return logging.INFO
- value = logging.getLevelName(raw.strip().upper())
- return value if isinstance(value, int) else logging.INFO
+def resolve_level(name: str) -> int:
+ """Level name → number. Both ways in (the `LOG_LEVEL` env and the `log_level` admin knob) are
+ already held to the `LogLevel` choices, so an unknown name here is a programming error."""
+ value = logging.getLevelName(name)
+ if not isinstance(value, int):
+ raise ValueError(f"unknown log level {name!r}")
+ return value
-def configure_logging(source: str, *, level: str | None = None) -> None:
+def configure_logging(source: str) -> None:
"""Configure logging for THIS process. Call once, first thing, in every entry point:
`main.py` (api), `worker.main`, `downloads/runner.main`, `alembic/env.py`.
- `source` names the process in every line. `level` overrides `LOG_LEVEL` (the bootstrap default);
+ `source` names the process in every line. The level starts at `LOG_LEVEL` (the bootstrap default);
the runtime admin knob goes through `set_level()` instead, so changing it needs no restart.
Our loggers deliberately keep `propagate=True` and own no handler: everything reaches the single
@@ -316,7 +316,7 @@ def configure_logging(source: str, *, level: str | None = None) -> None:
is configured the moment it is created."""
global _source
_source = source
- formatter = "json" if (settings.log_format or "json").lower() != "text" else "text"
+ formatter = settings.log_format
logging.config.dictConfig(
{
"version": 1,
@@ -336,16 +336,16 @@ def configure_logging(source: str, *, level: str | None = None) -> None:
"filters": ["context"],
}
},
- "root": {"level": resolve_level(level or settings.log_level), "handlers": ["stdout"]},
+ "root": {"level": resolve_level(settings.log_level), "handlers": ["stdout"]},
"loggers": {name: {"level": lvl} for name, lvl in _THIRD_PARTY_LEVELS.items()},
}
)
-def set_level(raw: str | None) -> None:
+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(raw))
+ logging.getLogger().setLevel(resolve_level(name))
def current_level() -> str:
diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py
index e65d65e8..aec74c3e 100644
--- a/backend/app/plex/stream.py
+++ b/backend/app/plex/stream.py
@@ -404,7 +404,7 @@ def _select_encode(db: DbSession, item: PlexItem, transcode: bool, src: Path) ->
source that has an OpenCL runtime, while a 10-bit SDR source drops to software."""
if not transcode:
return (False, False)
- mode = (sysconfig.get_str(db, "plex_transcode_hw") or "auto").lower()
+ mode = sysconfig.get_str(db, "plex_transcode_hw")
if mode == "off" or not _render_node_present():
return (False, False)
if mode == "on":
@@ -664,7 +664,7 @@ def start_session(
# remux ignore preset/crf/max_height, and remux must not touch the DB for them).
preset, crf, max_height = "veryfast", 20, 0
if transcode and not hw:
- preset = sysconfig.get_str(db, "plex_transcode_preset") or "veryfast"
+ preset = sysconfig.get_str(db, "plex_transcode_preset")
crf = sysconfig.get_int(db, "plex_transcode_crf")
max_height = sysconfig.get_int(db, "plex_transcode_max_height")
# Nice the SOFTWARE transcode child (not the HW path, and not remux) so a CPU-bound re-encode
diff --git a/backend/app/routes/search.py b/backend/app/routes/search.py
index 21338e8b..91ecad5d 100644
--- a/backend/app/routes/search.py
+++ b/backend/app/routes/search.py
@@ -231,9 +231,7 @@ def search_youtube(
limit = max(1, min(limit, 100))
# Source: "scrape" (InnerTube, no API quota) or "api" (search.list, 100 units/page).
- source = (sysconfig.get_str(db, "search_source") or "scrape").lower()
- if source != "scrape":
- source = "api"
+ source = sysconfig.get_str(db, "search_source")
# Per-user daily cap applies to both sources (anti-abuse); with scrape it's the only guard
# since the search itself spends no quota.
diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py
index 3ca1e0e0..b9cc7417 100644
--- a/backend/app/sysconfig.py
+++ b/backend/app/sysconfig.py
@@ -14,7 +14,7 @@ from dataclasses import dataclass
from sqlalchemy.orm import Session
from app import security
-from app.config import settings
+from app.config import Settings, choices_of, settings
from app.models import SystemConfig
log = logging.getLogger("siftlode.config")
@@ -27,7 +27,7 @@ _decrypt_warned: set[str] = set()
@dataclass(frozen=True)
class ConfigSpec:
key: str
- type: str # "int" | "str" | "bool"
+ type: str # "int" | "str" | "bool" | "choice"
group: str # admin-UI grouping (logically related keys stay together)
default_attr: str # attribute on `settings` holding the env/config default
secret: bool = False
@@ -35,16 +35,33 @@ class ConfigSpec:
max: int | None = None
allow_empty: bool = False # str keys: a stored "" is an explicit empty value, not "unset"
+ @property
+ def choices(self) -> tuple[str, ...]:
+ """A choice key's allowed values, read from its setting's Literal type in config.py — the one
+ place the list is written. The admin page renders them as a picker."""
+ return choices_of(Settings.model_fields[self.default_attr].annotation)
+
# Registry of DB-overridable keys. Add a key here + wire its read through get_*() to move it
-# off env into the admin UI; the admin page renders it automatically from this list.
+# off env into the admin UI; the admin page renders it automatically from this list. The ORDER is the
+# admin page's tab order (first appearance of each group), so keep a group's keys together.
SPECS: tuple[ConfigSpec, ...] = (
+ # --- Access / registration ---
+ ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
# --- Email / SMTP (one logical group; the password is the only secret) ---
ConfigSpec("smtp_host", "str", "email", "smtp_host"),
ConfigSpec("smtp_port", "int", "email", "smtp_port", min=1, max=65535),
ConfigSpec("smtp_user", "str", "email", "smtp_user", allow_empty=True),
ConfigSpec("smtp_from", "str", "email", "smtp_from"),
ConfigSpec("smtp_password", "str", "email", "smtp_password", secret=True),
+ # --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
+ ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
+ # Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key).
+ ConfigSpec("youtube_api_proxy", "str", "youtube", "youtube_api_proxy", allow_empty=True),
+ # --- Google OAuth client (Sign in with Google; both encrypted, env fallback). Set from the
+ # install wizard / Configuration page so no restart is needed when the creds change. ---
+ ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True),
+ ConfigSpec("google_client_secret", "str", "google", "google_client_secret", secret=True),
# --- Quota (shared YouTube Data API daily budget) ---
ConfigSpec("quota_daily_budget", "int", "quota", "quota_daily_budget", min=1, max=1_000_000),
ConfigSpec(
@@ -59,7 +76,7 @@ SPECS: tuple[ConfigSpec, ...] = (
max=100_000,
),
# Live-search source: "scrape" (InnerTube, zero API quota) or "api" (search.list, 100u/page).
- ConfigSpec("search_source", "str", "quota", "search_source"),
+ ConfigSpec("search_source", "choice", "quota", "search_source"),
# --- Backfill (recent-first first pass per channel) ---
ConfigSpec(
"backfill_recent_max_videos",
@@ -96,16 +113,6 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec("explore_grace_days", "int", "explore", "explore_grace_days", min=0, max=3_650),
# Days an un-kept live-search result video is kept before the cleanup job removes it.
ConfigSpec("search_grace_days", "int", "explore", "search_grace_days", min=0, max=3_650),
- # --- YouTube Data API key (secret; optional — public reads prefer it over OAuth) ---
- ConfigSpec("youtube_api_key", "str", "youtube", "youtube_api_key", secret=True),
- # Optional egress proxy for YouTube API calls (fixed-IP host for an IP-restricted key).
- ConfigSpec("youtube_api_proxy", "str", "youtube", "youtube_api_proxy", allow_empty=True),
- # --- Google OAuth client (Sign in with Google; both encrypted, env fallback). Set from the
- # install wizard / Configuration page so no restart is needed when the creds change. ---
- ConfigSpec("google_client_id", "str", "google", "google_client_id", secret=True),
- ConfigSpec("google_client_secret", "str", "google", "google_client_secret", secret=True),
- # --- Access / registration ---
- ConfigSpec("allow_registration", "bool", "access", "allow_registration"),
# --- Download center (yt-dlp) — per-user defaults + retention/GC + layout ---
ConfigSpec("download_total_max_bytes", "int", "downloads", "download_total_max_bytes", min=0),
ConfigSpec(
@@ -137,7 +144,7 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec(
"download_gc_grace_days", "int", "downloads", "download_gc_grace_days", min=0, max=3_650
),
- ConfigSpec("download_layout", "str", "downloads", "download_layout"),
+ ConfigSpec("download_layout", "choice", "downloads", "download_layout"),
ConfigSpec(
"download_worker_concurrency",
"int",
@@ -151,10 +158,9 @@ SPECS: tuple[ConfigSpec, ...] = (
# Days to keep admin audit-log rows before the audit_gc job prunes them (0 = keep forever).
ConfigSpec("audit_retention_days", "int", "audit", "audit_retention_days", min=0, max=3650),
# --- Diagnostics (E22) ---
- # Runtime log level for the API process: TRACE | DEBUG | INFO | WARNING | ERROR. Free-text like
- # search_source (an unknown value falls back to INFO in logs.resolve_level, so a typo can't
- # silence the app). Applied without a restart — see routes/config.py and main.py's lifespan.
- ConfigSpec("log_level", "str", "diagnostics", "log_level"),
+ # Runtime log level for the API process. Applied without a restart — see routes/config.py and
+ # main.py's lifespan.
+ ConfigSpec("log_level", "choice", "diagnostics", "log_level"),
# --- Plex integration (optional; local-file playback + Plex metadata) ---
ConfigSpec("plex_enabled", "bool", "plex", "plex_enabled"),
ConfigSpec("plex_server_url", "str", "plex", "plex_server_url"),
@@ -164,10 +170,10 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"),
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
- # Transcode re-encode tuning (E6 S1). hw = "auto"|"on"|"off" (free-text, like search_source); the
- # libx264 knobs apply only to the software fallback. crf 0–51 (x264 range); max_height 0 = source.
- ConfigSpec("plex_transcode_hw", "str", "plex", "plex_transcode_hw"),
- ConfigSpec("plex_transcode_preset", "str", "plex", "plex_transcode_preset"),
+ # Transcode re-encode tuning (E6 S1). The libx264 knobs apply only to the software fallback.
+ # crf 0–51 (x264 range); max_height 0 = source.
+ ConfigSpec("plex_transcode_hw", "choice", "plex", "plex_transcode_hw"),
+ ConfigSpec("plex_transcode_preset", "choice", "plex", "plex_transcode_preset"),
ConfigSpec("plex_transcode_crf", "int", "plex", "plex_transcode_crf", min=0, max=51),
ConfigSpec(
"plex_transcode_max_height", "int", "plex", "plex_transcode_max_height", min=0, max=4320
@@ -199,11 +205,24 @@ def _default(s: ConfigSpec):
return getattr(settings, s.default_attr)
+def _choice(s: ConfigSpec, raw: object) -> str:
+ """The canonical spelling of `raw` among the key's choices (any letter case), or ValueError."""
+ folded = str(raw).strip().casefold()
+ for option in s.choices:
+ if option.casefold() == folded:
+ return option
+ raise ValueError(f"{s.key} must be one of: {', '.join(s.choices)}")
+
+
def _coerce(s: ConfigSpec, raw: str):
if s.type == "int":
return int(raw)
if s.type == "bool":
return raw == "true"
+ if s.type == "choice":
+ # A stored value that is no longer a choice (the list shrank) reads as the default, like a
+ # non-numeric int override does — see get().
+ return _choice(s, raw)
return raw
@@ -279,6 +298,8 @@ def set_value(db: Session, key: str, value) -> None:
raw = str(v)
elif s.type == "bool":
raw = "true" if value else "false"
+ elif s.type == "choice":
+ raw = _choice(s, value)
else:
raw = "" if value is None else str(value)
@@ -321,6 +342,7 @@ def describe(db: Session) -> dict:
"max": s.max,
"is_set": is_set, # an override row exists
"allow_empty": s.allow_empty, # if true, the UI stores "" rather than deleting the row
+ "choices": list(s.choices), # the picker's options; [] for any other type
}
if s.secret:
item["default_is_set"] = bool(_default(s))
diff --git a/backend/tests/test_logs.py b/backend/tests/test_logs.py
index 2c19e185..62c79e54 100644
--- a/backend/tests/test_logs.py
+++ b/backend/tests/test_logs.py
@@ -17,6 +17,7 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient
from app import logs
+from app.config import LogLevel, choices_of
class _Sink(logging.Handler):
@@ -161,11 +162,17 @@ def test_bound_correlation_appears_and_is_released(sink):
assert "job_id" not in sink.objects()[-1]
-def test_an_unknown_level_name_falls_back_to_info_instead_of_silencing():
- assert logs.resolve_level("nonsense") == logging.INFO
- assert logs.resolve_level(None) == logging.INFO
- assert logs.resolve_level("trace") == logs.TRACE
- assert logs.resolve_level(" debug ") == logging.DEBUG
+def test_every_selectable_level_resolves_to_a_real_logging_level():
+ # The picker's options come from `LogLevel`; each must be a level `logging` knows, TRACE included.
+ assert [logs.resolve_level(n) for n in choices_of(LogLevel)] == [
+ logs.TRACE,
+ logging.DEBUG,
+ logging.INFO,
+ logging.WARNING,
+ logging.ERROR,
+ ]
+ with pytest.raises(ValueError):
+ logs.resolve_level("nonsense")
def test_configure_logging_silences_the_uvicorn_access_log_and_keeps_ours_propagating():
diff --git a/backend/tests/test_sysconfig_registry.py b/backend/tests/test_sysconfig_registry.py
new file mode 100644
index 00000000..7a25e5d6
--- /dev/null
+++ b/backend/tests/test_sysconfig_registry.py
@@ -0,0 +1,93 @@
+"""The sysconfig registry: its order is the admin page's tab order, and a closed-set knob is a CHOICE.
+
+The admin Configuration page used to keep its own hand-written group order, which had already drifted
+(`explore` was missing, so its tab sorted last). The page now follows SPECS, so SPECS has to read in
+tab order: one run of keys per group.
+
+A closed-set admin knob is a CHOICE, not free text (E22 S2 follow-up).
+
+`log_level`, `search_source`, `download_layout` and the transcode `hw`/`preset` used to be free-text
+fields: the admin could type anything and every consumer carried its own `or "auto").lower()` guess
+at what a typo meant. Now the value set is a `Literal` in config.py, pydantic holds the env to it at
+boot, sysconfig holds a stored override to it, and the admin page renders a picker from the same list.
+The DB lane (`db` fixture) covers the store; the rest is pure logic.
+"""
+
+import pytest
+from pydantic import ValidationError
+
+from app import sysconfig
+from app.config import Settings, choices_of
+
+CHOICE_KEYS = {s.key for s in sysconfig.SPECS if s.type == "choice"}
+
+
+def test_each_group_is_one_run_of_keys_so_the_registry_reads_in_tab_order():
+ runs = [
+ s.group
+ for i, s in enumerate(sysconfig.SPECS)
+ if i == 0 or sysconfig.SPECS[i - 1].group != s.group
+ ]
+ assert len(runs) == len(set(runs)), runs
+ assert runs[:2] == ["access", "email"]
+
+
+def test_every_closed_set_knob_is_a_choice_whose_options_come_from_config():
+ assert CHOICE_KEYS == {
+ "search_source",
+ "download_layout",
+ "log_level",
+ "plex_transcode_hw",
+ "plex_transcode_preset",
+ }
+ for s in sysconfig.SPECS:
+ field = Settings.model_fields[s.default_attr]
+ if s.type == "choice":
+ assert s.choices, s.key
+ assert field.default in s.choices, s.key
+ else:
+ # A Literal-typed setting exposed as "str" would be a free-text field again.
+ assert s.choices == () and choices_of(field.annotation) == (), s.key
+
+
+def test_the_env_accepts_any_letter_case_and_rejects_an_unknown_value():
+ assert Settings(log_level="debug").log_level == "DEBUG"
+ assert Settings(search_source=" API ").search_source == "api"
+ with pytest.raises(ValidationError):
+ Settings(log_level="verbose")
+
+
+class _Row:
+ def __init__(self, value):
+ self.value, self.encrypted = value, False
+
+
+class _FakeDb:
+ """Just enough of a Session for sysconfig's reads: one stored override row per key."""
+
+ def __init__(self, **rows):
+ self.rows = {k: _Row(v) for k, v in rows.items()}
+
+ def get(self, _model, key):
+ return self.rows.get(key)
+
+
+def test_a_stored_value_reads_back_canonical_and_a_stale_one_falls_back_to_the_default():
+ assert sysconfig.get_str(_FakeDb(plex_transcode_hw="OFF"), "plex_transcode_hw") == "off"
+ # A value that is no longer (or never was) a choice is not passed through to the consumer.
+ assert sysconfig.get_str(_FakeDb(plex_transcode_hw="maybe"), "plex_transcode_hw") == "auto"
+
+
+def test_describe_hands_the_picker_its_options():
+ items = {i["key"]: i for g in sysconfig.describe(_FakeDb())["groups"].values() for i in g}
+ assert items["log_level"]["choices"] == ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"]
+ assert items["log_level"]["value"] == "INFO"
+ assert items["quota_daily_budget"]["choices"] == []
+
+
+def test_set_value_stores_the_canonical_spelling_and_refuses_anything_else(db):
+ sysconfig.set_value(db, "search_source", "API")
+ assert sysconfig.get_str(db, "search_source") == "api"
+ with pytest.raises(ValueError, match="scrape, api"):
+ sysconfig.set_value(db, "search_source", "bing")
+ db.rollback()
diff --git a/frontend/src/components/ConfigPanel.choice.dom.test.tsx b/frontend/src/components/ConfigPanel.choice.dom.test.tsx
new file mode 100644
index 00000000..8ad885fe
--- /dev/null
+++ b/frontend/src/components/ConfigPanel.choice.dom.test.tsx
@@ -0,0 +1,118 @@
+// @vitest-environment jsdom
+//
+// A closed-set config key is picked, not typed: a short list renders as a segmented bar, a long one as
+// a dropdown, and the pick is what Save sends. A cleared draft (reset to the default) shows the default
+// as the active option rather than no option at all.
+//
+// i18n is a key-passthrough; the admin config API is stubbed.
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ConfigItem } from "../lib/api";
+
+vi.mock("react-i18next", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
+ };
+});
+
+const choice = (key: string, choices: string[], value: string, is_set = false): ConfigItem => ({
+ key,
+ type: "choice",
+ group: "g",
+ secret: false,
+ min: null,
+ max: null,
+ is_set,
+ allow_empty: false,
+ choices,
+ value,
+ default: choices[0],
+});
+
+const PRESETS = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow"];
+const setConfig = vi.fn((_key: string, _value: unknown) => Promise.resolve({}));
+const resetConfig = vi.fn((_key: string) => Promise.resolve({}));
+
+vi.mock("../lib/api", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ api: {
+ ...actual.api,
+ adminConfig: () =>
+ Promise.resolve({
+ groups: {
+ g: [
+ choice("plex_transcode_hw", ["auto", "on", "off"], "on", true),
+ choice("plex_transcode_preset", PRESETS, "veryfast"),
+ ],
+ },
+ secrets_manageable: true,
+ }),
+ setConfig: (key: string, value: unknown) => setConfig(key, value),
+ resetConfig: (key: string) => resetConfig(key),
+ },
+ };
+});
+vi.mock("../lib/useMe", () => ({ useMe: () => ({ email_configured: true }) }));
+
+import ConfigPanel from "./ConfigPanel";
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+const mount = () =>
+ render(
+
+
+ ,
+ );
+
+describe("ConfigPanel choice keys", () => {
+ it("renders a short list as a segmented bar and saves the pick", async () => {
+ mount();
+ const bar = await screen.findByRole("group", { name: "config.fields.plex_transcode_hw.label" });
+ const pressed = () =>
+ [...bar.querySelectorAll('[aria-pressed="true"]')].map((b) => b.textContent);
+ expect(pressed()).toEqual(["config.choices.plex_transcode_hw.on"]);
+ expect(screen.queryByRole("textbox")).toBeNull(); // no free-text field for a choice key
+
+ fireEvent.click(screen.getByRole("button", { name: "config.choices.plex_transcode_hw.off" }));
+ fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
+ await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_hw", "off"));
+ });
+
+ it("shows the default as active once the override is reset, and Save resets it", async () => {
+ mount();
+ await screen.findByRole("group", { name: "config.fields.plex_transcode_hw.label" });
+ fireEvent.click(screen.getByRole("button", { name: /config\.reset$/ }));
+ expect(
+ screen
+ .getByRole("button", { name: "config.choices.plex_transcode_hw.auto" })
+ .getAttribute("aria-pressed"),
+ ).toBe("true");
+ fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
+ await waitFor(() => expect(resetConfig).toHaveBeenCalledWith("plex_transcode_hw"));
+ });
+
+ it("renders a long list as a dropdown showing only the value", async () => {
+ mount();
+ const trigger = await screen.findByRole("button", {
+ name: "config.fields.plex_transcode_preset.label: config.choices.plex_transcode_preset.veryfast",
+ });
+ expect(trigger.textContent).toBe("config.choices.plex_transcode_preset.veryfast");
+ fireEvent.click(trigger);
+ // The popover rows are queried as FilterSelect.dom.test does (`hidden: true`, picked by text).
+ const rows = screen.getAllByRole("menuitemradio", { hidden: true });
+ fireEvent.click(
+ rows.find((r) => r.textContent === "config.choices.plex_transcode_preset.slow")!,
+ );
+ fireEvent.click(await screen.findByRole("button", { name: "config.save" }));
+ await waitFor(() => expect(setConfig).toHaveBeenCalledWith("plex_transcode_preset", "slow"));
+ });
+});
diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx
index 92ff29a0..26b4cea7 100644
--- a/frontend/src/components/ConfigPanel.tsx
+++ b/frontend/src/components/ConfigPanel.tsx
@@ -12,30 +12,17 @@ import Tabs, { usePersistedTab } from "./ui/Tabs";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { PageToolbar } from "./ui/PageShell";
import { Switch } from "./ui/form";
+import SegmentedToggle from "./ui/SegmentedToggle";
+import FilterSelect from "./ui/FilterSelect";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS } from "../lib/storage";
import { notifyActionError } from "../lib/actionErrors";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and
-// applied together via one Save/Discard bar; nothing hits
-// the server until Save. Group order is fixed where known so logically related settings (e.g.
-// Email/SMTP) stay together.
-const GROUP_ORDER = [
- "access",
- "email",
- "youtube",
- "google",
- "quota",
- "backfill",
- "shorts",
- "batch",
- "maintenance",
- "downloads",
- "audit",
- "plex",
- "tmdb",
-];
+// applied together via one Save/Discard bar; nothing hits the server until Save. The tabs follow the
+// registry's own order (`sysconfig.SPECS`), so a new group lands where the backend puts it instead of
+// needing a second, hand-kept order list here.
export default function ConfigPanel() {
const { t } = useTranslation();
@@ -171,9 +158,7 @@ export default function ConfigPanel() {
return ;
}
- const groupKeys = Object.keys(data.groups).sort(
- (a, b) => (GROUP_ORDER.indexOf(a) + 1 || 99) - (GROUP_ORDER.indexOf(b) + 1 || 99),
- );
+ const groupKeys = Object.keys(data.groups);
// Clamp the persisted tab to a real group (the registry can change between sessions). The
// tab pill carries a dot when that group has unsaved edits, so a draft in a hidden tab is
// never silently lost behind the global Save bar.
@@ -330,6 +315,7 @@ function Field({
const { t } = useTranslation();
const isNum = item.type === "int";
const isBool = item.type === "bool";
+ const label = t(`config.fields.${item.key}.label`, item.key);
const secretDisabled = item.secret && !secretsManageable;
// Status line: never reveal a secret's value.
@@ -356,7 +342,7 @@ function Field({
return (
);
}
+
+// A closed-set key picks from its options instead of taking free text: a segmented bar for a short
+// list, a dropdown past six (the house rule for chip vs. select). A cleared draft ("" = reset to the
+// default on Save) shows the default as the active option, as a text field shows it as placeholder.
+const MAX_SEGMENTS = 6;
+
+function ChoiceField({
+ item,
+ label,
+ value,
+ onChange,
+}: {
+ item: ConfigItem;
+ label: string;
+ value: string;
+ onChange: (v: string) => void;
+}) {
+ const { t } = useTranslation();
+ const options = item.choices.map((c) => ({
+ value: c,
+ label: t(`config.choices.${item.key}.${c}`, c),
+ }));
+ const current = value || String(item.default ?? "");
+ if (options.length <= MAX_SEGMENTS) {
+ return (
+
+ );
+ }
+ return (
+ next && onChange(next)}
+ />
+ );
+}
diff --git a/frontend/src/components/ui/FilterSelect.tsx b/frontend/src/components/ui/FilterSelect.tsx
index 329e2491..bb12deac 100644
--- a/frontend/src/components/ui/FilterSelect.tsx
+++ b/frontend/src/components/ui/FilterSelect.tsx
@@ -21,6 +21,9 @@ export interface FilterSelectOption {
export interface FilterSelectProps {
/** Visible group name shown as the trigger prefix (`Label: value`) and the accessible name. */
label: string;
+ /** Show only the value on the trigger — for a form row whose own caption already names the field.
+ * The label still names the control and its menu for assistive tech. */
+ hideLabel?: boolean;
options: readonly FilterSelectOption[];
selected: string[];
onChange: (next: string[]) => void;
@@ -39,6 +42,7 @@ export default function FilterSelect({
onChange,
multiple = false,
requireOne = true,
+ hideLabel = false,
testId,
}: FilterSelectProps) {
const [open, setOpen] = useState(false);
@@ -47,6 +51,14 @@ export default function FilterSelect({
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-start",
+ // Single choice: opening lands on the option in use, not the first row. A multi-select toggle
+ // list has no one "current" row, so it keeps the default.
+ selectedIndex: multiple
+ ? null
+ : Math.max(
+ 0,
+ options.findIndex((o) => o.value === selected[0]),
+ ),
});
const labelOf = (v: string) => options.find((o) => o.value === v)?.label ?? v;
@@ -88,7 +100,7 @@ export default function FilterSelect({
open ? "border-accent" : "border-border"
}`}
>
- {label}:
+ {!hideLabel && {label}:}
{summary}
diff --git a/frontend/src/i18n/backendIds.contract.test.ts b/frontend/src/i18n/backendIds.contract.test.ts
index 3b2eb699..867c7658 100644
--- a/frontend/src/i18n/backendIds.contract.test.ts
+++ b/frontend/src/i18n/backendIds.contract.test.ts
@@ -7,7 +7,8 @@ import { describe, expect, it } from "vitest";
// `plex_overlay_map`… as bare identifiers, for months, and the i18n parity test stayed green because EN
// and HU were missing the SAME keys (owner report, E11 S5). The ids live in Python, so the check reads
// the registries straight from the backend sources:
-// - `sysconfig.SPECS` → `config.groups.`, `config.fields..label` + `.hint`
+// - `sysconfig.SPECS` → `config.groups.`, `config.fields..label` + `.hint`, and for a
+// "choice" key `config.choices..