Compare commits

...
429 Commits
Author SHA1 Message Date
peter 0dfb4a984b release: 0.99.2 (bug/b1-e34-defects) 2026-10-04 18:28:53 +02:00
peter 5bcd1d8d8a chore(release): 0.99.2 2026-10-04 18:19:37 +02:00
peter f49789665e fix(i18n): label the history cleanup's quota_reaped result
The scheduler row read 'quota reaped 0' — the raw key, humanised — because the new result key had no
label (found in the browser check; nothing tests result-key labels).
2026-10-04 18:03:19 +02:00
peter 0e077b84c8 fix(search): the live-search tooltip says what a search costs
It always said a live search spends the shared API quota, while the default source (the website's
own endpoint, scrape) spends none — the owner avoided a free search because of it. /api/me now names
the running search_source, and the tooltip names the cost under it: none for scrape (the enrich step
costs a unit or two), 100 units a page for the official API.
2026-10-04 17:53:19 +02:00
peter ce8ccfc3c0 fix(quota): prune quota history past a retention window
quota_events had no retention: one row per API spend plus a zero-unit row per live search, kept
forever. The daily history cleanup (audit_gc) now also prunes it past quota_retention_days (a new
Quota setting, default 365 so the admin usage view's longest window stays whole; 0 = forever). The
floor of two days keeps today's rows, which the per-user search cap counts.
2026-10-04 17:53:19 +02:00
peter 33dec56e45 fix(worker): obey the stored concurrency and log level, live
The admin page's download_worker_concurrency and log_level were stored but never reached the worker:
it read the env once at boot (worker.py) and never looked again. The worker now re-reads both every
10 s (_sync_knobs): extra loop threads start at once, surplus ones retire after their current job, and
a changed level is applied to the process. An unreadable database keeps what already runs.
2026-10-04 17:53:19 +02:00
peter 2a699e26ba release: 0.99.1 (improvement/drop-ci) 2026-10-04 06:45:48 +02:00
peter 13c8c05661 chore(release): 0.99.1 2026-10-04 06:36:06 +02:00
peter cc414de952 build: the gate is scripts/check.sh, and CI goes
The Gitea Actions gate repeated, after the merge, what a release already runs
before it, and its runner shared the git host's container: one run there
starved Gitea of memory until it stopped answering. The lanes now live in
scripts/check.sh, run the same by the release dispatcher and by hand, and the
gate-contract test holds its lines (the ruff and pip-audit checks read it
instead of the workflow). The two audits only CI ran join the gate: pip-audit
over the lock (pinned in the test image, blocking at any severity) and npm
audit of the production dependencies. The engine contracts drop the CI
containers; the register records that pushes have no server-side gate.
2026-10-04 06:20:58 +02:00
peter 0528b35f2d release: 0.99.0 (feature/e32-gate-integrity)
gate / backend (push) Successful in 8m5s
gate / frontend (push) Successful in 8m32s
2026-10-04 05:19:05 +02:00
peter cb46774851 chore(release): 0.99.0 2026-10-04 05:09:33 +02:00
peter f1cc6ce5dc fix: the reconcile scan's return type names its settled sections; the composer stays at its size ceiling 2026-10-04 05:04:14 +02:00
peter 4efe799eda fix(ui): the issue composer opens on its title, not on the Edit switch
A form dialog opens on the body's first control, and the composer's first
control is the Edit / Preview switch above the form: the focus landed there,
at open and when a retried load brought the form, and typing went nowhere. A
form can now mark the field to open on (data-initial-focus); every other
dialog keeps its first control. The Modal's comment on the re-armed watch now
says where the focus goes when the focused control is removed but others stay.
2026-10-04 04:56:57 +02:00
peter 40b27f2982 fix(sync): a channel with no uploads backfills as done, not as a failure
YouTube answers the uploads list of a channel with no uploads as
playlistNotFound. The recent and deep backfills raised on it, so the channel
was never stamped and was picked again, charged a quota unit and logged an
ERROR on every run. Both now read it as an empty last page, as explore already
does; any other failure still raises.
2026-10-04 04:51:25 +02:00
peter 0314c4cce2 fix(plex): the watch reconcile settles each library on its own walk
A mirrored library that answers "0 items" is not believed (its rows stay), but
that verdict stopped the whole reconcile: one library emptied on Plex with its
mirrored items kept made every run incomplete, and un-watches in every other
library never synced again. Each section now settles on its own walk, and only
the settled sections' rows are cleared; the unsettled ones are named in the
warning.
2026-10-04 04:49:55 +02:00
peter 182ec31120 test(gates): hold the lint and lane configs to a committed snapshot
The register test detected narrowings by modelling config shapes, and each
review round found one more shape it did not model (a basePath global ignore,
a rule src never declares, a widened glob filed under a narrow row, a substring
check on a CI line, knip.json beside knip.jsonc). It now compares instead:
gates.snapshot.json holds ESLint's effective config for every code file and the
lines that run each lane, and any difference fails until it is regenerated.
The register is read off the snapshot's configs, which surfaced the ui/ and
lib/ test scopes the sampling never reached.

The complexity ratchet also names a wrapped function by its binding
(const onSave = useCallback(() => ...)), not as "useCallback callback".
2026-10-04 04:47:10 +02:00
peter 12a39f5291 fix(plex): the watch reconcile clears only what it walked, and doubts a mirrored 0
A library left out of the allowlist is never scanned, so its watch states were cleared on
every reconcile; clearing is now limited to the sections walked. A section we mirror items
from that says it holds 0 is not believed complete. The page size is no longer read as the
section total when totalSize is missing (measured: the server always sends totalSize on a
paged request, 0 for an empty one).
2026-10-04 04:16:52 +02:00
peter 79704b0001 fix(youtube): "this playlist no longer exists" only where the user picked that list
playlistNotFound was mapped to "gone" everywhere: exploring a channel without uploads (its
uploads list answers not found) and a list deleted mid bulk pull both said a playlist no
longer exists. The reason is now "gone" only on single-playlist routes; explore reads it
as an empty back-catalogue, and the bulk pull skips that list and syncs the rest.
2026-10-04 04:16:52 +02:00
peter 4191189b4c fix(ui): a late form takes a dialog's focus once, even if it arrived elsewhere
The watch stayed armed when the form arrived while the focus was off the close button, so a
later change in the body pulled the focus off a close button the user went back to. It now
disarms on arrival and re-arms only when the focus is taken away.
2026-10-04 04:16:52 +02:00
peter 7dbda60815 fix(ui): an undo in the same tick as a rebase steps back to a rebased snapshot
rebase updated the state only, so the mirror the callbacks read still held the removed
row and an undo right after it wrote that row back for a moment.
2026-10-04 04:16:52 +02:00
peter d1df76e8e5 test(gates): the complexity ratchet names a function by what it is bound to
ESLint leaves an arrow bound to a const or a property unnamed, so three ratchet keys were
(anonymous) and ranked by position again. A tiny rule reports each unnamed function's
binding (const, property, assignment, else <callee> callback) at the head complexity
reports, and the keys line up by location.
2026-10-04 04:16:52 +02:00
peter 3f6a9efd57 test(gates): the register test parses ruff's TOML and holds pytest.ini line by line
Round 2 of the E32 review found the key whitelists blind: a line regex saw neither a
[lint.per-file-ignores] table nor a quoted key, and pytest.ini's continuation lines and
key: value form slipped past. Ruff's config is now parsed (smol-toml) and held to its
key paths; pytest.ini to its whole lines; the CI ruff steps to --config backend/ruff.toml,
which is what makes that file the whole configuration.

ESLint: every glob of a block must reach a sample (reactPlugin.ts and devOnlyGuard.ts
rode along unseen), a block's own severity is held to src, a no-restricted-* list is held
to the src block's declaration rather than Feed.tsx's effective config, named global
ignores count, an ignored sample is named, and linterOptions are checked. tsconfig.node
must keep allowJs + checkJs while it lists a .js file; knip's positive glob is pinned.
2026-10-04 04:16:52 +02:00
peter 64e8f1da3d fix(lint): E402 is on — the ignore meant for main.py had let 11 late imports in elsewhere
ruff.toml ignored E402 everywhere for main.py's logging setup. Measured: 28 hits, of which 11
had nothing to do with it — runner.py's logger line between its imports, models.py importing
canonical_email mid-file, a test constant above its imports, and main.py's own third-party and
mimetypes imports. Those are moved up. What stays after configure_logging in main.py is marked
per statement (noqa E402, logging is configured first), its comment now says why the order
matters (a line logged while importing, not the logger's creation). The register row goes, the
ruff key whitelist loses ignore, and a late import is red again. The T20 comment counts the
third deliberate print (scripts/e2e_seed.py).
2026-10-04 03:14:24 +02:00
peter a1224e9a8c test(gates): the repo-root scripts are linted and format-checked
scripts/check-node-modules.mjs and gen-changelog.mjs run in check and release but sat outside
every lane: the eslint and prettier lanes run from frontend/, and an ESLint config cannot reach
past its own directory. A contract test now holds them to the js-configs bar (recommended rules
under Node, the frontend's Prettier style); an unused variable and the old formatting are each
red. 0 lint hits; both files reformatted. Registered in docs/gates.md.
2026-10-04 03:00:49 +02:00
peter da4b19a39a docs(lint): the config comments say which rules can report, and count what is suppressed
eslint.config.js called every zero-hit React Compiler diagnostic a tripwire. Under
eslint-plugin-react-hooks 7.1.1 three cannot report anything: exhaustive-effect-dependencies (its
validation is compiled off), memoized-effect-dependencies (nothing emits its category) and
rule-suppression (lint mode passes no suppression rules). Probed: a missing effect dependency is
reported by exhaustive-deps only, and an inline disable is named by nothing. The header's
"33 suppressions" of exhaustive-deps is 0. reactPlugin.ts said one react-hooks rule was still
off; none is (three run at warn, which --max-warnings 0 fails).
2026-10-04 03:00:49 +02:00
peter 5a74cf86b7 test(api): the wire-key checks send every filter field, and tsc says when one is new
The Plex and feed filter fixtures were typed as the filter type itself, whose scalar filters are
optional: a new optional field compiled without a test value, so its wire key went unchecked.
Both are Required<…> now (a probe field fails typecheck on each). The feed set gained the fields
it omitted (year, librarySource, the scope and show switches) and goes out with and without year,
which wins over the other date filters; a misspelt year key is now red in the wire check, which
passed it before.
2026-10-04 02:57:09 +02:00
peter e432dc2780 test(gates): the complexity ratchet holds each function by name, and its scope is registered
The ceilings were a per-file ranked list, so one function could grow by what another in the same
file lost: Home.tsx's [34, 32] passed with Home at 32 and Hero at 34. Both twins now key every
offender as module::function (an unnamed one is (anonymous); a repeated name gets 2, 3 in source
order) and re-baseline at today's measured values. Swapping two ceilings is red on both sides
(Home 34 > 32; feedquery filtered_query 28 > 14).

The ratchet measures shipped code only. docs/gates.md said C901 covered backend + scripts; it
covers backend/app. The exclusion of tests, test kits, e2e and scripts/ is now a row with its
measured hits, and the stale counts are replaced (9 BE modules over 800, not 11).
2026-10-04 02:55:07 +02:00
peter 3741d1a46d fix(playlists): a list reopened from the cache keeps its rows
Leaving the Playlists page and coming back painted the selected list empty ("0 videos") while
the rail counted its rows. The page re-seeds its undoable order from the cached list in an
effect; StrictMode runs that effect twice on the remount, so a reset was followed in the same
tick by a rebase. Since E32 S3 a reset is state only and the rebase read the mirror ref, which
still held the empty initial history, and wrote it back over the reset.

rebase is now a state updater, so it builds on whatever an earlier call in the tick installed.
Both tests seen RED (hook: [] for [10, 20]; page: no rows), and the bug reproduced in the
browser on the old hook.
2026-10-04 02:48:10 +02:00
peter 98ec1abf25 test(gates): the register test reads every config block, option and key, not eight samples and severities
E32 review round 1 (T1–T5, s1). The test that holds docs/gates.md to the effective configuration
could itself be passed by a narrowing it did not look at:

- ESLint: besides each scope's sample, every config block is read on its own. A block that targets
  files no sample reaches fails as an "unsampled block"; a no-restricted-* list re-declared without
  an entry src has, an option that lets something through (allow…/ignore…/except…), a block-level
  `ignores` and EVERY global ignores object (not only the first) count. A scope switching a src rule
  at warn off now counts too (it skipped them). Two live narrowings surfaced and are registered:
  no-floating-promises' allowForKnownSafeCalls (src and e2e) and the three network owners'
  no-restricted-globals.
- TypeScript: a strict sub-flag set false beside `strict: true`, an extends/exclude/files, a changed
  include, and a frontend-root config file no project checks. eslint.config.js is that file: listed,
  with the 43 errors strict checkJs reports in it (measured, none a defect).
- Ruff, basedpyright, pytest and knip are read as key whitelists (an unknown key fails until someone
  decides whether it narrows); pyright's information/hint count as weak; ruff's extend-select is
  pinned; pytest's three keys are pinned exactly; knip's script must run both modes.

Each new check was probed with a planted narrowing (an unsampled block, a block ignores, a second
global ignores, a warn rule off in a scope, strictNullChecks false, a ruff extend-exclude, a pytest
-m) and failed on it.
2026-10-04 02:37:33 +02:00
peter e5b155ada9 refactor(ui): reuse the store, media-query and deleted-tag rules the review found rebuilt, and name a loading pick
E32 review round 1 (F3–F6).

- Channels' open-row store and the chat dock use lib/store's createStore instead of a hand-rolled
  listener set (the chat dock gets a test of its own).
- The Plex backdrop reads prefers-reduced-motion through useMediaQuery; the query string is one
  constant (REDUCED_MOTION) for the hook and the carousel's one-off read.
- A link-restored collection filter showed its raw rating_key in the picker while the titles
  loaded: usePlexCollectionTitles now says it is pending, the picker draws a placeholder
  (labelsPending, as the channel picker does) and so does the chip row.
- The "deleted tag" rule was written twice with two i18n keys and had drifted: the channel manager
  showed NO chip for a pick while the tag list loaded, or ever after a failed load, while the pick
  still narrowed the table. One rule now (lib/tagPicks), one key (common.deletedTag); the manager's
  row shows every pick, as a placeholder while loading and as its id after a failure.
2026-10-04 02:29:03 +02:00
peter e09d613b04 fix(ui): a reviewed log takes the focus back once, and a dialog's focus survives a Retry
E32 review round 1 (F1, F2).

- Issue composer: the review's focus-return token became state in S4 and was never spent, so every
  Preview → Edit round trip remounted the attachments and moved the focus from the toggle to the
  log's Review button. The token is cleared once it has been used, as the ref was.
- Modal (initialFocus "field"): the late-form watch moved the focus to the first control once and
  stopped. A failed load's Retry then gave way to the spinner, its focus fell to <body> inside the
  aria-modal dialog, and the form's first field never got it. The watch now re-arms when the focus
  falls to <body>: it waits on the ✕ and moves to the first field when the form arrives. Focus in a
  dialog stacked on top is left alone.
2026-10-04 02:29:03 +02:00
peter 38943fa93e fix(maintenance): one clock per run, so a video phase 1 recovers is not fetched again in phase 2
E32 review round 1 (B7). Each phase took its own now; phase 2's "not checked this run" test then
let a just-recovered video through and spent another videos.list call on it.
2026-10-04 02:14:04 +02:00
peter 887b4e546c fix(feed): a malformed saved view cannot break the autotag cleanup, and a foreign private tag reads as missing
E32 review round 1 (B5, B6).

- The autotag orphan cleanup reads every user's feed views. A view whose tag pick was not a list
  ("tags": 5) raised for everyone's job, True passed as tag id 1, and ~66k ids pushed the NOT IN
  past psycopg's 65535 bind parameters. The views are now read defensively and the kept ids
  filtered in Python; the saved-views route holds filters.tags to a list of at most 500 ids, on
  create and on update.
- tags_by_category resolved any tag id, so another user's private tag landed in its real category
  and the feed summary answered differently than for an id that does not exist, which told the
  caller which foreign ids exist. It is owner-scoped now (pre-existing; S2's missing-tag branch
  made the difference visible).
2026-10-04 02:14:04 +02:00
peter df4a79a1f1 fix(sync): an empty answer the remote's own count contradicts is not believed, one level down
E32 review round 1 (B1, B2, B3, B8).

- Plex watch reconcile: a section the server SAYS is empty (totalSize 0, nothing listed) is a
  complete listing. It read as "cut short" forever, so one empty library stopped un-watches from
  ever syncing. A listing with no total at all stays incomplete (the client now returns None for
  it instead of 0), and a whole scan that listed nothing still clears nothing. Tests now cover
  completeness across two sections, both orders.
- Plex collections: no members from a collection whose listing counts some (childCount) keeps
  the membership we hold, in the full sync and in the targeted resync after a write.
- YouTube playlists: no items for a playlist whose listing counts some (itemCount) keeps the
  mirror in the bulk pull, and is refused as retryable in "reset to YouTube" and in the push
  plan/merge, where it read as "YouTube deleted them all". The rule sits beside vanished().
- A playlist deleted on YouTube answered "reconnect your account"; it now says it is gone
  (errors.youtube.gone, HU+EN).
- The pure 3-way merge moved to sync/playlist_merge.py; playlists.py is back under 800 lines and
  off the size list.
2026-10-04 02:13:53 +02:00
peter 5d10a521a8 docs(gates): react-hooks/refs is on, the compiler skips one function, and the async-cleanup idiom that keeps it so 2026-10-04 00:05:37 +02:00
peter d7fb7075b8 refactor(ui): every function compiles under the React Compiler but VirtualGrid, and react-hooks/refs is on
E32 S4 — the burn-down from 29 skipped functions to 1:

- try/finally (26 sites): the cleanup runs after a try/catch; steps that return early moved into
  their own function (push, the setup wizard's next, the share page's unlock).
- A conditional, loop or optional call inside a try skips the function too, and react-hooks/todo
  does not see it: the value is computed before the try, or the try lives in a module-level helper
  (config writes, clip queueing, clipboard, frame counts, seekable end, YouTube player times).
- The two exhaustive-deps suppressions are gone. useYtProgress takes its per-item snapshot through
  one effect event, read once when the player is built; PlexPlayer's unmount relies on the
  leaving-the-item cleanup for the generation bump it duplicated.
- What the skips hid: PlexPlayer's 17 render-phase ref writes (useLatest), its tracks popover alias,
  the loadSession self-reference (declared early, set after commit); PlayerModal's session-position
  read during render (now where the player starts), navRef/modeRef writes, and a description helper
  that ran a ref-carrying callback during render (now PlayerDescription); SavedViewsProvider's
  latest-cfg ref, and its setState in the seed effect (preferSticky now prefers the starred default
  among same-filter views, so the seed needs no sticky write — test seen red on the old helper).
- A store's hook was called `use()`, which the compiler does not know as a hook: inside the newly
  compiled usePlaylistActions it was memoized, ran on the first render alone, and the playlist rail
  threw "rendered fewer hooks" on its second render. Renamed to `useValue()` everywhere; the old
  contract test claimed the call was safe inside any `use…` function, which was false — replaced by
  a render test that is red under the old name.
- PlexPlayer's palette ref was written and never read since the Backspace guard moved to the layer
  stack: removed.

react-hooks/refs is back at error with no exception. The ratchet lists only VirtualGrid
(IncompatibleLibrary) and pins both players as compiled. No size or complexity ceiling was raised.
2026-10-04 00:05:37 +02:00
peter a486c7521a docs(gates): the compiler burn-down after E32 S3, and the two idioms that keep it
react-hooks/refs 245 -> 31 hits and 93 -> 29 skipped functions in the register and the eslint
comment. The canonical-patterns table gets the latest-value row (useEffectEvent; a handler or timer:
useLatest / useStableCallback, never a render-phase ref write) and the popover / scroll-fade row
(destructure the ref setter).
2026-10-03 23:18:33 +02:00
peter 76358a0d77 refactor(ui): the popover engine and every render-phase ref write compile under the React Compiler
E32 S3. The compiler skipped 93 functions; it now skips 29 (try/finally shapes, two suppressions,
VirtualGrid). react-hooks/refs hits 245 -> 31.

- Popover engine: `ref={pop.refs.setReference}` made the compiler treat the whole popover handle as
  a ref, so every component holding one was skipped (21 call sites, the engine and its surface
  among them). The setter is destructured now; the same for useScrollFade and useReturnFocus.
- Render-phase `ref.current = x` writes moved to `useEffectEvent` where only effects read them,
  else to the new `useLatest` (written in a layout effect) / `useStableCallback`.
- FocusSafeButton (under every Button) read the focused element during render; it reads it through
  useSyncExternalStore, which React asks on every render instead of a cached memo.

Bugs this surfaced, fixed:
- Combobox: a chip could keep showing its raw id. The label cache was a ref an effect filled after
  the render that needed it; it is state merged during render now (test seen RED).
- useUndoable rendered from a ref plus a forced re-render; compiled, that memoizes a stale value.
  The history is state now, mirrored in a ref for the stable callbacks.
- ConfigPanel and the saved-views editor re-seeded their drafts in an effect, painting the stale
  draft for a frame; both adjust during render against the remembered key.
- Toaster's handover is written after commit instead of during render; a new test pins that the
  inherited set is taken once, at the takeover (seen RED on a per-render re-read).
- IssueComposer mutated a ref prop as a one-shot focus token; it is plain state.
- PlexBrowse wrote its info-scroll memory from an effect declared before the ref, past its own
  "one writer" rule; the writer comes first now and the effect goes through it.

The compiler ratchet drops to the measured 29 and now also requires FocusSafeButton and the popover
engine to compile.
2026-10-03 23:18:26 +02:00
peter dc00b55feb test(auth): warm the Welcome import once so the first case cannot time out under load 2026-10-03 21:53:06 +02:00
peter fb6b56f121 test(api): every filter key the client sends is one the backend reads
FastAPI ignores an unknown query parameter, so a misspelt or renamed key silently stops a
filter while its chip still shows. Contract tests read routes/feed.py _feed_params and
plex/query.py LibraryFilters. The superseded feedCount/facets calls and keys are removed.
2026-10-03 21:53:06 +02:00
peter 2e2d6f5746 fix(downloads): the quota editor opens at once and says it is loading or failed
It rendered nothing until its quota arrived and nothing at all when the load failed, so the
click looked dead. The first field still takes focus when the form arrives.
2026-10-03 21:53:06 +02:00
peter 2ab9ff6a42 fix(plex): a server without a machine id refuses the collection write by name
The missing identifier became server:/// in the write URI. Client tests over a MockTransport
(errors, paging, cutoff, write URIs, image transcode) and admin-route tests (connection test,
enabled-section sampling, no server URL in a 422, the one-time watch import).
2026-10-03 21:53:06 +02:00
peter 378c4daf0f fix(youtube): the scrape search never pins a stale key, and an unknown shape is an error
A failed config fetch or a keyless consent page cached the fallback InnerTube key for the life
of the process; once it went stale, search returned nothing with no error. The fallback is now
used for that call only. A 200 JSON without a result frame returned an empty page that read as
no results; it is a ScrapeError now. Parser, cursor and Shorts-probe tests added.
2026-10-03 21:53:05 +02:00
peter 8f1208fdfe fix(feed): a filter on a deleted tag matches nothing and its chip says so
A saved view or the stored filter outlives its tag. feedquery dropped the unknown id, so the
view showed the whole feed under its old name. The id now lands in a MISSING_TAG_CATEGORY that
filters to nothing in the SQL and in feed_summary's mirror alike; the feed chip reads "Deleted
tag" (a placeholder while the tag list loads, the id if it failed). Channels' persisted tag
filter gets the same chip, first in the row so the +N overflow cannot fold it away.
2026-10-03 21:53:05 +02:00
peter 52f12e5331 test(e2e): click only a channel link that is the element hit at its centre
A card inside the viewport can still sit under the chrome above the scroller (the update
banner); the covered click made Playwright scroll and retry, moving the feed the scroll-restore
test then measured. webkit failed 3 in 24 before, 0 in 48 after.
2026-10-03 21:10:27 +02:00
peter c4abbe48c1 refactor(sync): the empty-answer rule for a mirror is one function, vanished()
The YouTube playlist guard from the previous commit put sync/playlists.py over its size ceiling;
the rule now lives beside should_prune as vanished(existing, listed), which the mirror calls in
place of its own list. The pyright baseline also drops by one (187), from the plex/overlay split.
2026-10-03 19:45:14 +02:00
peter 4fc307c426 fix(autotag): the orphan cleanup keeps a system tag a saved view filters on
Feed views store tag ids. A system tag no channel carried for a while was deleted and, once a
channel earned it again, recreated under a new id — the view's filter then pointed at nothing, the
feed dropped the unknown id, and the view showed the whole feed under its old name. The cleanup now
skips tags a saved feed view references (test seen red without it).
2026-10-03 19:37:24 +02:00
peter 328d415cfe test(e2ee): the DM crypto round-trips, refuses the wrong key, and keeps its nonces apart
lib/e2ee was 14% covered and fails silently in every way it can break. Real WebCrypto and an
in-memory IndexedDB: a message decrypts for its partner and nobody else, every message gets its own
IV, a new device unlocks with the passphrase and refuses a wrong one, the key survives a reload
until reset, a partner's rotated key is used only after forgetConv, and the key check never shares
the wrap IV (GCM nonce reuse). Mutations seen red: the shared IV, a no-op forgetConv, no persist.
2026-10-03 19:35:36 +02:00
peter 43e4929b45 fix(sync): an empty answer no longer empties a mirror; maintenance checks each video once a run
The catalog prune and the subscription import have long refused to reconcile against an empty
listing that contradicts what we hold (should_prune: a 200 with no items is a blip far more often
than a user who removed everything). Two mirrors did not: the YouTube playlist mirrors (dropped
every mirror) and the Plex collections (dropped every collection with its user-set `editable`
flag, and every member's keys — the guard landed in plex/sync.py with the previous commit). Both
now hold the same rule; test_empty_listing_guards covers each way (seen red).

sync/maintenance (19% covered) gets a lifecycle test: recover, grace, delete + one notification
per user, live stream kept, abandoned premiere. It found the rolling revalidation re-fetching the
same videos until `checked` reached the batch (15000 by default) whenever fewer videos were
available — quota spent on answers it already had; a run now skips what it has checked.
2026-10-03 19:33:38 +02:00
peter 5806adda7e fix(plex): a listing cut short no longer clears watch state or Hungarian text
The daily watch reconcile reads "absent from Plex's scan" as "Plex un-watched it" and deletes the
row — but it walked the sections without asking _paginate whether the walk reached the server's
total, so a Plex hiccup mid-walk (an empty page early) un-watched every title it failed to reach
until the next complete run. The scan now reports completeness; an incomplete one still pulls in
what it saw, re-pushes dirty rows, and clears nothing (logged as plex.watch.reconcile_incomplete).

The HU overlay had the same shape: clear every row's HU text, then refill from the listing. It now
reads the listing first and clears only after a complete one. The overlay moved to plex/overlay.py
(plex/sync.py drops under the 800-line limit and off the size ratchet's list).

Tests: plex/paths (23% -> 100%: prefix boundary, longest prefix, dead mount), the reconcile's
clear/keep/re-push rules, the cut-short scan and listing (each seen red with the guard removed).
2026-10-03 19:33:30 +02:00
peter c73761ae39 refactor(plex): one cast row for a show's page and a title's info
CastStrip (a show's page) and PlexInfo (a title's page and the player's overlay) each drew the
cast carousel by hand, and the copies had drifted: the gap, lazy pictures, the name's tooltip, the
initial's size. Both now render CastRow, which takes the overlay's white-on-art palette as a tone;
each caller keeps its own frame and heading. A DOM test holds the palette per variant (seen red
with the tone wired to "panel"). jscpd over the shipped code: 1 clone >= 10 lines -> 0.
2026-10-03 19:12:17 +02:00
peter ca9ca85b18 test: no test throws a response away; the stream session is narrowed, not baselined
The 7 baselined Optional accesses in plex/stream.py were one fact the type did not carry: the
initial spawn (replace_only=None) always supersedes, so only the fallback respawn can get None.
An assert at the call site says so; the pyright baseline drops 195 -> 188.

test_abuse_limits: the public-URL and YouTube enqueue tests stopped at the profile lookup
(400 no_profile) and passed on that unrelated refusal. They now send an inline spec and assert
the job is queued. The same shape — a request whose response is discarded — sat at 53 more
places across the suite (all returned what they should, measured with a TestClient probe);
each now asserts its status, and test_response_checked keeps it that way (seen red).
2026-10-03 19:06:47 +02:00
peter 614d728561 test(e2e): the nav spec opens the admin pages from their rail panel
E31 put the admin pages behind one rail row ("Administration", nav-admin)
that opens a panel. The spec still clicked nav-scheduler, nav-config,
nav-users and nav-audit as rows of their own, and timed out on every engine.
That went unseen from E31 to now because e2e ran in no required gate, the
exact hole E32 closes: the first run of the new release e2e step found it.

The rail test keeps the rail rows. A new test opens each admin page from the
panel and checks that the group row is current and that the reopened panel
marks the page. Seen red before the fix; the whole suite then passes, 52/52.
2026-10-03 17:41:26 +02:00
peter 783f6408f9 docs: the gate register, held to the effective config by a test
docs/gates.md lists every place a gate deliberately checks less than it
could, each with its reason and the count measured when the row was
written:
- an ESLint rule off, at warn, or not applied, by scope;
- a weak compiler flag, a ruff ignore, the basedpyright mode and baseline;
- a knip exclusion, a lane that does not run everywhere;
- an accepted CVE.
gates.contract.test.ts reads the EFFECTIVE configuration and fails when a
narrowing has no row:
- ESLint's computed config per scope;
- the three tsconfigs, ruff.toml, pyrightconfig, pytest.ini and knip.jsonc;
- .trivyignore.yaml, where an entry also needs a statement and an expiry;
- CI's pip-audit ignores.
It also pins `--max-warnings 0`, which is what makes a warn-level rule fail.
Seen red on a missing row, a new `off` rule and an allowlist entry without
an expiry.

CLAUDE.md gains the rule E32 exists for: a gate with any real hit is never
switched off or narrowed, and an "enforced by" comment is checked by running
the check. The gate descriptions there, in docs/development.md and in
backend/app/README.md name the lanes as they now are, and the "directly"
recipe points at the full list.
2026-10-03 17:26:42 +02:00
peter 751e1ae5ed test: a size and complexity ratchet; coverage reported on both sides
The "class size" item from the quality checklist, measured:
- A shipped file over 800 lines may not grow past its line count today
  (13 frontend, 11 backend). No other file may cross 800.
- A function over the complexity limit may not rise, and no new one may
  appear. The limit is eslint `complexity` 20 on the frontend (64 functions
  over it today) and ruff C901 10 on the backend (32).
- Each is a per-file ceiling. Shrinking is free; an entry back under the
  limit must be deleted, so the lists only shrink.

C901 is measured in a test rather than switched on in ruff.toml: ruff has no
per-function baseline, and a noqa would let a function grow without limit.

Coverage (owner decision D4) is printed with no threshold:
`vitest --coverage` (v8) gives 80% of lines, and the pytest lane ends with
the total for app/, 78%. The bar stays "every regression test seen red".
.coverage is ignored by git and kept out of the image's build context.
2026-10-03 17:18:53 +02:00
peter da554df7c7 build(image): the image passes a Trivy scan: OS security updates, no runtime pip
The first Trivy scan of the shipped image (0.98.0) found four fixable HIGH
advisories. One was pcre2: a Debian fix newer than the base snapshot, now
taken by `apt-get upgrade` at build time. The others were urllib3 (bumped in
the lock), and msgpack, setuptools and urllib3 vendored inside pip itself.
pip is removed once it has installed the lock, since nothing runs it at
runtime.

A local build of this Dockerfile with --pull scans clean: 0 HIGH/CRITICAL
with a fix, no secrets. .trivyignore.yaml is the publish gate's allowlist,
empty today. Each entry must carry a statement and an expiry date.
2026-10-03 17:06:28 +02:00
peter b1ff4058ac build: CI builds the bundle and audits what ships; the toolchain advisories are cleared
The server gate adds `vite build` (and with it devOnlyGuard, which until now
first ran inside the image build, after main was pushed) and
`npm audit --omit=dev --audit-level=high`. pip-audit is blocking: when it was
finally read it held three urllib3 advisories with a fix available.
urllib3 goes 2.7.0 -> 2.8.0 as a single in-place pin, passing pip check,
rather than a regeneration that would move every package.

The dev toolchain's 18 advisories are gone:
- vite 5 -> 6.4.3 (path traversal, fs.deny bypass, and esbuild <= 0.24.2
  through it);
- vitest 3 -> 4.1.11 (mocker path traversal);
- typescript-eslint 8.46 -> 8.71;
- `npm audit fix` for the rest.
tsc, eslint, knip, all 2269 tests and the build pass unchanged.

scripts/check-node-modules.mjs checks that frontend/node_modules holds
exactly what package-lock.json pins. The local gate's frontend lanes judge
the host tree, and nothing tied it to the lock the image installs.
2026-10-03 16:50:56 +02:00
peter 7ed2ac4e95 build(frontend): knip also checks what ships; strict indexing in every project
`npm run knip` now runs a second, --production pass, which asks whether the
code that ships uses an export, not just whether anything does. Of the 47
exports only tests reached:
- 34 are exported for their test and say so with @internal, naming the test.
- 11 were module-private names with a stray `export`. Two of them were not
  used at all and are deleted.
- The unused alreadyReported re-export is gone.
Test kits, the e2e suite and the two build-tooling modules are excluded from
the production project, each named in knip.jsonc.

noUncheckedIndexedAccess is now on for the node and e2e tsconfigs too (0
errors), matching the app project. tsconfig.node.json listed a
tailwind.config.js that has not existed since Tailwind 4. That entry is now
playwright.config.ts, which the typed lint finds through this project.
2026-10-03 16:44:28 +02:00
peter cff8fe9a6a test(frontend): a React Compiler ratchet over everything the build ships
compilerCoverage.contract.test.ts used to check two files. It now runs the
real compiler over all of src (minus tests) and holds the exact set it skips
today, file -> function -> category: 93 functions. A new skip or a category
change is red, and so is a listed skip that has gone, so the list only
shrinks. Channels and DataTable must also show up as compiled, so a harness
that compiled nothing cannot pass.

Measured against the audit's scan: no function this sprint touched started
to skip. PlexPersonView and useOverlayOrder now compile.

reactPlugin.ts claimed the compiler's rules were linted, which was false
while react-hooks/refs was off. That comment and the refs block in
eslint.config.js now say what holds the line and why refs is still off:
245 real hits, pinned by the ratchet, burned down from S3.
2026-10-03 16:38:46 +02:00
peter 471846d784 build(frontend): close the lint gaps the E32 audit measured
eslint now also enforces:
- the React Compiler diagnostics that `recommended` leaves out (todo,
  memo-dependencies and ten zero-hit tripwires);
- a description on every suppression, and no blanket disables;
- no focused or skipped tests (vitest and playwright);
- the typed pair no-floating-promises and no-deprecated.

Lint also covers e2e/ and the TypeScript build configs, which `eslint .`
had been skipping in silence.

Every hit is fixed or suppressed inline with a named reason:
- The 31 try statements the compiler cannot lower yet carry a suppression
  until S3 rewrites them. Five other compiler skips were plain fixes:
  a module counter bumped inside a hook, a ref read in a default
  parameter, and a local named like the <section> it renders.
- The real defects behind floating promises are fixed. ChatDock threw at
  nobody when the device key store could not be opened. VideoEditor's
  play() rejection went unhandled. loadYouTubeApi never rejected, so a
  blocked IFrame API left the player on an empty stage forever; it now
  fails on a script error or after 15 s, shows its own error with the
  way out, and lets the next open try again.
- MutableRefObject, FormEvent and lucide's deprecated brand icon are
  replaced. The YouTube glyph now lives in lib/brandIcons.
- The lint-probe harness lets its virtual file names into TypeScript's
  default project, so the contract tests still run the real config.
2026-10-03 16:34:20 +02:00
peter 3c16012524 build(backend): close the lint gaps the E32 audit measured
ruff gains RUF100, PLW1510 and flake8-bugbear, every hit fixed rather than
ignored. B008 stays on with FastAPI's Depends/Query declared immutable.
basedpyright moves to standard mode, pytest to --strict-markers, and a hand
run of pytest now requires the database unless SIFTLODE_TEST_REQUIRE_DB=0.

PLW1510 found a real bug: the three ffprobe probes in plex/stream.py never
looked at the exit status, so a probe that failed was read as the file's
truth (an HDR source sent down the HW-safe path, a multi-audio file collapsed
to one track, a subtitle offset taken from garbage) and the warning each
except branch promises never fired. They now run with check=True.
2026-10-03 16:04:06 +02:00
peter cdfe7a8986 perf(channels): the row menu opens and closes without re-rendering the table
The React Compiler silently skipped Channels and DataTable (a ref written
during render; the refs lint is off), and even once compiled it dropped the
memo of the rows and columns (they reach filterRows/optionsFromColumns past
the page's hooks) and the columns read the open row. Every open, close and
reposition of the right-click menu rebuilt the whole table: on "All" rows
~0.9 s to open and ~0.6 s to close (dev). Now: effect events instead of the
render-written refs, explicit memos for rows, columns and their inputs, a
module-scope keyOf, and the open row reaches its "…" through a small store.
Measured on 397 rows (dev): ~75 ms to open, ~35 ms to close.

A contract test runs the real compiler over both files and fails on any
skipped function. The dropped memos have no automated guard: they need
the full page rendered with its providers; the measurement above is the
evidence.
2026-10-03 05:10:52 +02:00
peter 3e96211db0 fix(plex): the backdrop crossfade toggle wins over reduced motion once set
prefers-reduced-motion hard-disabled the rotation whatever the toggle said,
and Windows reports it whenever its animation effects are off — so the
owner never saw the crossfade. Untouched, the setting now follows the
system (and the toggle shows that state); a pick made on the toggle is
stored and wins on every device.
2026-10-03 04:38:50 +02:00
peter e9c15baae0 fix(feed): a channel filter's chips name their channels from the first paint
After a reload the chips (filter panel and the row above the feed) read as
raw UC… ids until the subscription list landed. The names seen last time
are now kept per account (useChannelNames) and name the chips at once; the
list then replaces the copy, so a renamed or dropped channel heals. A
channel never named on this browser draws a placeholder bone while the
list loads; a failed list still falls back to the id.
2026-10-03 03:29:54 +02:00
peter 6edb22988c release: 0.98.0 (feature/e31-design-review-2)
gate / frontend (push) Successful in 8m29s
gate / backend (push) Successful in 9m54s
2026-10-03 02:58:46 +02:00
peter f9c3714025 release: 0.98.0 notes — design review 2 2026-10-03 02:58:07 +02:00
peter a4775e1cd0 fix(ui): the filter rail shows no tag counts until the facets land
It printed the tag's whole-library count as the current filter's while the
facets loaded, and for good when they failed: live, "Music 238" beside a
search that matched 3 of its channels. Ordering still falls back to it.
2026-10-03 02:43:52 +02:00
peter b40440b825 fix(ui): review round 2 — reveal floor, failed next page, count states, row Play
- DataTable: the reveal floor holds only in the view it was made for (Channels
  never clears its follow, so every later sort mounted rows up to it)
- Feed + Plex browse: a failed next page stops the read-ahead and shows a row
  that retries, instead of re-requesting it once a round-trip
- the YouTube Load more click uses nextPageOnClick; a contract pins NEXT_PAGE
  to the read-aheads and the click sites to nextPageOnClick
- ItemCount: a failed count says so; a stale one says updating to a screen reader
- the narrow-picture Play lift applies only where a hover row is there to clear
- PlayerModal: one description ladder for the popover and the panel, both tested
- plexImage contract reads every img src, any attribute order
- Tabs comment names who persists the sub-tab
2026-10-03 02:25:15 +02:00
peter c4c442bc5b fix(feed): a hand-made relevance cursor is refused, not a crash
The page halves compare the cursor's timestamp with the window's in Python;
a null or naive one raised there, past the route's 400. decode_cursor now
refuses a null non-nullable key and a naive timestamp.
2026-10-03 02:25:14 +02:00
peter c74ceb73e3 fix(feed): the count pill dims while a new search's count loads
keepPreviousData held the last filter's count, and a broad word's count
takes seconds: the pill showed the old number as the new search's. It now
dims and is aria-busy until the new count lands, as AdminLogs does.
2026-10-03 01:01:46 +02:00
peter e0e28c006c perf(feed): relevance ranks the newest 2000 matches, the rest follow newest first
A broad word matched most of the library and ts_rank read every match:
"a" took 4.4 s a page on prod. The window's start is read once (0.1 s) and
rides the cursor, so a video ingested mid-scroll cannot repeat a row; the
page is read as its two halves, each on an index (80 ms + 5 ms).
2026-10-03 00:28:29 +02:00
peter f8f22fc985 test(i18n): a language test unmounts its reader before switching the language back
useTags reset the language in a finally while its hook was still mounted;
the switch scheduled a render, and in a loaded gate run it landed after
the jsdom environment was torn down ('window is not defined', two
unhandled errors). auditColumns and comfortPin had the same shape.
2026-10-02 23:48:37 +02:00
peter 90e0baa870 style(db): ruff format the re-runnable index migrations 2026-10-02 23:43:21 +02:00
peter dfd43ae89c docs: the folding channel header in its e2e, and three comments brought up to date
The e2e asserted the channel chrome never moves; it folds once now. The
ui catalog lists scrollFold and progressiveRows, Tabs names the
per-tab memory its callers use, and the welcome note says a removed
language orphans its stored copies too.
2026-10-02 23:42:32 +02:00
peter 608fde2d55 fix(db): re-running 0095/0096 rebuilds an invalid leftover index
A concurrent build that failed part-way leaves an INVALID index under its
name; IF NOT EXISTS kept it on the re-run, and 0095 then dropped the old
channel_id index, leaving none usable. The leftover is now dropped and
built again, and the old index goes only once the new one is valid.
2026-10-02 23:42:32 +02:00
peter 0302c466e8 fix(i18n): a failed RSS poll over one channel reads as one, and keeps its stops
rss_all_failed is a plural pair counted on the raw total (the values are
formatted strings, which pick no plural form); both sentences end with a
stop. The Hungarian 'show N more' uses the house's megjelenítése.
2026-10-02 23:42:32 +02:00
peter a50055577a fix(plex): every small Plex picture asks for its card-size cut
The playlist rows, the search box and the collection picker still
fetched the 400px poster for a 32-80px picture. A contract test pins the
set of bare Plex pictures, and the srcset widths to imgcache.SIZES.
2026-10-02 23:42:32 +02:00
peter e16cdbd16f fix(ui): a card's Play clears its hover row at a large text size; Downloads links and the player description
At text size 1.3 a 242px feed thumbnail put the 63px Play 13px into the
hover row (the Play grows with the root font, the picture's height does
not). A picture narrower than 14rem centres the Play and the resume pair
above the row; the picture box is a size container for it.

The Downloads title and channel links were 21px tall inline boxes; they
stand on a 24px line now (B1). The player's description says a failed
load, with a retry, instead of 'No description' (both the side panel
and the popover).
2026-10-02 23:42:31 +02:00
peter 15ec67b029 fix(ui): a read-ahead waits out a background refetch, and a revealed row is mounted before it is focused
NEXT_PAGE joins ANY fetch in flight: a read-ahead during an invalidation
or a poll joined that refetch and appended no page, and nothing asked
again until the reader moved the range (the channel page during a
backfill). The read-ahead now waits while the list fetches anything
(mayReadAhead, re-run on isFetching); a Load older click cancels a poll
instead (nextPageOnClick).

A page turn to a revealed row restarted the progressive mount at 30
rows; Channels focuses that row in the next commit, and at index 40 of
a 50-row page it was not there, so focus fell to <body>. The mount now
reaches through the revealed row.
2026-10-02 23:42:31 +02:00
peter ac53a3b757 perf(search): a one- or two-letter last word is matched whole, not as a prefix
A 1-2 letter prefix matched most of the library and ts_rank scored every
match: "m" took ~4-8 s on prod against ~150 ms now. The prefix match
starts at three characters (MIN_PREFIX_LEN); shared by the YouTube feed
and the Plex library search.
2026-10-02 21:52:47 +02:00
peter e6de18b8c0 perf(plex): the facets ask the episode rollup once per call, not once per show group
A watch-state scope (watched / in progress / unwatched) joined the per-user episode rollup into
each show facet query: five passes over the user's episodes per call, half of a filtered facets
request. The shows in scope are now resolved once and every group filters on their ids; the
grid-parity test holds the counts. Localdev: 99 -> 61 ms.
2026-10-02 21:32:49 +02:00
peter fb2e3d8490 perf(plex): a card fetches its picture at a card's size, not the detail page's
Cards fetched the full cuts: a 400x600 poster (~38 KB) for a ~144px picture and the 1280x720
backdrop (~162 KB) for a ~212px shelf picture. The image proxy now also cuts thumb_sm (240x360)
and art_sm (480x270), cached apart; card pictures (grid, shelves, collection tiles, Up next) carry
both in srcset with sizes=auto, so the browser picks by laid-out width and DPR. Localdev:
17 KB a poster, 56 KB a backdrop. The detail page keeps the full cuts.
2026-10-02 21:27:13 +02:00
peter d928d50686 perf(db): each feed sort rides an index, and an ICU upgrade rebuilds the title ones
The views, duration and title sorts had no index: every page read the whole videos heap and kept a
top-N (prod 350 ms a page, 550-780 for title). Migration 0096 adds one per direction and, for title,
per reader collation, in the exact ORDER BY feedquery builds. Localdev prod copy: plain sorts
180-310 ms -> ~1 ms, page 20 3-5 ms, narrow filters unchanged. view_count/duration are only written
by the enrich, which already rewrites indexed columns, so no HOT update is lost.

The title indexes are the first on an ICU collation, and postgres:16-alpine floats: collation.heal_drift
rebuilds them (CONCURRENTLY) at startup when Postgres reports the ICU version moved.
test_feed_sort_indexes holds every videos sort to an index and checks the rebuild.
2026-10-02 21:17:41 +02:00
peter 540822d2b0 perf(ui): a next-page fetch joins the one in flight instead of sending the cursor again
react-query's fetchNextPage cancels a next page still loading and sends it again by default.
Our requests ignore the abort signal, so the server ran both: the feed's read-ahead effect
re-runs on scroll frames before isFetchingNextPage reaches it, and on prod every feed page
went out 2-3 times (a 0.8 s title sort queued to 2.3 s). Every call now passes NEXT_PAGE
({cancelRefetch: false}), lint-enforced, with a contract test.
2026-10-02 20:46:30 +02:00
peter b4ef07d28e perf(ui): a long table page paints its first rows at once and the rest in steps
The channel manager's All page (~400 rows) blocked one frame for 0.7-0.9 s on prod to mount,
and every sort and search key re-rendered every row (300-400 ms). No single cell is to blame,
measured column by column. DataTable now mounts the first 30 rows at once and 40 more per
background transition; a new page, size, sort, filter or caller-side search (narrowedBy)
starts over from the first rows. A row is its own component, so the compiler keeps the rows a
step already holds.
2026-10-02 03:16:37 +02:00
peter e995c664c6 perf(db): the channel manager and the feed sidebar read an index, not the video heap
GET /api/channels summed every subscribed channel's videos by reading each video's heap page
(681 MB on prod): 51-61 ms warm, but 2.25 s on a first open and 6.4 s under EXPLAIN once the
table had left memory. /feed/summary and /feed/years did the same (~140 ms warm, ~800 ms cold).
Migration 0095 replaces ix_videos_channel_pub_desc with the same key INCLUDING the columns
those queries read, so they are Index Only Scans over a 25 MB index. A test holds the queries
and the index's column list together.

The admin Configuration page did a db.get per setting (two for an unset one): it now reads
every override in one query (~40 ms of ~55 on prod).
2026-10-02 03:16:37 +02:00
peter 20010178b0 fix(ui): the Plex detail poster keeps its 2:3, and a long crew shows its first names
The hero's grid row is as tall as the crew column beside the text, and the poster, a flex child,
stretched to it: a 2:3 picture drawn ~175x590 and cropped. It now aligns to the top, and a wide hero
gives the movie and show poster a bigger rung (w-56). The crew, the hero's tallest part, shows its
first 6 names and a 'Show N more' toggle; a list of 8 or fewer shows whole.
2026-10-02 02:37:20 +02:00
peter 78fdcda6bb feat(ui): E31 S4c — the channel header folds, the Plex pages fill their hero, the player gets a side panel
- Channel page: the banner and identity block fold away once the grid scrolls
  (ui/scrollFold: folds past 32px only if the page can stay scrolled, unfolds
  at the very top) and the name rides in the tab row. One Back button in both
  states keeps its focus; folded at a tablet width the actions wrap instead of
  pushing the page sideways.
- Plex movie and show pages: the TMDB facts over the key crew are the hero's
  second column (plexDetailAside), the keywords sit under the text; the show
  page shows them for the first time, with the same three toggles. A
  collection's hero is sized to its text.
- YouTube player, 1280px and wider: the title, channel, actions, description
  and queue sit in a side panel beside the video, which takes the window's
  height (1438x809 at 1920x911, was ~1008 wide). The wheel scrolls that panel
  while it can and turns the volume elsewhere.
- DESIGN.md decision log: the keywords' place under the text.
2026-10-02 02:13:17 +02:00
peter 98d0ca6eeb fix(i18n): every counted string is a plural pair, so one reads singular
"1 seasons", "Synced 1 subscriptions", "1 video(s) were skipped": 21 English
strings carried {{count}} in one plain key and read plural at one. Each is a
_one/_other pair now (Hungarian keeps one form for both), and so is every other
counted string, so the next wording change is a plural too. A contract test
holds the rule for every locale file.
2026-10-02 02:13:17 +02:00
peter e4c80fb7bb fix(ui): a page's sub-tab is remembered per browser tab, not forever
Settings, Configuration, Statistics and Users kept the last sub-tab in localStorage, so every
fresh tab opened Configuration on "Plex" as if that were its home. useTabPersistedState keeps it
in sessionStorage (account-scoped): a reload and a trip to another page keep it, a new tab starts
on the first sub-tab. The pre-selects (a Review link, the Google-link return) write the same store;
a value an older build left in localStorage is cleared on first read.
2026-10-02 00:49:46 +02:00
peter be156fc52b feat(ui): E31 S4b — the admin pages behind one rail row, Configuration as a sectioned list, Statistics as tiles and bars
- NavRailGroup: one "Admin" rail row opening a panel of the admin pages (links, so a
  middle-click still opens a tab); the rail fits 133% zoom without a hidden scroll
- sysconfig.SECTIONS orders the config groups under four headings (every group in exactly
  one, test-held); the page shows them as Settings' side list (Tabs `group` headings);
  the lone registration group reads "Registration" under "Access"
- StatTiles / StatTile / StatBars: the Statistics overview's figures as tiles, the
  per-operation quota use as horizontal bars (a zero draws none)
2026-10-02 00:07:00 +02:00
peter 25905aa786 fix(audit): fold a job's successful runs whatever ran between them
The owner read a fold that broke at every other job as the same row five
times. A job's successful runs now fold into one row at the newest run's
place across other jobs and admin actions; only that job's own failed or
skipped run breaks the fold (it stays its own row, and older successes
start a new fold below it).
2026-10-01 23:05:38 +02:00
peter 87d71e3762 feat(ui): E31 S4a — rail rows are links, the channel page opens on unwatched, users and audit tidied
- Nav rail page rows are real links (/?page=<id>): a middle-click or
  Ctrl-click opens the page in a new tab; a plain click stays in-app.
- A fresh channel page shows the unwatched videos; a picked watch state
  is a remembered preference (channelShow).
- Users: the role, suspend and delete move into the row's one menu
  (the "…", right-click, Shift+F10); locked ones keep their reason.
- Audit log: back-to-back successful runs of one job fold into one row
  that opens to the earlier ones; a failed run reads in the danger colour.
- DESIGN.md decision log: the E31 picks.
2026-10-01 22:51:38 +02:00
peter ab146211fa fix(scheduler,i18n): a failure a job names reads in the reader's language
The audit log showed a failed RSS poll as the server's English sentence
under a Hungarian UI. A job can now raise progress.JobFailed(reason,
message, **params): the scheduler stores the reason and params as the
run's data (dashboard, manual-run notice, audit row) beside the English
line, and the client renders scheduler.failure.<reason>. The RSS outage
names its first channel and the reason code (HTTP 404 vs unreachable).
An upstream's own error text stays as raised (diagnostic).
2026-10-01 22:51:37 +02:00
peter ec22eefd31 fix(ui): E31 S2 — the tab names the object, the channel header wraps, the player fits a short window
- The browser tab names the place and the object: PageHeading reports the
  view's own title (lib/documentTitle), so a channel, playlist or any Plex
  view reads "<object> · <module> · Siftlode" however it was opened; the
  Feed is named too; a season adds its show (tabTitle).
- The channel page's tab + action row wraps and lets the tag lane fold into
  "+N": at 1024 it ran past the edge and the page scrolled sideways.
- The YouTube player card's width is capped from the window height less the
  chrome measured under the video: at 133% zoom (685px) it cut its channel row.
- Plex certificate words (Not Rated, Approved, Passed) are named through the
  vocabulary on the info badge, show page, collection, filter and chips.
- DESIGN.md: the zoom target is 125% and 133%, not 200% (decision log).
2026-10-01 21:17:20 +02:00
peter 2543d44860 fix(messages): the welcome reads in the reader's language, not the one it was created in
E31 S1: the welcome is rendered once, at account creation, so an account that
switched language kept it in the old one (the owner's, English under a
Hungarian UI). A stored copy is recognised by its opening line (derived from
WELCOME) and served in the reader's language; other system messages stay as
stored.
2026-10-01 21:17:09 +02:00
peter 29c0715237 release: 0.97.0 (feature/e30-app-in-hungarian)
gate / frontend (push) Successful in 6m16s
gate / backend (push) Successful in 7m19s
2026-10-01 10:44:40 +02:00
peter 6b86f99249 release: 0.97.0 notes — the app in Hungarian 2026-10-01 10:44:39 +02:00
peter 3563858fd1 fix(ui,i18n): E30 review round 3 — the stored language follows the screen, a fresh text starts collapsed, Retry waits again 2026-10-01 10:43:59 +02:00
peter 08e62bbb16 fix(plex): E30 review round 3 — a dead headshot breaks the Plex-failing streak, 410 held by a test 2026-10-01 10:43:59 +02:00
peter 9454717f47 fix(ui,i18n): E30 review round 2 — honest bio failure, a full show summary, one HU name per thing
- Person page: a bio ask that fails says so with a retry instead of reading as 'no biography'.
- Show page: the summary is a ClampedText (5 lines + More), like the person and collection pages;
  one More/Less pair (plex.textMore/textLess) for all three.
- Channels: a re-slotted row is placed in the language the server orders by (the stored
  preference, else English), not the UI's guess.
- A skipped run's body is only the reason (its title already says skipped).
- HU: Behúzási kvótatartalék; Eseménynapló ürítve / az eseménynaplót; indok; közvetlenül
  játszható / közvetlen közvetítés in the Plex hints.
- Tests: ClampedText re-measures on a resize, the show summary clamp, serverOrderLang.
2026-10-01 08:31:04 +02:00
peter 6ac9ffd45e fix(plex,audit): E30 review round 2 — only a missing file is a dead headshot, one spelling per portrait
- warm_portraits: only 404/410 mark a headshot dead (measured: Plex answers a dead CDN file 404);
  a refused token or a 5xx is Plex failing, ends the run after three in a row and marks nothing.
  'failed' counts every failure, not only the current streak.
- is_portrait_url: plain path segments only — no dot or empty segment an HTTP client folds onto
  one file; person_thumb_url uses the same predicate (27 852 stored headshots all pass).
- Tests: Plex refusing (401/403/429/5xx), failures between successes (portraits and bios), the kept
  original served from cache, the audit_clear / demo_reset / discovery_purge rows' counts as data,
  and the 0094 backfill on real rows.
2026-10-01 08:31:03 +02:00
peter fa55e01ef0 fix(ui,i18n): E30 review round 1 — audit rows in the reader's language, one HU name per thing
- audit log: a job run named from its result data (nested phases too), a skipped run says so, a
  secret's reset reads as a reset, choices named as Configuration names them, interval / batch /
  counts localized; the inbox shares the same jobRunText
- HU: remux is "Közvetlen közvetítés" (it shared "Közvetlen lejátszás" with direct play); backfill is
  behúzás, enrich is dúsítás, collection is kollekció, the allow-list is fehérlista; the last
  "jobnak" / "transzkódolás"; a test holds the options of one choice to distinct names
- contract: every skipped / reason word a job returns is named in both languages
- person page: the bio wait is timed from the open and a failing ask stops the polling; ClampedText
  (ui) shared with the collection page, whose short summary lost its dead More
- a playlist names season 0 the Specials; the channel re-slot compares in the reader's alphabet; an
  invite's date is in the admin's language; the language-ordered lists are asked again once a
  language switch is saved (saveLanguage)
- poster grids' column minimum in rem; lint refuses minmax(Npx,…)
2026-09-30 03:26:58 +02:00
peter ab963c81d2 fix(plex,audit): E30 review round 1 — the people job keeps going, HU genres everywhere, results as data
- people_enrich: a failed TMDB lookup or a dead headshot is skipped, not the end of the run (a dead
  file is marked and left for a week; three failures in a row end it); portraits wait for the Plex
  module switch; a no-Plex original keeps its own cache key, so the resize still replaces it; the job
  entry lives in the plex domain (run_people_enrich)
- person-image takes only a headshot-shaped CDN url (no query variants of one file)
- every synced row gets Hungarian genres, and a HU genre filter matches the word as picked too;
  migration 0094 fills the rows no overlay reached
- a job run's audit row carries its result as data; audit_clear / demo_reset store their counts
- one credit-column list for the prune and the enrichment order; _overlay_pass typed
- tests: batch skip + stop, HU bio stored, dead headshots, module switch, the person-image route,
  the job body, genre filter + facet write + twin fallback, tagline/summary fallbacks, audit data,
  the reader's alphabet at every remaining collated() call site
2026-09-30 03:26:57 +02:00
peter 59e6cc027d fix(plex): a short "Known for" rail sits right beside the text (E30 S6)
With one to three titles (about one person in five) the rail sat at the far edge of the hero with a gap
between it and the bio. The person's column is now capped at the bio's reading measure and a short rail
follows it directly, so the leftover is the panel's end (owner pick "b"). Four or more keep half the hero.
2026-09-30 02:27:28 +02:00
peter ca94a716d8 docs(design): composition and the reader — a chapter and a Conformance step (E30 S6)
The rules covered each piece; nothing asked whether a screen works as a whole, and the person page passed
every rule with half its panel empty. New chapter: the space is used, one focal point, worst real data,
where am I, a click answers at once (no outside service on open), zoom and text size first-class (the
owner reads zoomed in). Conformance step 5 writes one composition line per screenshot and checks 200%
zoom and the largest text size. Anti-patterns and a decision-log row to match.

HU: the download worker setting reads "Párhuzamos letöltések" (a leftover from the S5 sweep).
2026-09-30 02:19:54 +02:00
peter 63d01babe5 feat(plex): the person page in two columns; a bio on its way holds its place (E30 S6)
The biography sat at reading width in the left half of a full-width panel with nothing on the right.
Now the person (headshot, facts, the bio clamped to eight lines with More / Less when it is cut) is on the
left and "Known for" on the right; with fewer than four titles the rail takes only its posters' width.
One column where the panel is narrow (200% zoom, the largest text size).

While the server fetches a bio (`enriching`), the page shows a paragraph skeleton (a new "text" shape of
LoadingState) and asks again every 1.5 s for up to 20 s. The view is keyed by the person's name, so
another person starts fresh.
2026-09-30 02:19:54 +02:00
peter 705377c797 feat(plex): the people are local — bios and portraits fetched ahead, not on the click (E30 S6)
A person's TMDB bio was fetched only when their page was first opened, off the request path, so the first
open showed no bio and the page never asked again (2 of 18,202 linked people were ever enriched).

- people_enrich scheduler job (every 10 min, 300 per run): linked people's bios from TMDB and their
  portraits into the image cache, the most-credited people first. A failed TMDB lookup ends the batch.
- Portraits are resized through Plex's photo transcoder to a 320 box (~15 KB instead of ~170 KB; the
  person-image proxy uses the same cache key, and a full-size original is removed as its small twin lands).
- The person response says `enriching` while a bio is on its way (a person the job hasn't reached yet).
- "Known for" is ranked by vote count, without guest visits as themselves (talk shows had crowded out the
  films: Tom Cruise had 14 of 15), with directed works, from a 40-deep pool; the page shows up to 15.
  Migration 0093 un-stamps enriched people so the job refetches them.
- A phased job result (backfill, people_enrich) now counts as a change for the audit trail; before, an
  automatic backfill run that added videos was never audited.
- HU: the watch reconcile job reads "egyeztetés" (a leftover from the S5 sweep).
2026-09-30 02:19:54 +02:00
peter 2819049133 feat(audit): job runs and config changes read in the reader's language (E30 S5)
The audit log showed the server's fixed English lines ("rss_poll: ok — new=5, failed=0",
"log_level = DEBUG", "Background sync paused") to a Hungarian reader. The details are rebuilt from the
row's data on the client: a job run names the job and its result as the inbox does, a config change
names the field and its values (on/off, grouped counts, "default"), a pause/resume adds nothing the
action does not say. A failed run keeps its error text (diagnostic). parseSummary reads enrich's bare
count ("50") back as `enriched`, as the server's `_result_data` names it — the audit and old notices
keep only the one-liner. No backend change; old rows render too.
2026-09-30 01:08:29 +02:00
peter 95b94a1d72 fix(ui): a longer label fits its control — pill value truncates, headers get their room (E30 S5)
Found in the Hungarian UAT: a toolbar SelectTrigger's text value wrapped out of the pill in a narrow
place (the playlists rail) — it now truncates in its own span; the Channels "Prioritás" and "Utolsó
feltöltés" headers were clipped by their fixed columns — sized for the label, funnel and sort arrow;
a sortable header is at least 24px wide (Hungarian "Ki" was a 12px target). PRE-EXISTING, any
language: the messages unlock button and "Forgot passphrase" sat flush — one gapped row.
2026-09-30 01:08:29 +02:00
peter 12077fbebc feat(i18n): Hungarian words for the English loanwords, one name per thing (E30 S5)
The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info,
Frontend/Backend, Backfill and transcode get the Hungarian word (közvetítés, Prioritás, Feladat,
Eseménynapló, Demó, Automatikus, Infó, Felület/Szerver, videók behúzása, átkódolás). The final sweep
also took out reset/sync/reconcile/setup/logs/whitelist/email, and three features that went by two
names: access requests (kérés/kérelem), backfill (betöltés/behúzás), and the connection test
("Check the connection" beside "Test connection", in both languages). Two quota actions shared one
Hungarian name. The playlists rail title and two of its sort labels are shorter so they fit.
DESIGN.md Copy: the rule and its decision-log row.
2026-09-30 01:08:29 +02:00
peter 519fbe2a18 feat(i18n): the person page's biography in Hungarian, from TMDB's translation (E30 S4)
The /person fetch appends translations and keeps TMDB's Hungarian biography beside the instance-language
one (new plex_people.biography_hu, migration 0092). A Hungarian reader is served it, the English one when
TMDB has none. Already-enriched people are un-stamped, so their next open refetches them once.
2026-09-29 23:44:48 +02:00
peter a39504e617 fix(stats): the full-history ratio counts the requested channels on both sides
"Full history: done / requested" counted every subscribed channel whose
back-catalog was complete in the numerator - also the ones another user asked
for, or short enough to finish on their own - against only the channels this
user requested, so the Stats page read "342/329". The numerator now counts the
requested channels that are done; "channels without full history yet" still
counts every subscribed one.
2026-09-29 22:46:04 +02:00
peter aac79ffa53 fix(i18n): numbers, units, dates and order in the UI language, and after a switch (E30 S3)
Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.

- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
  the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
  outside lib/format.ts, localeCompare, and a component reading the language
  off the i18n singleton
2026-09-29 22:39:57 +02:00
peter 67c9f10153 feat(i18n): every server A→Z list in the reader's alphabet (E30 S3)
The Postgres image is Alpine, whose en_US.utf8 is byte order: "Életrajzi" and
"Ötödik" sorted after "Zorró" in the feed, the channel lists, the Plex grid,
its genres and collections. `app/collation.py` names the ICU collation of the
reader's language (hu-x-icu puts "Ö" after "O" and "Cs" after "Cu"), wraps
every text ORDER BY with it - the feed's keyset cursor compares with the same
expression, so paging walks the order it sorted in - and orders the lists built
in Python (genre and studio facets, collection members, the discovery re-sort)
through the same collation. Plex title tiebreaks use the shown (HU) title.
2026-09-29 22:39:57 +02:00
peter 584d082b6b feat(inbox): a run notice from before the result rode along reads its one-liner back into one (E30 S2) 2026-09-29 21:32:29 +02:00
peter f3a85916ec feat(i18n): the server's closed English vocabularies named in the reader's language (E30 S2)
- lib/vocab + the `vocab` locale area: TMDB crew jobs and departments, auto-tags (D1 A: the data
  stays English, the catalog is named at its one source, useTags), built-in download profiles,
  YouTube topics; held key-for-key to the backend's own lists by backendIds.contract.
- Countries by ISO code: TMDB parse stores {code, name}; rows stored before are coded from TMDB's
  own country list in one request (run_enrich); Intl names them, historic codes (YU, SU, XI…)
  from the catalog.
- Player tracks named from the language tag, forced/SDH flags and codec tail, not Plex's
  server-language displayTitle; no invented "Audio N" on the server.
- Download lifecycle notices (shared / expiring / expired / evicted) named from data; the share
  notice carries the title.
- Scheduler: the job result rides along as data and is named per key (dashboard + run notice).
- Invite status named; a render crash's notice no longer shows the engine's English message.
- Fix: YouTube links …/wiki/Humour, the tagger looked for "Humor" — Comedy never came from a topic.
2026-09-29 21:21:19 +02:00
peter f6298d29af fix(plex): the recs rail's anchor and a held recommendation are named in the reader's language (E30 S1b)
Found in the S1b UAT: 'Mert megnézted: Batman Forever' kept the English title, and
the info page's TMDB recommendations kept TMDB's English titles even for titles we
hold. A held recommendation is now named as the library names it, in the reader's
language; one we lack keeps TMDB's title.
2026-09-29 02:25:36 +02:00
peter 632e39d458 feat(plex): every card rail in the reader's language; an episode in a poster rail stands as its show (E30 S1b)
The grid served Hungarian titles, but every other card surface (Discover rails, the
show page's episodes, search, collections, the person page, playlists, Home's resume
rail, hero and comfort shelf) served English. The card serializers now take a
required keyword-only lang (a forgotten call site fails instead of going English), and
each of those reads takes ?lang=hu, with the language in its query key. A Hungarian
search finds an episode by its Hungarian title too.

An episode on a poster rail showed its 16:9 still cropped into a poster, titled only
with the episode name; it now shows the show's poster and name, with S·E · title under
it (option A). Plex's generic season names (Season 1, Specials) are rebuilt from the
number in the reader's language; a season with a name of its own keeps it.

Home's comfort-shelf move wrote its optimistic order to the key without the language,
which nothing read any more; it writes the query's own key.
2026-09-29 02:12:32 +02:00
peter 13d95ef70e feat(plex): episodes, taglines and genres in Hungarian from the HU overlay (E30 S1a)
The HU TV section lists every episode with its Hungarian title and summary, but the
overlay read only movies and shows, so every episode stayed English. It now overlays
the episodes too, and the HU tagline (new tagline_hu column, served on ?lang=hu).

Plex's HU agent returns a mixed genre vocabulary (Action beside Dráma), and a title
with no HU twin kept its English list, so the HU genre list showed Drama and Dráma.
plex/genres.py maps every genre word to one Hungarian word; the overlay writes it
into genres_hu for every row, and a HU genre filter is read through the same map,
so a saved filter still holding Action keeps matching.
2026-09-29 01:52:02 +02:00
peter 0f9ed7cdbf test: a list key waits for the list's registration, not only its rows
gate / frontend (push) Successful in 6m5s
gate / backend (push) Successful in 7m10s
The server gate's frontend job went red on 0.96.0: PlexPlaylistView's j/Delete
test pressed j after the rows painted but before useListNav's passive effect
registered the list, so the key did nothing on the slower runner. Every test
that presses a list key after an async load now waits for [data-nav-list] on
its list first (PlexPlaylistView, Playlists, Home, PlexDiscover). Checked
against a 30 ms delayed registration: the old tests fail, the new ones pass.
2026-09-27 23:01:57 +02:00
peter ffcea0cb07 release: 0.96.0 (feature/e28-design-constitution)
gate / frontend (push) Failing after 6m12s
gate / backend (push) Successful in 7m22s
2026-09-27 22:46:39 +02:00
peter a3f8314c9a chore(release): 0.96.0 — the design constitution
E28: one action list per object, keyboard reorder, touch drawers and player
controls, the table pager, plus four deep-review fix rounds (playlist sync
locking, player restart and focus behaviour).
2026-09-27 22:46:39 +02:00
peter d5ccb1091a fix(player): a held key on faded bands stays one blind press; a play asked for mid-restart is the new session's
The repeats of a Space/Enter that landed on faded bands pressed the control once the bands were up;
they are swallowed with it. A play or pause while a session restart is out now sets that session's
intent instead of playing the outgoing stream. Tests: Skip intro outside the bands keeps its keys,
every restore takeover (wheel, finger, scrollbar, key) is covered, and the chip handoff test tells
the chip before from the row's first control.
2026-09-27 22:34:14 +02:00
peter 528774d640 fix(playlists): a push stops at a list deleted mid-way, and a rename made during it stays unsynced
A delete landing while a push read YouTube left a plan over no items, so the push emptied the
YouTube playlist a local-only delete keeps: the push now stops at a vanished list (PlaylistGone,
404), and a delete is refused while a sync of the list runs. The closing baseline fingerprints the
title YouTube got, not the one its lock re-reads, so a rename made during the writes stays dirty.
The other-tab test helper bounds its lock wait, so a lock-order regression fails instead of hanging.
2026-09-27 22:34:14 +02:00
peter 3a9ea9e330 test: the side panel's focus step is held in its commit; the MenuCheck contract counts chosen roles 2026-09-27 21:56:24 +02:00
peter 25f2d9d0e4 fix(ui): a removed chip's focus outlives its neighbour; a scroll restore yields to the reader
A chip handed its focus to a Clear all or +N that went in the same removal, and the focus fell to
the body. The restore now stops when the reader scrolls, and finishes the way once the Back-to-top
spacer brings the saved spot within reach.
2026-09-27 21:56:23 +02:00
peter 5ab6778b97 fix(player): a Restart of the paused film plays the new session; a key on faded bands only shows them
The same-film restart read the paused state, restarted the session without play, and resumed the
outgoing stream beside it. Space or Enter on a control the bands had faded from pressed it unseen;
now it only brings them back, and Space plays or pauses.
2026-09-27 21:56:23 +02:00
peter 26697472cf fix(playlists): every sync path locks the list before its items, and holds it to the write
The mirror refresh re-ingested inside its row lock, and that ingest's commit let go of the lock
before the replace: a remove made then was undone and the list read as synced. Push, re-pull and a
delete touched items before the list row, a deadlock beside the playlist-first route edits. A push
now re-plans from the items under the lock and derives dirty after its YouTube writes.
lock_playlists moves to the sync package. Tests pin both ids of a move's lock and the claim's
own connection.
2026-09-27 21:56:23 +02:00
peter 2abd4ffcb6 fix(player): a session superseded before it played is reclaimed, not "couldn't start"
Two session requests for one item in flight at once (a quick double seek; the
StrictMode replay of the start effect) let the server's last spawn be the older
request's. The newer request's answer then named a session already gone; its
playlist 404'd before a manifest parsed, and the player stopped with "Playback
couldn't start". The 404 reclaim now covers a session that never played too,
bounded by the same budget, and keeps the start's own autoplay.
2026-09-27 20:22:43 +02:00
peter a9ac303d3e fix(side-panel): the key's focus steps into the peeked card in the commit that opens it
A frame later never came in a page the browser was not painting (measured: a hidden page runs no
requestAnimationFrame), and the focus stayed on the inert tab under the card.
2026-09-27 19:58:18 +02:00
peter 740602b9bf fix(ui): focus, paging and state that a key or a finger can reach
- A key landing on a side panel's tab steps into the card it peeks open, not onto the hidden tab.
- The Channels cards keep their status / pager row at the list's end while the band is folded.
- A menu row's note shows on an enabled row too: the download state under 'Download…', not a title.
- A removal chip hands the focus to its neighbour before it goes, for every caller (the feed's and
  Plex's active filters); the hidden-filter cue's own copy is gone.
- Back to a spot within a page's end spacer lands at the page's end, not its top.
- A running push / revert is tracked per list: another list's push is no longer refused unseen.
2026-09-27 19:43:04 +02:00
peter e31a588f19 fix(menus): one tick for every choice; a listbox holds its options only
- The saved views, the view switcher, the year filter and the table's single-choice filter mark the
  chosen row with MenuCheck, as DESIGN asks, not a trailing tick, a dot or a filled row; a contract
  test holds every checkable menu row to it. keepSelectedPaint had no caller left and is gone.
- A combobox's panel carries its listbox role on an inner PopoverList (listInside), so the failed
  load, Loading, No matches and the 'n more' line sit beside the options, not inside the listbox. The
  Plex search box's Loading line likewise.
- The Plex sidebar's playlist picker offers Create only once the lists have loaded: over a failed load
  it pushed the error and its Retry out at the first keystroke.
2026-09-27 19:43:04 +02:00
peter cda626be10 fix(player): the playing film restarts in place; arrows stay the volume; the bands' holds end
- The info overlay's collection strip listing the film itself: Play/Restart restart or resume it in
  place instead of pausing it on a frozen 0:00.
- Up/Down on the closed ⋯ and tracks buttons stay the volume (openOnArrowKey: false).
- A keyboard focus in the bands holds them for 10 s after the last key, not the whole film; a tap
  leaving a tapped button no longer re-shows the bands its own toggle then hid.
- Next leaving under the focus hands it to its neighbour (the player keeps its focus).
- The chosen audio / subtitle track wears the MenuCheck tick.
2026-09-27 19:42:53 +02:00
peter b7ce1d467c fix(playlists): item writes lock the list first; one bulk pull per user; the claim on its own connection
- Every playlist item write (add, remove, move, reorder, rename; Plex remove and remove-bulk) row-locks
  the playlist before it touches an item, a move both lists in id order: remove beside reorder was a
  deadlock, and two adds of one video a unique-key 500.
- The scheduled mirror refresh re-reads dirty under that lock, so an edit made during its YouTube calls
  is not replaced by YouTube's order.
- A user's bulk pull holds a per-user advisory claim: two at once created duplicate mirrors. The manual
  sync answers 409 sync_all_busy instead of '0 synced', and like the re-pull spends its cooldown only
  on a pull that runs.
- Deleted mirrors are committed at once, not only when a later list commits.
- The claims open an unpooled connection of their own instead of a third pooled one per push.
2026-09-27 19:42:53 +02:00
peter 52fb999af6 feat(pickers): a list that failed to load offers Retry
A picker whose options failed to load said so, but the only way back was closing its dialog.
Combobox takes `onRetry`: a Retry beside the message for the pointer, and Enter in the field for
the keyboard (the panel is a listbox, which may hold options only, so the alert names Enter for
assistive tech). Every caller with an error state passes its query's refetch: both user pickers,
the channel picker and the two Plex pickers.
2026-09-27 17:47:01 +02:00
peter e0453fa07d fix(playlists): one YouTube sync of a list at a time, across tabs and the scheduler
The client's in-flight flag covers the menus of one page only. A push reads YouTube, then writes
both sides for seconds and commits step by step, so a row lock ends at its first commit: two tabs,
or a push and the scheduled mirror refresh, could interleave into a doubled YouTube item or a
baseline recorded over the other's writes. `sync_claim` takes a session-level advisory lock on a
connection of its own (it survives those commits; a crashed worker's lock dies with its
connection, so no claim column is left stuck). Push and revert answer 409 `sync_busy`; the
scheduled refresh skips a list being synced and picks it up next run.
2026-09-27 17:47:01 +02:00
peter 2525a12264 fix(keys): a key a focused widget already handled fires no shortcut
The player's settings tabs switched tab on an arrow and prevented its default, yet the player's
window listener still sought the video 10 s. dispatch() now skips a defaultPrevented key for
every scope; listNav's private copy of the same guard goes.
2026-09-27 17:28:10 +02:00
peter 8e09dd85f8 test: the store contract reads the last match without Array.at (the node tsconfig's lib) 2026-09-27 16:38:33 +02:00
peter addb3d1b32 fix(a11y): every side-panel pin names its panel; the tab under an open card leaves the tree
The card's own pin and the drawer's still said 'Unpin panel' / 'Pin panel open' after the edge tab
learned the title (F6). Naming them too made two buttons share a name, because the invisible tab
under an open card was still in the accessibility tree: it is now aria-hidden as well as out of
the Tab order. The generic sidebar.pin / unpin keys are gone.
2026-09-27 16:30:40 +02:00
peter 86884e368e fix(ui): the page's end scrolls out from under Back to top
The button floats over the bottom-right corner, and at the end of a long table it covered the closing
pager's '100' and 'All'. While it is up, PageScroller ends the page a button's height later (only
then, so a full-height layout gets no scrollbar). Its shown state is a shared store read through a
use… wrapper: a store's .use() called straight from a component is memoized by the React Compiler
and never re-renders (contract: store.contract.test.ts).
2026-09-27 16:28:18 +02:00
peter 51f881681b feat(tables): name a filter whose column is off screen, and let its chip clear it
A tabletHidden/narrowHidden column below its width, or any column in a card
layout, took its header funnel with it: the rows were narrowed by a filter
nobody could see. The cue is shown by the inverse of the column's own
container query, so it agrees with the table at every width and text size.
Wired into DataTable, the playlist table and the channel cards (E28 F2).
2026-09-27 16:22:26 +02:00
peter 27483ae1d6 fix(focus): a removed control's focus skips a neighbour the CSS has hidden
A display:none button refuses focus(), so handing the focus to a hidden
column's header button dropped it to <body>.
2026-09-27 16:22:26 +02:00
peter 595c29e17c style: prettier 2026-09-27 15:17:02 +02:00
peter acf93a1f36 test: the video menu's download row is 'Download…'; drop home.pin.menu (its menu is gone) 2026-09-27 15:13:19 +02:00
peter 84278293df docs(ui): the raw-button rule is 'outside the control primitives', and the README table is whole
CLAUDE.md, DESIGN.md and ui/README said a raw <button> is allowed anywhere in components/ui/; the
lint rule allows it only in BUTTON_PRIMITIVES (and refuses a bare FocusSafeButton elsewhere too).
The focus-safe rule's message no longer points feature code at FocusSafeButton. The README's
'actions & inputs' row was split in two by a stray line break.
2026-09-27 15:09:22 +02:00
peter dc24d80ed4 fix(player): a description link keeps its sentence's punctuation and its own parentheses
The trailing .,;:!?)] was trimmed off the href and dropped from the text too, and a URL ending in a
balanced ")" (a Wikipedia article) was cut short. The trim keeps a closing paren that has its
opener, and the trimmed tail is put back after the link.
2026-09-27 15:08:27 +02:00
peter 3810af66da fix(channels): every subscribe and unsubscribe refreshes the same reads
Each of the four sites kept its own invalidation list and each missed something (the channel menu's
unsubscribe left the feed showing the channel's videos; the channel page's left the counts stale).
lib/subscriptionCache invalidateSubscription is the one set; a contract test holds every call site
to it.
2026-09-27 15:07:39 +02:00
peter eb339195c7 feat(channels): the band's tag chips and status row fold on a short window (B4)
Every other page band folds its filter row below ~600px of height; the Channels band never did, so
its tag chip lane and status / pager row kept a short laptop window for themselves. They fold behind
one "Filters · n" (n = picked tags + other active column filters); the tabs and the TAGS editor stay,
and a folded table keeps its controls at the list's end.
2026-09-27 15:06:32 +02:00
peter 5d1065c1bc feat(playlists): a playlist's menu copies its name and its YouTube address
Every object's action list ends its middle group with Copy name / Copy link (E28 C1, the rows a
finger cannot select); the playlist's had neither.
2026-09-27 15:04:47 +02:00
peter 6ffb5bd8c1 refactor(pins): drop the unused ComfortPinMenu and the comment that described it
Every surface composes the pin row into its object's one action list (E28 C1); the pin-only menu
had no caller left. Also un-exports isStandInFocus, which only its own file reads since H1.
2026-09-27 15:03:28 +02:00
peter dddf79ec2f refactor(ui): FilterSelect and Combobox draw their checks with MenuCheck
They still painted their own 14px box beside the menus' 16px MenuCheck. A contract test holds the
filled-box paint to MenuCheck alone.
2026-09-27 15:02:42 +02:00
peter 5ebb4cb506 fix(a11y): chips, grips, the panel tab and Back to top keep the focus somewhere real
- F5: a chip's × pressed from the keyboard hands the focus to the next chip (else the previous one,
  else what follows) before it unmounts — in DismissibleChip, so all five chip rows get it
  (`focusNeighbour` in lib/useFocusKeeper).
- F4: a drag grip names what it lifts (`common.order.dragNamed`: panel groups, personal views);
  the two generic "Drag to reorder" keys are gone.
- F6: the side panel's edge tab is out of the Tab order while the card covers it, names its panel,
  and a keyboard pin moves the focus into the card.
- F7: Back to top is no Tab stop while hidden, and a keyboard press leaves the focus on the page.
2026-09-27 15:01:22 +02:00
peter 0b1d905866 fix(ui): Enter on a selectable title opens it even with its words selected
endsTextSelection takes the click event and exempts a keyboard click (detail 0): Enter / Space
ends no selection, so a title selected and then tabbed to opened nothing. All three callers
(FocusSafeButton, the Plex caption, the video card title) go through it.
2026-09-27 14:55:50 +02:00
peter 52c15adaf2 fix(downloads): a row whose remote picture fails too falls back to its glyph
The remote thumbnail was the last resort with no fallback of its own, so a dead CDN link drew the
browser's broken-image mark. A failed remote URL is remembered like a failed poster, and the row
shows its film / music glyph.
2026-09-27 14:54:28 +02:00
peter 3372626e34 fix(notifications): "Find in feed" drops every narrowing that could hide the video
locate() spread the reader's filters and cleared only the tag, search and age cap, so a year, a
custom range, a length, a content type (a hidden Short under "no Shorts") or the scope could still
leave the video out. It builds the filters from nothing now, keeping only the sort and tag modes.
(Also types the C1 test's pending plan.)
2026-09-27 14:53:24 +02:00
peter 98bb841a6a fix(playlists): one push or revert at a time for a list, whichever menu started it
The in-flight flag was per hook instance, and the rail row's menu and the list page header's "…"
are two at once: a push from one left the other offering a second push. It is one app-wide store
(`lib/store`), read with `get()` at the start so a second start refuses before a render; a revert
re-reads it after its confirm dialog.
2026-09-27 14:51:37 +02:00
peter 98bf0a9d66 fix(i18n): the video menu's download row says "Download…", and counted copy is pluralized
- The row opens the download dialog, so it is labelled so; the download's state ("Downloaded",
  "In queue") is its hint, not its label.
- The share dialog's expiry ("1 days") and a link's view count ("1 views") go through i18next's
  plural forms (`count`).
2026-09-27 14:50:04 +02:00
peter 17bb14fb18 fix(ui): a picker whose list failed says so, and takes no pick while one is being written
- Combobox gains `error` (said in the list, not "No matches") and a soft `disabled` (keeps the
  focus, announced, opens and changes nothing).
- UserPicker passes both: the share dialog's recipient picker is off while a share is pending, and
  the share and quota pickers say when the user list failed to load.
- ChannelPicker and the Plex playlist/collection pickers say a failed load too.
- The channel menu's Tags row says loading / failed instead of "no tags yet".
2026-09-27 14:47:46 +02:00
peter 70f98b352f fix(plex): a collection sibling is a full Plex card, and the player's info overlay plays it
- PlexInfo's collection strip gives each card the related strip's action set: the "…" menu (with
  Restart for a touch reader), the watched toggle and the playlist add.
- `onPlayItem` is required, so no variant draws a Play that does nothing; the player's info overlay
  plays the sibling in the running player (`go(id, _, fromZero)` for its Restart).
2026-09-27 14:44:38 +02:00
peter efa4aeeeda fix(plex): a card offers Restart by the menu's own "started" rule
The picture read "started" off the rounded progress %, so a resume point under 1 % showed Play
while the card's menu offered "from the start". Both use `plexStarted` now.
2026-09-27 14:38:27 +02:00
peter a7d41eb5f3 fix(plex): the player's settings panel is a tab list with named, touch-sized rows
- Its section switch is `ui/Tabs` (new `tone="media"` paint for the dark chrome) with a wired
  tabpanel; the panel opens with the focus on the selected tab.
- On/off rows are `CheckRow` (the whole row, 44 on touch), the "no background" checkbox too.
- Every slider and the four colour wells carry the name their row shows.
- The clock's date-style buttons reach the touch target.
2026-09-27 14:38:27 +02:00
peter 76eead976f fix(plex): the player's stage follows the tablet toolbar, and its bands stay out of a double-tap
- The stage is sized in dvh (80dvh × 1.25 windowed, 100dvh fullscreen); the viewport-units
  contract no longer exempts the player.
- A tap that reveals the bands keeps them pointer-events-none for the double-tap window, so the
  second tap of a skip at the bottom is not a seek.
- An arrow on the volume slider wakes the bands, and a keyboard focus in a band holds them
  (`holdOnFocus`, `:focus-visible` only, so a clicked button does not pin the bar).
- Next stays mounted (disabled) while the next episode's detail loads, keeping the focus.
2026-09-27 14:34:31 +02:00
peter 922d589b09 fix(ui): Tabs is an ARIA tab list with roving focus and a wired panel
The rail was a row of aria-pressed toggles: every tab a Tab stop, no arrows,
nothing tying a tab to its content. tablist/tab/aria-selected, one Tab stop,
arrows on either axis, Home/End, and useTabPanel for aria-controls/labelledby.
2026-09-27 14:22:37 +02:00
peter a975ab627a fix(plex): the player's ⋯ and tracks lists are real menus, and every close returns the focus
They were hand-built divs: no menu roles, no arrow keys, and Escape left the
focus on <body>. Both run on the popover engine now (media-tone rows), and the
tuning panel is a labelled dialog that takes the focus and hands it back to ⋯.
2026-09-27 14:22:37 +02:00
peter 9bedd96774 fix(nav): Back restores the focus after a click on the page's blank area
The page region is <main tabIndex=-1>, so a background click focused it and the
Back restore took that for the reader's choice. A resting place is marked and
one predicate (focusIsUnclaimed) decides "the focus is nowhere" everywhere.
2026-09-27 14:06:16 +02:00
peter 383c85231a fix(plex): a Restart does not replay on a reload or a Forward onto the player
fromStart rode in history.state._sub, so reloading the player entry restarted
the item from 0 again and the progress checkpoint overwrote its resume point.
A sub-view config can now say what the history entry keeps of a view.
2026-09-27 14:03:48 +02:00
peter de62035889 fix(playlists): an Undo puts a row back after its old neighbour, not at a stale index
A raw index recorded at removal drifted once anything changed before the Undo.
Both lists now read the list fresh and re-insert each row after the row that
preceded it (lib/reinsert). A failed watched toggle in a Plex playlist also
puts the row's state back: its local copy was never re-seeded.
2026-09-27 14:02:15 +02:00
peter b456232d00 fix(playlists): lock the playlist row before reading and writing item positions
Two adds read the same max(position) and a reorder racing an add collided with
it. Every add/insert/reorder takes the parent row FOR UPDATE; the YouTube list
breaks position ties by id and a Plex reorder renumbers rows it was not sent.
2026-09-27 13:58:23 +02:00
peter c9e95ecea6 fix(channels): a channel YouTube no longer returns is not asked for on every view
channels.list simply omits a terminated channel, so nothing was stamped and the
channel page spent a quota unit on it every time. The miss is stamped like a
fetch; the published_at sentinel that kept it stale is gone.
2026-09-27 13:58:23 +02:00
peter 7987bd8a37 fix(plex): a stray byte keeps a UTF-8 subtitle UTF-8; untagged and Serbian read as cp1250
One invalid byte sent a whole UTF-8 sidecar to a legacy code page, an untagged
file fell to Windows-1252 (ő/ű became õ/û) and Serbian was read as Cyrillic.
The cache key moves to sub3_ so a track the old rules mis-decoded is read again.
2026-09-27 13:58:23 +02:00
peter 3411265dc1 docs(design): tablet columns, glyph pager, match switch, missing picture, Copy 2026-09-27 07:12:51 +02:00
peter 098b8dbe43 feat(downloads): a bare link is titled once, a missing picture shows a glyph
A job with no name yet is titled by its short link, as the link itself;
the channel line no longer falls back to the ref and the link line does
not repeat it. No picture at all draws a film (or note) glyph instead of
an empty box.
2026-09-27 07:12:51 +02:00
peter f48f16830e feat(filters): the any/all switch sits on the panel above its picker
ui/MatchToggle replaces the Combobox popover footer (Feed tags, topics,
languages; Plex collections) and PlexSidebar's own toggle (genres,
actors): shown from two picks, one wording. A closed picker had hidden
that "all" was on.
2026-09-27 07:12:51 +02:00
peter 955c23d321 feat(tables): a glyph pager with first/last, and tablet-width columns step aside
The pager is four named arrows round the page box (first, previous,
next, last). A table hides a tabletHidden column below 64rem of its own
width, as narrowHidden does below 42rem, through one shared helper that
also hides the column's <col>. The Channels table drops Prio, About and
Length there: at 1024 the channel name had 60 px.
2026-09-27 07:12:51 +02:00
peter d3e1d8219c docs(design): Fjord stays a fifth scheme beside Midnight
The trial is over: the owner kept it. The trial wording goes from the
tokens, the scheme list, the lab and DESIGN; B2's own-hue rule still
binds any new scheme.
2026-09-27 06:46:52 +02:00
peter 4480839db0 merge: bug/channel-details-refresh into E28
# Conflicts:
#	frontend/src/components/ChannelPage.tsx
2026-09-27 06:42:37 +02:00
peter 8e25567ff2 merge: bug/hu-subtitle-encoding into E28 2026-09-27 06:42:08 +02:00
peter 8f65bd184e fix(i18n): a locale edit hot-swaps the texts instead of re-running main.tsx
The locale JSON's importer did not accept HMR, so the update climbed to
main.tsx, whose re-run called createRoot on the live container again; the two
roots tore each other's DOM down ('removeChild ... not a child' by the dozen),
each a fatal toast evicting an old notification. Dev only.
2026-09-27 06:35:40 +02:00
peter ae9c248987 fix(downloads): Retry draws the refresh glyph, not Play (E28 S4)
The first UI-review run (Downloads) found a failed row whose Retry wore the
Play glyph in the row and its menu, promising a video. DESIGN.md gains
'a glyph means one thing' and a log row.
2026-09-27 06:35:40 +02:00
peter bd6aaa1e43 feat(design): the Copy rule — names, verbs, dates and units (E28 S3g)
The owner's picks (all the recommended options) become DESIGN.md's Copy
chapter and a sweep of both languages (742 values, no key drift):

- One name per feature: the rail's label, which may shorten it (Alerts
  -> Notifications, Fiókok -> Felhasználók, Download Center -> Downloads,
  Channel manager -> Channels); one "sync from YouTube" verb.
- Four verbs, four meanings (Delete / Remove / Dismiss / Discard, and
  their Hungarian pairs), checked against what each action does.
- Sentence case, the app's places capitalised; "…" on a command that
  asks first (a popover or form title got its own key without it);
  one-sentence messages without a stop or a tick, "Couldn't …"; curly
  quotes.
- Dates only from lib/format.ts, in the UI language: formatDateTime
  names the month and stops at the minute (it printed "9/27/2026,
  8:30:14 AM"), formatDateTimeSec for the log, the audit and the
  recorder; ui/RelativeTime pairs "3d ago" with the exact minute on
  hover. local/date-format refuses toLocale*String and Intl date
  formatters elsewhere.
- Units from Intl, short (formatUnit): runtimes, totals, the Scheduler
  countdown, the stats range, the player's tap skip. "2h 13m" was
  English in both languages.
- src/i18n/copyRule.test.ts holds the machine-readable part (quotes,
  "...", ticks, bare unit letters).
2026-09-27 05:46:17 +02:00
peter 2c65edfffd fix(downloads): the failure link keeps the 24px floor on a mouse (E28 S3g)
The S3g measure found the row's failure link 20px tall (the meta line's
text height): DESIGN B1 puts nothing under 24. Coarse pointers already
grew it to 44.
2026-09-27 05:13:32 +02:00
peter 6c2c01e67b fix(design): the S3g conformance sweep — primitives at their rung (E28 S3g)
The probe re-run over the 15 pages at 1920x1080 found every button, icon
button, chip, field, tab, segment and menu row drawn by its primitive
except the S3h Plex picture, a div with the button role. It is a
Pressable now, and the button-primitive lint rule refuses an element
with role="button" outside the control primitives.

- SegmentedToggle defaults to the toolbar rung (28, text-xs): the old
  small default had put the pager's page sizes and the YouTube result
  count on 10px type beside 28px buttons. The Plex match toggle asks
  for the small rung explicitly.
- The language badge on the rail uses text-3xs (was an 8px one-off);
  the Plex playlist's watched mark and the Home hero's length use
  MEDIA_BADGE instead of hand-copied classes.
- A media picture shows the pointer whether it is a link or a button.
- DESIGN.md: a Conformance chapter (the four-step check before a UI
  change is done, with the S3g baseline), the segmented rung, two log
  rows. CLAUDE.md points at the check.
2026-09-27 05:11:34 +02:00
peter ef343dbec0 feat(design): side panels open as a drawer on touch and below 768 (E28 S3f3)
C2 as decided: on a coarse pointer, or a window below 768, the panel's
tab (shown at every width, 44 wide on touch) opens the panel as a
start-edge drawer over the content, with a scrim. It is ui/Modal's new
drawer-start placement, so Esc, Back, the scrim, the focus trap and the
scroll lock are the dialog's own. A tap never peeks; pinning is a button
in the drawer's header, offered only at 1024 and up; a pin saved on a
desktop is left alone below that. A mouse on a wide window keeps
hover-to-peek and click-to-pin.

DESIGN: C2 as built, rows answer to their own width, phone-only layout
deferred to its own epic (principle 2).
2026-09-27 04:38:42 +02:00
peter a58a398427 feat(design): the three 375 rows (E28 S3f3)
- Download row: answers to its own width (container query); below 40rem
  it stacks: picture beside a two-line title, the meta line across the
  row (one line, never wraps: zoomed on the iPad its pieces broke apart),
  then the source link and the actions on one wrapping line. The paste
  field takes a line of its own when the buttons would squeeze it.
- YT playlist row: below 42rem the length rides on the picture and the
  title runs two lines over the channel.
- Channels tag row: the suggestions take a line of their own below
  40rem (the chip lane was 0 px and the row's menu off the screen).
- Pager: on a phone the arrows drop their words (names kept), and the
  size label stays one line.
- Lint: a class split where the interpolation is the variant itself
  (${NARROW}:w-24) is refused too; it had shipped with no CSS.
2026-09-27 04:15:08 +02:00
peter d510c8a534 fix(plex): the show and season restart names its object and starts at 0 (E28 S3f2)
The header button said only "Play from start" and resumed the first
episode at its saved point. It now reads "Season from the start · S6 · E1"
or "Show from the start · S1 · E1" and asks the player for 0.
2026-09-27 03:30:04 +02:00
peter dc5206f9d3 docs(design): C3 + C5 as built, and its log row (E28 S3f2) 2026-09-27 02:12:54 +02:00
peter 89e65ca331 feat(design): players on touch and the C5 control set, restart on the card (E28 S3f2)
Plex player: a finger's tap on the picture only shows or hides the controls,
the hide timer starts with playback, a double-tap on a side third skips 10 s,
the seek bar is a 2.75rem touch-none hit area with its head shown on touch.
The row is play/pause, Back by position, Next only with a next item, mute,
time, tracks, a fixed ⋯ (settings, media info, stats, shortcuts, download)
and fullscreen; Stop, Restart and the ±10 s buttons are gone.

A started card shows the video card's Continue + Restart pair (one shared
MediaResumeButtons, beside the picture's button), and its menu lists the
restart; the player opens that item at 0 (fromStart). YouTube modal: the
description opens from an ⓘ, the queue arrows sit inside the screen.
2026-09-27 02:12:54 +02:00
peter 17916b411a docs(design): C4 as built, and its log row (E28 S3f1) 2026-09-27 01:33:37 +02:00
peter 3ef2c38eb4 feat(design): dialogs open by purpose and keep their commit row pinned (E28 S3f1)
Modal takes a required initialFocus (field / close / action), a field that
loads late takes the focus while it still rests on the close button, and
ModalFooter portals a dialog's commit row under the scrolling body from any
depth. The header and footer fades follow the body's scroll timeline, and the
viewport's interactive-widget=resizes-content keeps the footer above an
on-screen keyboard.
2026-09-27 01:33:37 +02:00
peter 4c63d6660d docs(design): the media card chapter and its log rows (E28 S3h) 2026-09-27 00:54:28 +02:00
peter a453b77f04 feat(design): one media card, and Home's hero and shelves as cards (E28 S3h)
- ui/MediaCard + mediaCardStyles: one frame for every video, Plex title and
  Home tile; the meta line pinned to the bottom edge, the length a badge on
  the picture, the media Play only where the click plays, the hover row at
  the picture's bottom-right (the owner's pick C).
- Shelf density: on any strip a one-line title over one meta line; mixed
  Home shelves are landscape; rails turn content-visibility off (a card's
  reserved height had become the strip's). Home slot 232px, so the Play
  and the hover row no longer touch; a landscape shelf takes the small Play.
- Home hero is a card (hover row, the whole menu, a touch "…"), three
  quarters wide and centred; channel / playlist tiles get Play, open and
  "…"; a show tile acts on its next episode, ⓘ opens the show.
- Plex preview: a ~0.9 s mouse rest opens a passive panel with the whole
  title, facts and synopsis (popover `pointerOnly`); the synopsis and title
  tooltips are gone.
- Compact list row B: the title leads, "…" at the end, toggles over the
  date on hover.
- Media-info layout B (collections, crew, cast beside the facts sidebar;
  an empty column is not drawn); filtering values are chips there and on
  the show page; "Cast" over the cast-only strips.
- Browse tiles hover like a card; prose capped by the `measure` utility.
2026-09-27 00:54:19 +02:00
peter 8e1004fe6f fix(channel): load the banner without a referrer
Google's image CDN refuses a banner request that carries a Referer from our origin - every
time, not only under load (measured: 6/6 refused with it, 8/8 served without). The banner
<img> now sends none, as ui/Avatar does for the same CDN; the comments that blamed a
transient burst throttle are corrected.
2026-09-26 21:45:46 +02:00
peter 663f30feeb fix(sync): refresh a channel's details once they are a week old
Channel metadata (banner, avatar, description, counts) was fetched only the first time a
channel was seen: the subscription import asked only for channels with no details, and the
channel page only for ones missing a join date. 394 of 397 subscribed channels had July
metadata. Both now also refresh details older than DETAILS_MAX_AGE (7 days), through one
shared staleness rule; channels.list costs 1 unit per 50 channels.
2026-09-26 21:45:46 +02:00
peter 6653d81a80 fix(channel): no broken-image glyph while the banner retries (E28)
The banner is retried on error; each failed attempt showed the browser's
broken-image glyph for as long as the retry waited. The <img> now stays
invisible until its source has actually loaded.
2026-09-26 20:55:34 +02:00
peter 21df7c8348 docs(design): the background menu as built, and the log row (E28 S3e4c) 2026-09-26 20:48:53 +02:00
peter 97381e65d0 feat(design): a page's background menu on the five list pages (E28 S3e4c)
A right-click on the EMPTY area of a page opens a menu of that page's own
toolbar: the feed (sort, reshuffle, view, watch state, content, source, clear
filters), the Plex library grid (sort, watch state, clear), the channel manager
(layout, the cards' sort, sync status, edit tags, clear the tag filter, read
subscriptions), a playlist (play all, group by, reset view, save order,
undo/redo, the list's own menu) and the notification center (mark all read).
No destructive row and no backfill.

PageShell listens on the page area; lib/backgroundTarget decides empty (not
text, a control, a link, media, or a row/card with its own menu). Shift+right-
click, a touch long-press and a right-click on selected words keep the
browser's menu. ui/PageMenu registers while mounted, so a loading or error
state keeps the browser's menu too. The rows mirror each control through
ui/pageMenuItems (sortSubmenu via pickSort, choiceSubmenu, multiSubmenu).
pageMenuScope.contract.test.ts pins the five pages.
2026-09-26 20:48:53 +02:00
peter f8ced6d819 docs(design): the flush-row ring, the fade at rest, the narrow table, and their log rows (E28) 2026-09-26 20:01:04 +02:00
peter f254588409 fix(playlists): hide '#' and Channel by the table's width, not the window's (E28)
Beside the playlist rail an 800 px window left the table 432 px: the fixed
columns took all of it and the title column was 0 px wide, its thumbnail
drawn over the channel. The table is an @container now and the columns hide
under 42rem of its own width (narrowHidden, was phoneHidden).
2026-09-26 20:01:04 +02:00
peter 3e7c79fa69 fix(design): the keyboard ring of a flush row stays visible (E28)
A list item's ring sat 2 px outside it. On a table row it ran under the
sticky head and past the table's sides; on a Plex playlist show header, flush
in its clipping card, it was cut away and the focus was invisible. A table row
(by its tag), a log line and the Plex show header and episode rows now ring
inside themselves, the show header with its card's corners.

The sticky header's fade-below was drawn at rest, over the first row's top
16 px, hiding the ring's top edge: it now fades in with the first 1rem of
scroll (a scroll-driven animation; no fade where unsupported).
2026-09-26 20:01:04 +02:00
peter 47c12f9405 docs(design): right-click on a selection, and the log row (E28 S3e4b) 2026-09-26 19:03:34 +02:00
peter bd0914b7ed fix(design): a right-click on selected words opens the browser's own menu (E28 S3e4b)
The row menu took every right-click, so selected text could only be copied
with Ctrl+C. On the selection (its laid-out line boxes) the event is left to
the browser; beside it the object's menu opens as before.
2026-09-26 19:03:34 +02:00
peter 079cee4e9c docs(design): titles in click surfaces stay selectable, and the log row (E28 S3e4b) 2026-09-26 18:46:24 +02:00
peter edf2f22ef9 feat(design): titles in click surfaces stay selectable (E28 S3e4b)
A button whose text is a title or a name is now `selectable` (FocusSafeButton,
under Pressable and Button): Chrome selects inside a button once it is
user-select: text (measured), and the click that ends a selection, or a press
dragged past the slop, opens nothing. 23 surfaces carry it (cards, rows, people,
channels, playlists, users, views, messages, log lines, description links); the
Plex caption div gets the same guard. A contract test pins every plain Pressable
with its reason, so a new one has to choose.
2026-09-26 18:46:24 +02:00
peter e1ef56cdbc docs(design): the non-drag reorder forms, and the log row (E28 S3e4a) 2026-09-26 18:14:28 +02:00
peter 4d0abc61d5 feat(design): reorder without a drag everywhere a grip drags (WCAG 2.5.7, E28 S3e4a)
One rule, two forms: useOrderItems (Top / Up / Down / Bottom rows of a row
menu) and OrderButtons (four buttons with the place, or a compact Up / Down
pair). A move that would go nowhere is shown off.

- YouTube playlist rows: the four moves now come from useOrderItems.
- Plex playlist rows: a block among the blocks, an episode within its show,
  off while a write is in flight; drag and menu share moveBlock/moveEpisode.
- Saved views editor: the selected view's four buttons (owner: A), and the
  grip's keyboard pick-up (its instructions promised one it did not have).
- Side panel groups in edit mode: an Up / Down pair (owner: A); a group the
  stored order does not hold yet moves too; the name wraps there.
- The grip's touch-action: none is now test-held.
2026-09-26 18:14:28 +02:00
peter 7c2b22bafa docs(design): the channel menu's Tags and Priority, MenuCheck, and the log row (E28 S3e3b) 2026-09-26 17:34:47 +02:00
peter 5e5c8a42a9 feat(design): the channel menu's Tags and Priority submenus (C1, E28 S3e3b)
Tags A-Z as in-place checks (the menu stays open for the next); Priority
as the fixed steps 10 5 3 2 1 0, an off-scale current value ticked in its
place. A pick re-sorts the list at once, moving only that row
(placeByPriority, the server's order), and the table / card pager and
the focus follow it (DataTable and useCardPager take a reveal seq,
listNav.focusNavItem lands on the row's own "…", a virtualized card
revealed first). ActionMenu rows take check: many | one. The menu reads
the row fresh from the cache and keys its target by id.
2026-09-26 17:34:47 +02:00
peter 2b3094ffc5 refactor(design): one drawn check for every checkable row that cannot be an input (E28 S3e3b)
ui/MenuCheck: a box filled with the accent for a set (a minus for a group
only partly in), a bare tick for one choice, in the row's leading slot.
Replaces seven hand-drawn marks: three box sizes, a tinted box, a cross
for "on" in the table filter, a trailing tick in the language menu.
2026-09-26 17:34:46 +02:00
peter 9c91eb5d22 feat(design): a library row keeps Share (the link copy) in sight, ahead of save (E28 S3e3)
The owner's call: sharing a download's link is the library's main errand, more
important than saving the file, so it does not hide in the row's "…". Manage
sharing stays in the menu. The "…" tests press as a mouse, so a stray long-press
timer no longer fires after teardown.
2026-09-26 07:21:21 +02:00
peter 2df60fbf07 docs(design): how the download, notification and Scheduler action lists are built, and the log row (E28 S3e3) 2026-09-26 07:10:02 +02:00
peter a2d17d693c feat(design): the download, notification and Scheduler action lists (C1) — the rows' "…", the band's and the strip's "…" (E28 S3e3)
A download row keeps play (pause / resume / retry in the queue) and the save link; the editors,
sharing, re-download, the copies and cancel / delete / remove moved into its "…"
(useDownloadActions). A link inside the row keeps the browser's own right-click menu. A
notification row keeps its link and its unread mark and shows its dismiss on hover; an activity
notice's Find in feed and Undo are in its "…". The inbox band keeps Mark all read and puts Clear
everything, now behind a confirm, in its "…". The Scheduler strip keeps Run all and Pause and puts
Purge discovery in its "…". Polled lists key their menus by id (keyOf). ui/menuGroups draws a
rule only between two non-empty groups. Find in feed also drops the tag, search and age filters
that could hide the video it looks for.
2026-09-26 07:10:02 +02:00
peter c4917619ee fix(design): a row's "…" closes its own menu when its row object is rebuilt (E28 S3e2)
The "…" told its own menu from another row's by payload identity. The Plex playlist page builds
each row's menu target afresh on every render, so a second press on the same "…" reopened the
menu instead of closing it. useContextMenu takes an optional `keyOf`, and the page keys its
targets by the row key.
2026-09-26 07:10:02 +02:00
peter 760bdae278 docs(design): how a channel's and a Plex playlist's action lists are built, and the log row (E28 S3e2) 2026-09-26 05:56:08 +02:00
peter 1b946113c6 feat(design): a channel's and a Plex playlist's one action list (C1) — the row's "…", Delete + undo, an action toast's 8 s floor (E28 S3e2)
- Channels: the row's full-history, hide and unsubscribe buttons become its "…" at the row end
  (useChannelActions: open, pin, YouTube, copy name / link, full history or the admin reset,
  hide with an undo toast, unsubscribe behind its danger confirm); the card layout docks the "…"
  beside the name (ChannelLayoutGrid moreKey). The admin reset's confirm now names the reset.
- Plex playlist page: a title, a show group and a season each open the page's one row menu (a
  title's is its usePlexCardActions list plus the removal); no X at rest; removals take an undo
  toast whose Undo puts every row back at its own index in one request (bulk add `positions`);
  j/k walk the visible rows, Delete removes the walked one; rename and delete in the header's "…".
- notify: a toast with an action lives at least 8 s (the owner's call), whatever the toast setting.
2026-09-26 05:56:07 +02:00
peter b0e51c6f89 docs(design): how a playlist's action list is built, and the log row (E28 S3e2) 2026-09-26 04:05:39 +02:00
peter 7455fff401 feat(design): a playlist's one action list (C1) — the row's "…", Delete + undo, the header's "…" (E28 S3e2)
- Playlist rows: the X column became the row's "…" (play, pin, copy title/link, move, order,
  remove). Remove runs at once, with no confirm; its toast's Undo puts the row back at its index.
  The Delete key removes a keyboard-walked row, and focus moves on to the next.
- POST /api/playlists/{id}/items takes an optional `position`, so an undo is one request that
  renumbers the list around the row.
- A new `listDelete` key scope. Only a list with `onDelete` registers it, so the help sheet does not
  offer Delete where nothing handles it.
- A playlist has one list, `usePlaylistActions.items`: rename, pin, YouTube open/push/revert,
  delete. The rail row's "…" and the page header's "…" both open it. Push and revert moved into
  the hook; the header keeps Play all.
- Phone-width playlist table: "#" and Channel are hidden below sm (`Column.phoneHidden`). Their
  fixed widths had left the title no width.
2026-09-26 04:05:39 +02:00
peter aaf1843b09 docs(design): how a Plex title's action list is built, and the log row (E28 S3e2) 2026-09-26 01:55:29 +02:00
peter a4737b92e4 feat(design): a Plex title's one action list (C1) — the poster's "…", details and the pin in its menu (E28 S3e2)
Every Plex poster list (library, Discover rails, person, show, season, collection, Home's rails)
now opens one menu per list from the right-click, the long-press, Shift+F10 and the card's "…":
play/open, watched, add to playlist, media info, copy title, the pin (usePlexCardActions +
PlexMenu, replacing the pin-only PlexPinMenu). The card keeps its state at rest and shows
watched / add to playlist / "…" on its picture on hover (ui/reveal.ts, shared with the video
card); on touch the toggles are gone and a 44px "…" sits beside the title, while the watched pill
stays. The hover ⓘ left the card; an episode's add button moved from its caption to the hover
row. Seasons get their own short menu (open, watched, add, copy), labelled "Open season" (the
card's overlay said "Open show"). Related shows are ordinary poster cards now. A collection's
admin "Remove" X moved into its menu, still behind the confirmation. The collection grid is two
columns on a phone. The unused .reveal-idle rule is gone.
2026-09-26 01:51:53 +02:00
peter 75ae810866 feat(design): a video's one action list (C1) — the card's "…", the menu holds the rest (E28 S3e1)
- useContextMenu gains buttonProps (a row's visible "…" opens the same menu under the button; a
  second press closes it) and anchor (the element the menu came from, for a panel a row opens).
- ui/MoreButton; ActionMenu rows can run inGesture (a clipboard write inside the click, Safari).
- useVideoActions: one builder for a video's menu (play / restart, channel, watched, save, add to
  playlist, download, details, copy title / link, hide, reset, pin), composed by the feed and by
  Home's rails; the playlists and details panels open anchored beside the card (VideoFollowUp).
- The card keeps watched / save / add to playlist and the "…": on the picture on hover (the
  duration steps aside), in the rows' action column; on touch only a 44px "…" beside the title.
  Download, hide, reset and the hover info card leave the card.
- The title is selectable: a click ending a selection neither plays nor follows the link.
- DESIGN.md: how C1 is built + the log row.
2026-09-26 01:23:44 +02:00
peter 9d7526ee57 fix(feed): a reset clears the card's resume position at once, not only its status
The optimistic layer overrode only the status, so after "Reset — clear watch progress/status" the
card kept its progress bar and its Continue button until the next refetch. Found in the E28 S3e1
UAT.
2026-09-26 01:23:44 +02:00
peter fa0b0edd63 docs(design): how B4, B9 and the viewport rule are built, and the log rows (E28 S3d4) 2026-09-26 00:39:10 +02:00
peter bfc6d199c0 fix(a11y): a video card's thumbnail link is named by the title, not the duration badge (E28 S3d4)
Its only text was the duration, so every card's first Tab stop was announced as "44:10".
2026-09-26 00:39:10 +02:00
peter 4d6e59b80a feat(design): the public watch page on the app's surfaces (B6) — the scheme backdrop, the title and actions on a glass panel, a glass Download (E28 S3d4) 2026-09-26 00:39:10 +02:00
peter 4646aae320 fix(design): the table header takes the B9 fade utility, and every size is dvh, not vh (E28 S3d4)
The table header carried an arbitrary-value copy of fade-below; a contract refuses a hand gradient of a surface colour. 100vh is the viewport with a tablet browser's toolbar retracted, so h-screen / min-h-screen / static vh hid the last rows under it; a contract holds dvh (the Plex player stage changes with S3f, where it is played on the tablet).
2026-09-26 00:39:10 +02:00
peter eddeca82d3 feat(design): the page band (B4) — one h1 per page, section headings, skip to content, filters fold on a short window (E28 S3d4)
- PageShell names the page with a visually hidden h1; a view with its own title renders it as
  PageHeading and the hidden one steps aside (contract: no hand <h1> inside the shell).
- Section captions are SectionLabel headings (h2, h3 in a dialog), ui/form Section included.
- "Skip to content" is the first Tab stop and focuses the scroller's <main>.
- Below 600px of height the band's filter row folds into "Filters · n" (Feed, Plex, server log),
  the same row shown in place.
- The band holds controls only: Downloads and Notifications lose their title cards, the Config and
  channel-discovery intros scroll with the content, the Stats tabs and the audit pager join the band,
  and the server log's toolbar sticks with the fade under it.
2026-09-26 00:39:01 +02:00
peter 69edeecc45 fix(a11y): a labelled rail row says its label and the exact unread count, not the capped badge first (E28)
With the labels on, the row's name was its text, badge first: 15 unread was heard as "9+ Alerts". The
name is now the visible label plus the count, as the icon-only row's is, and the drawn badge is hidden.
2026-09-25 23:59:20 +02:00
peter 6088e476a7 fix(design): the command palette's active row takes the menus' roving highlight, not an invisible bg-card; the Plex typeahead's hand copy of it goes (E28)
The palette row was ~1.07:1 on its glass (B3 names bg-card hovers a violation). ROVING_HIGHLIGHT is
exported from AnchoredPopover for the listboxes that are not PopoverItems; a contract holds that no file
paints the fill itself and every hand-built option row takes the recipe.
2026-09-25 23:51:39 +02:00
peter ce171fa954 fix(feed): a custom date bound applies once its year is a plausible upload year, not per typed digit (E28)
A native date field reports a complete date on every keystroke of its year (0002, 0020, 0202, 2026),
so one typed year refetched the feed four times. The field holds a draft while focused; leaving it
empty clears the bound, leaving it half-typed restores it.
2026-09-25 23:43:26 +02:00
peter 113da76c36 fix(design): a user row answers as a whole card — the hover layer under its badges and buttons, a click anywhere opens the account (E28)
The layer was the name button's own and ended at the row's first badge or button. The card now paints
the B3 layer under its content (isolate + a new z-under level), the name button's hit layer spans the
card, and the actions sit above it.
2026-09-25 23:43:26 +02:00
peter 4ef11901db fix(design): the nav rail's footer rows (language, About, Sign out) share the rail row and glyph (E28) 2026-09-25 17:21:01 +02:00
peter 29abf609f3 fix(design): the lab draws with the primitives' own classes, not a height-ladder copy; DESIGN no longer claims accent-color tints the colour well (E28) 2026-09-25 17:15:03 +02:00
peter e13d93a3ed fix(design): a toggle is pressed, never a bare aria-pressed; static labels are flat Badges with a tone, edged label spans lint-refused (E28) 2026-09-25 17:04:08 +02:00
peter b9850b1710 feat(release-notes): terse bilingual notes as data files — new/fixed only, the dialog lists the latest ten (E28) 2026-09-25 15:49:16 +02:00
peter 905a9f2856 feat(design): Fjord, a trial fifth scheme — Midnight's navy with a steel-blue accent and steel rules (E28) 2026-09-25 15:39:49 +02:00
peter 1d8b83c37c fix(plex): a sidecar subtitle in a legacy code page rendered its accents as U+FFFD
A Hungarian .srt beside the video (Windows-1250) was decoded as UTF-8 with
errors=replace: one episode served 1391 U+FFFD and no accented letter.
decode_subtitle now lets a BOM decide, keeps valid UTF-8, and otherwise
reads the legacy code page of the stream's language (cp1250 for Central
European, cp1251 for Cyrillic, else cp1252) — on the ffmpeg path too.

The cache has no eviction, so its key is versioned (sub2_): a track cached
broken is read again once instead of being served for good.
2026-09-25 15:20:31 +02:00
peter 62dbf35ea3 fix(player): the Plex bars hold while hovered, volume is the native slider, tooltips no longer re-layer the row (E28)
- F22: the control bar faded out from under a mouse parked on it; the hide timer
  now waits while the pointer is on either band and re-arms on leave (test seen
  RED by disabling the hold).
- The hand-drawn volume bar broke DESIGN.md A6 (native sliders): it had no hover,
  focus, keyboard or screen-reader state. It is the Slider primitive now; a
  pointer drag blurs it so Space and the arrows keep driving the player.
- The control tooltips are composited from mount (will-change: opacity): a fade
  that promoted one on hover re-layered its neighbours under the player's
  scale(1.25), and the whole row shimmered.
2026-09-25 14:48:45 +02:00
peter e9ccd9bc98 fix(player): the YouTube player's full window filled only its card — a blurred ancestor trapped the fixed stage (E28)
E28 S3d1 moved the modal's card from `.glass-card` (no blur) to the `.glass` panel role, whose
`backdrop-filter` makes the card the containing block of the fixed "full window" stage: F showed a
894×161 strip inside the card with a scrolling, wrapped control bar. While the stage is maximised the
card now drops its blur (`html .glass:has(> .player-stage--max)`, outranking the dark theme's rule);
measured live: the stage 1268×861 = the viewport, the blur back on exit.
2026-09-25 14:20:23 +02:00
peter cc8e0648db fix(design): the button and chip hover colours were never generated — an act: variant instead of a runtime prefix (E28)
buttonStyles/chipStyles glued the hover guard on at runtime (`${ACT}hover:text-fg`). Tailwind emits CSS
only for a class it finds whole in the source, so all 22 guarded hovers shipped with no rule: no text or
edge colour on hover for ghost/outline/danger buttons, no white layer on the player's control row, no
underline on a link button, no accent hover on token/suggest/ghost chips. The stylesheet had 0 rules
for `not-aria-disabled`.

- `@custom-variant act` (index.css): the same guard as the state layers; `:disabled` never matches an
  <a>, so one variant serves buttons and links and the per-element variant maps go.
- ui/tailwindScan.contract.test.ts: every class the primitives can emit stands whole in their source.
- a lint rule refuses a variant right after an interpolation; the `enabled:hover:` rule now points at
  `act:hover:`.
2026-09-25 14:13:51 +02:00
peter ff80cc96fd fix(design): rating and live colour tokens, the player's menus over its notices, the info overlay on a light theme (E28)
- F16: --rating (theme-scoped gold, 4.5:1 on the page and its chip), --rating-media, --live/--live-fg,
  --media-chrome replace 14 palette literals; a lint rule refuses palette classes; the theme contract
  measures them.
- The Plex player's notices drew over its open settings/tracks popovers: notices z-base, popovers z-menu.
- The player's info overlay rendered theme tokens on its always-dark panel: on a light theme the score
  (2.6:1), collection years (3.2:1) and cast initials (1.05:1) were unreadable; onDark variants now.
- The lint-rule contract tests share one ESLint with a one-off warm-up (a cold first lint took 8 s,
  past vitest's 5 s).
2026-09-25 13:55:26 +02:00
peter 295c1db5f1 fix(design): claim audit part 1b — whole-row switches, the field-family edge, state-layer hovers, icon links, reason-disabled controls, 24/44 targets (E28)
- F12 (owner: A): SwitchRow, the whole settings row toggles; a Config boolean toggles from its title
  and hint (a label pointed at the switch).
- F14 (owner: B): the form dropdown trigger wears the field frame (see-through, the >= 3:1 edge);
  labelled pills keep the faint edge and the accent-edge hover. DESIGN says so, with log rows.
- F6: ~15 bg-card / bg-border / opacity hovers onto the B3 state layer; a lint rule (HOVER_SWAP)
  refuses the swap, contract-tested.
- F10: bare 14px icon links onto IconLink / LinkButton; a local lint rule refuses a glyph-only <a>.
- F13: reason-disabled controls are aria-disabled (Users, Channels backfill, Discovery, Scheduler
  runs, Config tests); FocusSafeButton now swallows the click of a caller's aria-disabled; the
  Config tests say why while dirty (testDirty, HU/EN).
- F5/F11: every control measured live at 24 (mouse) and 44 (coarse) across every page; touch-target
  is a utility that wins over a caller's min-h-6; link variants carry it too; SegmentedToggle,
  Tabs, NavRail, menu rows, PriorityStepper, sliders, CheckRow, InnerInput, card links and the rest.
- F9: DismissibleChip's label reaches the chip's 24; the Combobox chips are DismissibleChips.
- F15: the "+N" / "More..." open state worded as it renders.
2026-09-25 03:58:17 +02:00
peter 0521149cf1 fix(design): claim audit part 1 — a closing key no longer re-presses its trigger, the Switch box and name, named z-levels (E28)
- In-place editors (Playlists heading + rail rename, Plex collection + playlist rename, PriorityStepper,
  Scheduler interval): a real Enter closed the editor, returned the focus to the trigger, and the same
  keystroke's activation pressed that trigger again - re-opening the editor, or in the rail selecting
  the renamed playlist. useReturnFocus.arm(e) now cancels the key's default (type-enforced); the rail and
  Scheduler editors do the same. The earlier tests dispatched a synthetic keydown, which has no default.
- Plex playlist rename: an unchanged or empty title closes with no write, as the collection rename does.
- Switch: a 36x24 box around the track (the 24 floor), 44 on a coarse pointer; `label` is required, and
  the Plex two-way-sync switch had no name.
- z-index: numeric z-10/z-20 and a literal 40 moved onto the named layer scale; a lint rule + contract.
- The /watch file card's Download is the filled primary: the page's one action.
- Stale comments: the 1024x768 floor, the retired glass tuner.
2026-09-25 02:25:54 +02:00
peter b4a6569d0d fix(design): the saved-view chip on one line, one Escape per layer, the /watch action row (E28 S3d)
- SelectTrigger lays its toolbar value out as a row; the saved-view chip passes its glyph and
  modified dot through the icon/trailing slots (an <svg> is display:block and broke the chip in two).
- One Escape in a dialog's open list closed the list AND the dialog: the list's close flushes
  synchronously and pops its layer before the dialog's window listener runs. takeEscape() is the one
  close-on-Escape test (topmost AND not yet taken); Modal, the Welcome lightbox and useLayer use it,
  and keymap.dispatch ignores a taken Escape.
- /watch: the Download sits on the toolbar rung, level with the Repeat chip.
2026-09-25 01:40:45 +02:00
peter 1833f594cb fix(design): an audit of the S3d claims — the float shadow, filter hints, one action size per row (E28 S3d)
- A glass role's shadow is one token (shadow-float); the header search's drop-shadow utility had
  replaced its top highlight, and the debug pill's shadow-lg did the same. The surface-role lint
  rule now refuses a shadow size beside a role.
- Plex: every value that filters carries a hint (genre, certificate, studio had none), and the facts
  panel's year and certificate filter like the header's. filterKeyword -> filterValue.
- Downloads: the row resized its buttons with a [&_button] override that the save link escaped
  (28px beside 32px); the size is the primitive's md rung now.
2026-09-25 01:16:20 +02:00
peter 1615838f33 fix(design): the rail's active edge bar was clipped by the scrolling list (E28 S3d3)
An overflow-y container clips on both axes; the bar sits in the rail's padding, outside the row.
NavRailList spans that padding (-mx-1 px-1) so the bar stays inside the list's box.
2026-09-25 00:59:23 +02:00
peter 7fa8f4ebde feat(design): the NavRail primitive with optional short labels, the flat surfaces onto roles (E28 S3d3, B7/B5) 2026-09-25 00:47:46 +02:00
peter 6354454cc9 feat(design): skeleton first loads by shape on the shared reveal clock (E28 S3d2, B8) 2026-09-25 00:37:45 +02:00
peter c47c193ad1 feat(design): glass surfaces by role, the static Badge, LinkButton, user pickers on the Combobox (E28 S3d1) 2026-09-25 00:34:35 +02:00
peter db10bfbbca feat(design): the header search is a 40 glass pill; a wrapper field dims for its input only (E28 S3c) 2026-09-25 00:05:31 +02:00
peter dac9d89f91 feat(design): the field family, the dropdown trigger, native controls, the no-raw-input lint rule (E28 S3c)
- ui/Input, Textarea, InnerInput + fieldClass (A4): the >= 3:1 edge, a 2px accent
  focus edge, the danger edge for invalid, md/sm/xs/heading rungs, 1rem and 44 on touch
- ui/SelectTrigger (A5): SortControl, FilterSelect, the view switcher and the date
  filter on one trigger, a pill in a toolbar, r8 in a form
- ui/NativeControls (A6): Checkbox, CheckRow, Slider, ColorInput
- every raw input swept (~100 sites); inputCls retired; local/input-primitive
- useReturnFocus: an in-place editor hands the focus back to its trigger on
  Enter/Escape (the playlist and Plex title renames, the priority number);
  the Plex playlist rename gains Escape and an accessible name
- back to top: a round 48 FAB (owner decision); DESIGN.md A2 and the log
2026-09-24 23:48:22 +02:00
peter 81936e9f56 feat(design): every button on its family, the chip primitive, the lint rule (E28 S3b2)
The second half of the button sweep: the remaining hand-rolled buttons in
feature components, the design lab and the components/ui composites now
stand on Button, IconButton, Pressable, Chip or DragHandle.

- Chip + DismissibleChip (filter / token / tag / suggest / ghost, 24px
  floor, 44 on touch) replace five chip geometries; FilterChipGroup
  renders Chips; overflow lanes measure a mirror painted by chipClass.
- The Plex control row is IconButton's player rung (36, 20px glyph,
  round, overlay; 44 on touch); the YT modal bar is on the sm rung with
  pressed cycle toggles and names that survive a folded label.
- New variants: IconButton ghost-accent, danger-soft; Button warn on a
  new state-warn utility. The .btn-{tone} recipes and btnCls are gone.
- Fixed on the way: membership rows (collection editor, Plex playlist
  add) announce checked/mixed; a one-letter tag chip was 21px wide;
  descriptionLinks moved out of lib/ (it renders components).
- local/button-primitive: a raw <button>/<FocusSafeButton> outside the
  control-primitive files is a lint error (contract-tested, seen red).
2026-09-24 23:03:23 +02:00
peter 86f467bd65 feat(design): the button families on the decided ladder, first half of the sweep (E28 S3b1)
Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a
pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`,
`media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for
the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and
DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held
by theme.contract.test.ts.

Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player
modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor,
Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are
153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is
why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and
the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play).
2026-09-24 05:25:55 +02:00
peter 7d480e2ad0 feat(design): Matrix toned down to moss, text on an accent fill held to --accent-fg
The lime accent was the loudest of the four (OKLCH L 0.85, C 0.21) and read as neon; the owner chose the
moss variant at Midnight's lightness with near-neutral greys, and the Matrix backdrops follow it. Fixed
white/black parts on a solid accent fill (two switch knobs, three play buttons, the Up next pill) now use
--accent-fg, and theme.contract.test.ts refuses a fixed white or black next to a solid bg-accent.
2026-09-24 04:49:07 +02:00
peter ad136c3fe8 feat(design): the scheme set, the colour contract and the token utilities (E28 S3a)
Four schemes (Midnight; Forest cyan, Slate fuchsia, Matrix lime), YouTube
and Starship retired with no data migration: normalizeTheme maps a stored
retired scheme to Midnight. A vitest contract measures every scheme x mode
(text 4.5:1, control edge 3:1, status text, the accent's 30-degree hue gap);
it caught light-mode danger at 4.43:1, now red-700. The state layers, the
strong edge, the touch target and the fades are index.css tokens and
utilities the lab now reads. Backdrops recoloured to the new accents, with
a grey overlay grain that actually breaks the banding (longest flat run
15-26px -> 4-8px). The glass tuner is retired.
2026-09-24 04:33:13 +02:00
peter 9c4a999e54 feat(design): C5 recorded, every E28 decision taken
The owner's lean Plex control set with both refinements (Back by position,
a touch double-tap for the 10 s skip) goes into DESIGN.md's Players
chapter and the log. The lab is now the reference only.
2026-09-24 04:12:19 +02:00
peter 3f5634a44f feat(design): C5 redrawn around the owner's reworked Plex controls
Stop, Restart and the 10 s skips leave the row, Back restarts or steps to
the previous episode, Next shows only when there is a next item, and the
card carries Resume + From the start. The lab asks back two points: how
Back tells restart from previous, and a touch path for the 10 s skip.
2026-09-24 04:05:09 +02:00
peter f4bc60d659 feat(design): batch C recorded, the player row open as C5 (E28 S2d)
C1-C4 as proposed: the action classification, overlay-drawer side panels,
touch-first players, dialog focus by purpose with a pinned footer. The
players were then played live at tablet size, which found the Plex
control row running off-screen below 1024; how it fits is open as C5.
CLAUDE.md now points at DESIGN.md.
2026-09-24 03:27:26 +02:00
peter 7055548260 feat(design): batch B recorded, batch C in the design lab (E28 S2c)
DESIGN.md: B1-B9 decided (compact density, the four-scheme set with the
accent-vs-status rule, state layers, the sticky band, surface per role, the
watch page, the slim NavRail with optional labels, loading by shape, the
fade edges) with their decision-log rows. The lab keeps the chosen A and B
renderings as reference on shared tokens (labTokens.ts) and adds batch C:
the action-placement classification for review, side panels on touch and
below 768, the Plex player on touch, and the dialog conventions.
2026-09-24 03:10:00 +02:00
peter 477271907e feat(design): batch A recorded, batch B in the design lab (E28 S2b)
DESIGN.md: the Controls chapter decided (A1-A6) with the decision-log rows;
density stays open as B1. The lab keeps only the chosen A renderings as
reference and adds batch B: density, the scheme set (measured accent hue
gaps and light contrast), state layers, the page-chrome model, surface per
role, the public watch page, the nav rail, the loading form and where the
header + fade edge goes.
2026-09-24 02:21:20 +02:00
peter 50923bb684 fix(build): keep JSX line-end spaces in dev and vitest
plugin-react recognises the React Compiler only by its package name, and
reactPlugin.ts passes the imported plugin, so plugin-react left Babel's
retainLines on in dev. With the compiler's re-printed JSX that trimmed the
space at the end of a text line (the privacy page read
"for example/api/feed" on :5173). Production builds never had it on.
Turn retainLines off through generatorOpts, which wins over the plugin's
top-level option, and pin it with a DOM test seen red without the fix.
2026-09-24 02:21:20 +02:00
peter d2e40dcffb feat(design): design lab + DESIGN.md skeleton (E28 S2a)
- docs/DESIGN.md: the written source of truth for the UI. Decided chapters
  (principles, device adaptation, action placement, text stays selectable,
  interaction rules, copy, anti-patterns, decision log); controls and
  surfaces are marked Pending until the owner picks from the lab.
- /design-lab (dev only): renders each open decision's variants with the
  app's real CSS, themes and font scale. Batch A = six control decisions.
- devOnlyGuard: a production build fails if anything under src/design-lab
  reaches the bundle (seen red with an unguarded import).
- Size contract: tablet portrait + landscape and up, replacing the
  1024x768 floor with its never-built "too small" message.
2026-09-24 01:34:12 +02:00
peter 58878958d8 merge: chore/e2e-password-from-store (E2E password from the secret store, no committed default)
gate / frontend (push) Successful in 4m37s
gate / backend (push) Successful in 6m29s
2026-09-23 23:05:17 +02:00
peter b47d22cf3b chore(e2e): no committed default for the E2E account password
The localdev e2e account's password was a hard-coded default in this public repo, so it
was never a credential. E2E_PASSWORD is now required; the ops wrapper exports it from
the secret store, and the seed/setup fail with a clear message when it is missing.
2026-09-23 02:35:19 +02:00
peter e804ff3b8e release: 0.95.0 (improvement/p1-upload-auth-first)
gate / frontend (push) Successful in 4m35s
gate / backend (push) Successful in 6m18s
2026-09-23 01:32:45 +02:00
peter cd6f119883 chore(release): 0.95.0
The P1 epic: the upload route reads its own body after `require_human`
resolves, so an anonymous caller can no longer make the server spool 500 MiB
before the auth check runs. Three deep-review fix rounds on top of it.
2026-09-23 01:32:32 +02:00
peter 321776480e fix(uploads): make the drift guards fail for the reasons they claim
Deep-review round 3, targeted at the surfaces round 2's fix commit changed.
Five of the eight findings are the same shape: the guards written to stop
half-done sweeps were themselves half-done.

test_upload.py's display-field guard walked only `ast.Assign`, so the one
writer with an unbounded REQUEST HEADER behind it — `spool_upload`'s
`display_name=display_text(...)` keyword — was invisible to the guard meant
to protect it. It also stopped at `app/routes/`, missing the four writers in
`downloads/` that actually construct the row, and asserted `not offenders`,
which enforces nothing once a refactor moves the writers out of its view.
It now walks assignments AND keywords across `app/`, and holds exact set
equality against a registry of all twelve write sites with each one's value.

test_body_limit.py's release guard used `any(... ast.walk(fn))`, which
accepts a `release_read_connection` that runs AFTER the await it exists to
precede — satisfying the letter of the rule while pinning a connection for
exactly the transfer it was supposed to free. Position is checked now, and a
late release is its own, louder failure. The registry keyed sites by file
basename while scanning recursively, and `admin.py`, `discover.py` and
`playlists.py` each exist both at the top level and under `plex/` — the same
entry-collapse the registry was widened to close. Keyed by relative path now.
And all three guards recognised a body read only by the `capped_` naming
convention, so a route spelling `await request.body()` inline declared no
ceiling, released no connection, and was invisible to every one of them.

`release_read_connection`'s precondition claimed the caller "must have
NOTHING PENDING, and that is checked rather than described" while checking
only UNFLUSHED ORM work. Measured: after `add`+`flush` all three collections
are empty and the rollback loses the row, and Core DML never enters them at
all — both of them this codebase's canonical write shapes. It now also asks
the transaction directly via `pg_current_xact_id_if_assigned()`.

`errors.tooLarge` was reworded to "try a smaller file", but that key answers
every route's body cap: the issue composer's over-size JSON draft got advice
about a file it does not have. Back to payload-neutral; the file-specific
sentence keeps its own key.

The upload dialog's new danger border outranked `dragOver`, so a user
replacing an over-cap file got no sign the drop zone was live — and with a
file picked the label shows its name rather than "drop it here", so the
border was the only feedback left.

Four further findings were refuted 3/3 and are not fixed: `aria-invalid` on
the drop zone (measured in Chromium — the AX node does carry it on a native
button), a bidi-override spoof through `display_text` (the served filename
already strips those categories, and the proposed filter would have broken
every ZWJ emoji), and `capped_body` not draining (the screenshot route's edge
ceiling equals its own cap, so that refusal is unreachable, and the log sink
is sent by beacon with nobody reading the answer).
2026-09-23 01:20:22 +02:00
peter 7c0647f87a fix(uploads): finish the sweeps round 1 left half-done
Deep-review round 2, on round 1's own fix commit. It found MORE than round 1
did (28 raw, against 13), and most of it was mine -- a new piece of UI attracts
findings a backend-only diff never does. The pattern in almost all of them is
the same: a principle applied to the site that prompted it and not to its
siblings.

* `display_text` reached three of the FIVE writers of a user-supplied name.
  `POST /api/downloads` and `POST /api/downloads/edit` still handed Postgres an
  unbounded string, so a 300-character name pasted into the Add-download dialog
  or the editor was a bare 500 (main.py re-raises every DataError but the NUL
  case). Four of the seven reviewers found it independently. The bound now lives
  in `models.py`, the three columns are DECLARED with it, and the guard for the
  next writer is parsed rather than remembered: every `display_name` /
  `display_uploader` assignment under `app/routes/` must go through the helper.
  That guard immediately found two more -- the Google sign-in callback writes
  the profile name into the same 255-wide column -- so it is six, not five.

* The connection release reached one of the THREE routes that read their own
  body. The screenshot route (10 MiB, from a phone) and the client log sink both
  still held a pooled connection idle-in-transaction for the length of the
  upload, which is the invariant db.py sizes the pool on. All three now call
  `db.release_read_connection`, whose precondition -- nothing pending on the
  shared session -- is CHECKED rather than described, because a whole-session
  rollback discarding a caller's staged write is a mistake this repo has already
  made once. `test_body_limit.py` holds the rule for the next such route.

* The AST drift registry keyed its sites by (module, limit expression), so a
  second route copied from the first collapsed onto the same entry and went
  uncovered -- the exact hole the registry exists to close. Keyed per call site
  now, and it carries the release requirement too.

* The dialog's own refusal was announced once and never again: two over-cap
  picks leave an identical node with identical text, and an untouched live
  region says nothing. It is keyed per pick now, the drop zone reads as invalid
  and points at the sentence, and the reason is reachable from the control
  rather than only from a one-shot announcement.

* Two tests rebuilt the blind spot they were written to remove: the dialog's cap
  test sat nowhere near the boundary (a `>` loosened to `>=` survived it, which
  is the mutation round 1 fixed on the server side), and the header-precedence
  test only covered an exact-case duplicate while a differently-cased one would
  have been COMBINED onto the wire rather than replaced. Both fixed, and the
  merge now dedupes case-insensitively because header names are.

Also: the upload dialog's unstubbed usage query was firing a real request from
jsdom in a neighbouring suite and leaking a delayed "connection lost" toast
across test boundaries; `GET /api/downloads/usage` had no test at all though the
whole client-side check hangs off it; and the comment claiming the edge 413 is
readable now says what was measured -- readable to 200 MiB over loopback, a bare
`onerror` through a proxy -- so the pre-send check is named as the mechanism and
that branch as the backstop it is.

Every test added or changed here was mutation-checked. One mutation SURVIVED and
is recorded rather than papered over: the `tooBig` term inside `submit()` cannot
be reached, because `ui/Button` swallows the activation itself, so it is marked
as belt and braces instead of being described as the guard.
2026-09-22 23:31:55 +02:00
peter efd7b217ca fix(uploads): make both caps answerable, and stop holding a connection
The epic-end deep review found three defects in P1 and four tests that did not
hold what they claimed. They land together because they share the upload route
and its test module; splitting by hunk would have produced commits that do not
individually run green.

* The edge ceiling and the route's own cap are the SAME number at the default
  configuration, so the edge always answers first: a declared Content-Length
  over the cap is refused before a byte is read, and an undeclared body trips
  the middleware on the same chunk the route would have tripped on. The route's
  numbered 422 -- and the drain written for it last week -- are reachable only
  once an admin LOWERS the knob, which is the one shape the browser UAT tested.
  Measured: headroom does not fix this, because the declared-length check fires
  first for any body big enough to matter and the drain pushes the count back
  over the edge for the rest. So the edge's own refusal is made readable
  instead: `errors.tooLarge` localizes `too_large` the way `upload_too_large`
  was already localized, and the dialog refuses an over-cap file BEFORE sending
  it, off the `upload_max_bytes` that GET /api/downloads/usage now reports.
  Measured in Chrome against the API directly: that 413 arrives as `onload`
  with a readable body in 43 ms.

* Reading the body behind the auth dependency put the whole transfer inside the
  request's database session -- the auth SELECT autobegins a transaction, so one
  of the twenty pooled connections sat `idle in transaction`, with nothing to
  time it out, for as long as the client cared to keep sending. That is the
  invariant db.py sizes the pool on and plex/playback.py records an incident
  for. The read transaction now ends before the await. Measured on the dev
  server: an 1818 ms upload shows no idle-in-transaction sample while the bytes
  move, only afterwards, when the handler actually writes.

* The display name moved into a request header, where nothing bounds its length,
  while the column stayed varchar(255) -- so a long enough name was a DataError
  500 after the whole file had already transferred. One `display_text` helper
  now serves all three writers (the metadata PATCH already truncated, the upload
  ingest did not), and the dialog stops at the same number.

* The gate invariant asked `route.dependant.body_params`, which holds only the
  route's OWN callable's parameters: a body declared in a SUB-DEPENDENCY -- the
  upload route's own idiom -- leaves it empty while FastAPI still reads the body
  before auth. It asks `route.body_field` now, which is what FastAPI itself
  consults. The AST registry of body-reading call sites had also never followed
  `capped_stream_to`, so the one route the whole invariant exists for was the
  single site the drift guard could not see.

Four tests that passed over their own mutation are fixed: the cap boundary (a
`>` loosened to `>=` survived the entire suite), the percent-decoding (every
filename the suite sends is ASCII, where quote() is the identity function), the
display-name header (no test at all), and the staging cleanup (deleting the
unlink left the suite green while every refused upload leaked its partial bytes
onto the volume). Every test added or changed here was mutation-checked.
2026-09-22 22:41:22 +02:00
peter 3d7ef9e945 fix(uploads): drain an over-cap body before refusing it
Found in the browser UAT, not by the gate. Moving the cap onto the arriving
bytes meant it fires while the client is still sending, and answering + closing
mid-upload is what XHR reports as a network failure: the upload dialog said
"the server couldn't be reached" while the access log showed a correct 422
carrying the limit. A refusal the client cannot read is an outage as far as the
user is concerned.

`capped_stream_to` now reads the remainder to nowhere before raising, so the
client finishes sending and reads the real answer -- re-measured in the
browser, which now names the limit. Nothing past the cap is written, and the
request is still bounded by bodylimit's ceiling, so the cost is bandwidth an
AUTHENTICATED caller was sending anyway. The anonymous path is untouched:
auth still resolves before a byte is read.

The regression test sits on the helper, not the route, and the reason is worth
keeping: the route-level version was written first and PASSED with the drain
deleted. httpx's ASGI transport pumps the request body whether or not the app
reads it, so "drained" and "abandoned" are indistinguishable through
TestClient. Its own mutation check is what exposed it.
2026-09-22 02:11:21 +02:00
peter 7fce7a7ce5 chore(deps): drop python-multipart -- nothing parses a multipart body now
Its own entry in requirements.txt said the download center's local-file upload
was "the only multipart endpoint in the app". Since that route reads a raw
body, the claim is false and the package is dead weight: no route declares
File/Form, and nothing calls `request.form()`.

Removed from requirements.txt and from its single pinned line in
requirements.lock. The lock is generated and normally regenerated wholesale,
but a full regeneration would bump every transitive dependency, which has no
business riding inside a security fix -- the next legitimate regeneration
reproduces exactly this state, since the package is gone from the source.

The `multipart` entry in logs.py's third-party level map goes with it: a level
for a logger that can no longer exist.
2026-09-22 01:59:09 +02:00
peter f5018bdcbc fix(uploads): read the body after authenticating, not before it
`POST /api/downloads/upload` declared `file: UploadFile = File(...)`, and
FastAPI parses and spools a declared body before the first dependency runs --
`require_human` included. An unauthenticated caller could therefore make the
process write the whole per-file allowance (500 MiB by default) to temp, once
per request. SEC-20's ceiling bounded that number but could not stop the
writing, which is why E27 parked it rather than closing it.

The route now takes no body parameter. `spool_upload` names `require_human`
first and only then streams `request.stream()` to the staging temp file, so a
stranger is refused before a byte moves -- the shape the screenshot route has
used since E16. Filename and display name travel as percent-encoded headers
(latin-1 on the wire, so a Hungarian filename needs the encoding) instead of
multipart parts.

`capped_stream_to` joins `capped_body` in routes/_common.py: the same counting
rule, written to a file descriptor rather than accumulated, because holding
500 MiB would trade a disk problem for a memory one. Its refusal is the
caller's, so the upload route keeps answering 422 `upload_too_large` with the
admin knob's own number.

With no envelope to allow for, the edge is now exactly that knob, and
MULTIPART_ENVELOPE_ALLOWANCE is gone.

The invariant stops being prose: test_body_limit.py asks FastAPI itself
(`route.dependant.body_params`) and fails the gate if any route with a raised
ceiling declares a body. A comment could not have prevented the defect it
described; that test would have.

Both new tests were seen red for the right reason -- before the fix the
anonymous probe got its 401 only after the server had read 4236 bytes of a
multipart body, and re-declaring the File parameter turns the invariant test
red naming the route and the parameter.
2026-09-22 01:59:03 +02:00
peter b82c22b888 release: 0.94.0 (improvement/e27-contract-hardening)
gate / frontend (push) Successful in 4m32s
gate / backend (push) Successful in 6m8s
2026-09-22 00:34:12 +02:00
peter f839a461e6 chore(release): 0.94.0 — framework & contract hardening 2026-09-22 00:34:06 +02:00
peter 8525666913 test: round 4 — an assertion that could not fail, and a comment that did not add up
Two findings, both in the review's own work; nothing in the product code. That is
the shift that ends the loop: 22 → 13 → 6 → 2, and the last two are a test and a
comment rather than a defect.

The "an override that is fine is still honoured" leg of the bounds-fallback test
stored `spec.max` — which, for this key, IS the env default, because the spec's
max is derived from it. So the assertion was satisfied by the fallback it existed
to rule out: mutating `_coerce` to reject EVERY int override left all three legs
green. It stores `spec.max - 1` now and asserts the value differs from the
default, so the leg can actually fail.

And the shrink test's comment claimed `3 - 5 = -2` for an expression that computes
`chips.length - visible` = `2 - 3 = -1` — naming neither operand nor the result,
in a comment that was rewritten one round earlier precisely for miscounting.
2026-09-22 00:22:30 +02:00
peter 3f24248e01 fix: round 3 — a vacuous ordering test, a clamp that was a silent rewrite, a regex that missed
Round 3 over round 2's fixes. The trend is 22 → 13 → 6, and the character has
shifted: these are mostly defects in the REVIEW's own work, not in the epic.

The ordering test I added to hold the middleware's position could not fail for
either arrangement it names. All four `@app.middleware("http")` layers — the
access log, the two header decorators AND the setup gate that must be inside —
register as the same `BaseHTTPMiddleware`, so asserting on the class passes for
the pre-move stack and for a cap moved back outside CORS. It names the layers by
their dispatch function now, which is the only thing that distinguishes them.

The `sysconfig` bounds check CLAMPED, and clamping is a silent rewrite — the thing
this module refuses to do everywhere else, where an unusable override falls back
to the env default and logs once. On the max side the difference is cosmetic; on
the min side it is a live bug. `download_upload_max_bytes` had `min=0` before E7,
and a stored 0 means "no per-file cap" to the route that reads it. Clamped to 1 it
becomes a ONE-BYTE cap: uploads dead, nothing in the log, the admin page showing
"1". It raises `OutOfRange` now and takes the existing fallback, with its own
event code.

The `capped_body` sweep grepped for an identifier as the limit argument, so a site
written `capped_body(request, 4 * 1024 * 1024)` — or with the parameter not named
`request` — was MISSED, and a miss reads as "no new sites". Parsed with `ast`
instead: every call is seen whatever it looks like, and an unfamiliar argument
shape fails the equality instead of vanishing.

Frontend: the shrink test drove the mock in a way the real hook cannot produce
(it moved `visible` in the same step as the shrink, and the comment miscounted the
tags), so it reproduced the right END STATE by accident rather than the sequence.
`visible` is now left alone across the shrink, which IS the precondition.

Ops: the baseline guard answered "did this run rewrite it?" and therefore went
green on the second run over an already-tightened, uncommitted baseline — the
exact state its own comment says marks the localdev stack stale ever after. It
asks both questions now. And `.dockerignore` joins BAKED_PATHS: it decides what
the COPY lines actually take, so it is an input to the build context exactly as
the copied trees are.
2026-09-22 00:12:30 +02:00
peter 6b86673af4 chore: untrack tsconfig.tsbuildinfo, and hold the CI baseline the way the local gate does
`frontend/tsconfig.tsbuildinfo` was swept into the previous commit by a `git add
-A`. It is a machine-local incremental artifact carrying absolute Windows paths,
no lane reads or regenerates it, and it was already stale in the commit that added
it — it does not list the contract test that same commit created. Untracked, and
`*.tsbuildinfo` ignored, because the day anything does start rewriting it a green
gate would leave a tracked file dirty: the exact hazard the basedpyright baseline
just got a guard for.

And CI ran bare basedpyright while the local lane gained that guard, so a
burn-down would have been green on the server and red on the developer's machine
over the same tree. Two gates, one tree, one answer.
2026-09-21 23:59:59 +02:00
peter d924c665f8 fix(frontend): round 2 — the popover latch reset read a stale, negative count
The reset I added guards on `hidden === 0`, and `hidden` can be NEGATIVE.
`useChipOverflowFit` keeps `visible` in state and re-decides in a layout effect,
so the render in which the chip set SHRINKS still carries the previous
measurement — delete three tags under an open "+N" and `chips.length - visible`
goes to -2, the equality never matches, and the flag survives the one window the
reset exists to close. The next re-fit brings the menu back on its own, which is
the original defect with an extra step. Clamped at all three lanes, with a test
that drives the shrink through the stale value.

Also: the Scheduler characterization banner said "the four tests below" and now
sits above six, the last two of which are genuine regression guards for the
range-refusal fix. A future mutation check would have read it, seen them go red,
and concluded the harness was broken rather than that the guard worked.
2026-09-21 23:59:59 +02:00
peter aa51644f5f fix(security): round 2 — the SEC-20 fix had four defects of its own
A second review round over the first round's fixes, which is where the remaining
real bugs usually are. All four are the same failure as the round it reviewed: a
claim stated in a comment that the code does not hold.

The middleware moved inside CORS, but the setup gate is registered LATER and is
therefore OUTSIDE it — so the comment's "the setup gate never runs for a body that
is already too large" was false the moment it was written. On an unconfigured
instance that gate opens a DB session per request, which is exactly the anonymous
pre-auth work SEC-20 exists to remove. The cap now sits between them, and the
order is asserted against `app.user_middleware` rather than described: prose about
a stack that `add_middleware` builds inside-out is how this got backwards.

The edge ceiling and the route's own cap were pinned EQUAL, but they measure
different things — the edge counts raw body bytes, the route counts the decoded
file. A file of exactly the advertised maximum was refused one layer before the
route that would have taken it. The edge now allows for the multipart envelope,
and the test asserts the relation and the gap instead of equality.

`sysconfig`'s new `max` was enforced on write only. That bound did not exist
before this epic, so a value that was legal when it was written can be sitting in
the database — served unclamped it produces exactly the drift the comment says is
impossible, with the admin page showing a number outside its own range. Int
overrides are clamped on read now, which also survives an env default being
lowered later.

And `DOWNLOAD_UPLOAD_MAX_BYTES=0` meant "no per-file cap" to the route but a hard
zero to the edge: every upload refused, including the admin page's own way back,
recoverable only by editing env and restarting. The setting takes `ge=1`.

Two test gaps from the same round: the header assertion that is the ONLY check of
the middleware's position was DB-gated and skipped on a host without Postgres, and
the headroom "ratio" the docblock calls load-bearing was held as `<=`, i.e. no
ratio at all. Both fixed, plus a sweep that every `capped_body` call site in the
routers is known and fits under its ceiling — the point of that list is the third
entry, not the two that exist today.
2026-09-21 23:59:45 +02:00
peter 378b7559e4 build(frontend): finish the React 19 move, and hold the versions that were left in prose
react-i18next was still on ^14.1.2. The E27 S1 plan named it as "the one peer that
actually needs moving — React 19 support is the 15 line", and then it did not
move. The 14 line's <Trans> reads `element.ref`, removed in React 19, and its
withTranslation types are built on the GLOBAL JSX namespace @types/react 19 no
longer declares — both latent only because this app imports neither. 15.7.4 fixes
both at the source (`c.props.ref ?? c.ref`, `React.JSX.LibraryManagedAttributes`).

`reactEngine.contract.test.ts` joins its Node and Python twins and holds what was
only a comment: the compiler's `target` against the installed React major, react
and react-dom together, the types against the runtime, and react-i18next's floor.
A stale target is not a corrupt bundle — the plugin validates the option — but it
is a silent one, since tsc never sees Babel's output.

pythonEngine gains the seventh spelling nobody held: `requirements.lock`'s own
regeneration recipe names a python image, and wheels resolve against THAT
interpreter, so a recipe left behind yields a lock that does not match the image
installing it.

The type lane now covers `alembic` too, for the reason the ruff lane was widened
to it — migrations run on every container start, so one that raises crash-loops
prod. It came back at zero new errors, so the baseline is untouched. `tests/` is
deliberately still out, and the config says why.

vitest.config's docblock claimed the React transform is "a no-op for the plain .ts
node tests". It is not: the compiler rewrites src/lib's hooks in that lane too,
which is the point of sharing one factory with the build — and worth knowing
before ruling the compiler out of a divergence.

Tests: useCardPager had none, in either the hook or its two callers, though its
page clamp moved to render in S2 — the transformation where a non-convergent
condition is a crash rather than a glitch. Four cases now, including termination
on an empty list. Combobox's and Avatar's close/failed-URL cases are relabelled
CHARACTERIZATION: both pass against the pre-sweep code, and the tests beside them
are the ones that discriminate.
2026-09-21 23:33:02 +02:00
peter 05121fbc99 fix(security): SEC-20's exemptions become raised ceilings, and the 413 is dressed
The module admitted its two opt-outs under a stated rule — "both enforce their own
limit on the bytes they actually read, which is what makes opting them out safe" —
and that rule is false for one of them. `POST /api/downloads/upload` declares
`file: UploadFile = File(...)`, so FastAPI parses and spools the whole multipart
part to a temp file BEFORE the first dependency runs, `require_human` included:
its in-handler cap measures bytes that have already landed. Measured on the
installed fastapi 0.141.1 / starlette 1.6.0 — starlette's max_part_size guards
non-file parts only, so an anonymous chunked upload was bounded by nothing. In the
shipped self-host compose that lands in a RAM-backed tmpfs.

The exposure predates SEC-20 (no route was capped before it); what this branch
added was the claim. So the list becomes a mapping of per-route CEILINGS instead
of a set of exemptions: every request now gets a finite number, the upload route's
being the same one the route itself enforces. `sysconfig`'s knob is bounded by it
so an admin cannot raise the handler's cap past the edge and start collecting 413s
from a layer the admin page never mentions — held by a test, not by prose.

Two more things the review caught in the same module:

The 413 was not byte-identical to `capped_body`'s after all. `json.dumps` puts
spaces where JSONResponse does not, and the test that promised to hold them
together compared PARSED JSON, so it could never have seen it. Built through the
same JSONResponse now, and compared as raw bytes.

And the middleware sat outside CORS and the security headers, so its 413 carried
neither — on the split-origin dev setup the browser drops it and the user gets a
generic network failure instead of the translated too_large, while the identical
refusal from capped_body one layer down renders fine. It moves to just inside
CORS: still ahead of the session, the setup gate and every auth dependency.

Also corrected: the docblock's "largest non-exempt body is 64 KiB, ~16x headroom"
(it is 512 KiB and 2x — the diagnostics sink, named eleven lines further down),
and CLAUDE.md's Request SIZE row, which forbade the one correct use of
capped_body that is actually in the tree.
2026-09-21 23:32:49 +02:00
peter bf518223e5 fix(frontend): the scheduler's two number editors stop swallowing what you typed
Both refused an out-of-range value by discarding it: the entry vanished, the box
snapped back to the server's number, and nothing said whether the save had
happened, been rejected, or been ignored. That is the house rule about honest
states, applied to a write instead of a read. They now keep the draft, mark the
field aria-invalid and name the range (HU+EN), and the Save control is disabled
rather than silently inert.

The interval editor also dropped a keyboard user's focus on every exit — Escape,
Cancel and Save all unmount the focused input — so it hands focus back to the
pencil that opened it, the pattern IssueComposer already uses.

Tests: the four interval-editor cases added in S2 are relabelled CHARACTERIZATION,
because they pass against the pre-sweep code too (the old effect was already
guarded by `editing`). The editor that genuinely lost typing to a poll is the
maintenance batch box, whose old effect had no guard at all, and it had no such
test — it does now, anchored on a value that follows the server so it cannot pass
on a page that simply has not polled yet.
2026-09-21 23:32:32 +02:00
peter 332d24a646 fix(frontend): four state defects the deep-review found in the E27 S2 sweep
Every one of them is the same shape: a value derived correctly, and a WRITE to
the thing it derives from left unattended. Deriving hides a stale value; it does
not consume it.

PlayerModal — the watch toggle inherited the previous video's auto-watch. Moving
the queue-advance sync into render was right for `currentVideoId`, but the same
block also synced `status`, and that one had an ordering constraint of its own:
useYtProgress's cleanup for the OUTGOING item can still call setWatched(true)
near the end of a clip, and cleanups run after the commit. As an effect the sync
landed last and corrected it; from render it lands first and the stray write
wins. Press Next in the final ten seconds and the NEXT video showed as watched —
one click on the toggle then sent onState(next, "new") for an item already new.
Fixed by keying the mirror to the id it describes, so a write for another item is
ignored rather than displayed, and no ordering has to be argued about at all.

ChannelsProvider — a focus-channel intent was masked by the page rather than
consumed, and nothing ever cleared it. The manager is a page chunk: it unmounts
on every navigation, and its seed effect runs again on each mount, so returning
to it re-applied a channel name from earlier in the session over whatever the
user had typed since. The consumed marker now lives in the provider, keyed on the
token — which also closes the case the old clear-on-leave effect never handled,
opening a channel page (that does not change `page` at all).

TagFilterChips + ChannelTags x2 — `open = openRaw && hidden > 0` hides the
overflow popover when the lane stops overflowing, but floating-ui only reports a
close for a real event on a mounted surface, so the raw flag stayed true and the
menu came back by itself on the next re-fit — a window resize or the text-size
slider is enough, with nobody near it. The flag is now reset in render against
the same condition that hides it.

Avatar + downloadRow — remembering a failed image BY URL fixed the recycled-row
frame but never forgot it, so a transient 429 pinned a channel to its initial for
the life of the instance, never re-requesting. The memory is now dropped once
`src` moves off it, which keeps both properties.

And playerModalHooks' last exhaustive-deps suppression claimed "nothing in the
body reads a render value" while reading five. The invariant that is actually
load-bearing is that each of them is a per-item snapshot — written down instead.

All four fixes are mutation-checked: each new test was seen red, against the
fixed line only, naming the real defect.
2026-09-21 23:32:22 +02:00
peter c5e022f056 refactor(frontend): the long tail — and eslint-suppressions.json is gone (E27 S2)
Closes the burn-down. Hook suppressions 113 -> 34, `exhaustive-deps` 45 -> 2 (the target was
<10), and every one of the 34 survivors is an inline directive NAMING the invariant it
protects. The suppressions file is deleted, because a bulk file hides the next violation
instead of answering it — and it cannot hold a warning-level rule at all.

The sprint plan's three verdicts missed the answer that turned out to be the most common one:
adjust the state DURING RENDER. React re-runs the component and discards that pass rather than
committing it, so the stale frame an effect-based reset paints does not exist. Applied to the
Plex player's up-next card, the playlist view reset, the feed's optimistic overrides, the
channel banner's retry count, the download center's tab guard, the Plex search highlight, the
comfort-pin latch, Welcome's per-view outcome and the card pager's page clamp. It is legal only
for a component's own state — PlexBrowse's language commit resets PlexProvider's filters, so
that one stays an effect and says why.

Mirrors of a prop or query became derived values or `draft ?? server`:
- `downloadRow` had ui/Avatar's defect exactly — a boolean "poster failed" that an effect reset,
  in rows recycled down a virtualized list, so one job's missing poster painted over the next
  one's for a frame. Keyed by URL now.
- Scheduler's two number editors held a mirror an effect re-synced, on a page that POLLS — the
  shape a poll can overwrite mid-edit. A draft while editing, the server's value otherwise; six
  tests where there were none, the one behavioural change (the save button stops being dirty at
  commit rather than at the echo) mutation-checked.
- SetupWizard's "no token" and WatchPage's "gone" are facts about the URL, so they are seeded and
  derived rather than pushed into state by an effect that had to commit first.

Elsewhere: PlexInfo's rating chip was a component declared during render — a new component type
every render, so React remounted the subtree and dropped the focus on its filter button. Hoisted.
Feed's undo action and PlexPlayer's session reclaim both re-entered their own useCallback, which
the checker reads as a forward reference; both go through the ref idiom this file already uses.
BootGate's dep list was load-bearing for correctness — a re-run meant `location.reload()` — so
the "already adopted THIS identity" comparison now enforces it in code, with a test that fails
without it (the existing reload tests all install their spy too late to catch it).

One correction to the batch before this: keying ChatThread's and Messages' decrypt passes on
`q.data` instead of `q.dataUpdatedAt` was wrong. react-query's structural sharing makes it safe
in production, but the component must not melt down when something hands back a fresh object per
render — and Messages.keyboard.dom.test does exactly that, so the effect span the suite. Both are
back on the fetch as the trigger with the payload behind a ref, which is honest AND loop-proof;
the replaced comment had warned about precisely this.

CLAUDE.md gains four canonical-patterns rows for the above, and its "React 18" line is now true.
2026-09-21 01:44:21 +02:00
peter 4065ac7e6a refactor(frontend): the two players — one real ordering bug avoided, five suppressions gone (E27 S2)
The interesting part of this batch is a change that was WRONG and the test suite said so.

PlayerModal synced its local watch-status mirror and `currentVideoId` from an effect on
`active.id`. Moving that into render is right — as an effect there was a frame per queue advance
showing the new episode's chrome over the previous item's watch state, with `navigated` still
true so the "you have navigated away" affordance flashed. But the same effect also advanced
`currentIdRef`, and that line must NOT move: useYtProgress's per-item cleanup persists the
OUTGOING item's position and guards it on `currentIdRef.current === id`. React runs every
cleanup before any setup, so in an effect the ref still names the item being left. Advanced to
render time the flip happens first, the checkpoint is skipped, and stepping next→prev back to an
item resumes from the frozen launch queue's stale position. "resumes an item played earlier in
the SAME session" caught it immediately (748 instead of 830); the ref write stays in its own
effect with the ordering written down.

The rest:
- PlayerModal's keyboard handler is a useEffectEvent, so the "bound once, reads live state"
  contract is enforced instead of asserted. `focusModal` is a useCallback now — it reads a ref
  and nothing else, and three hooks take it as a parameter where a fresh identity would spread.
- PlexPlayer's session reclaim called `loadSession` from inside loadSession's own useCallback,
  which the checker reads as a forward reference. Routed through a ref, the file's existing
  answer for exactly this (detachSessionRef, cancelAutoSkipRef).
- PlexPlayer's item-load effect excluded `prefs` by hand so a track pick from the player's own
  menu would not double-load the session. The two language prefs go through a ref instead —
  "latest value, not a trigger", said in code rather than in a comment.
- PlexPlayer closes its up-next card during the render that changes `id`, not a commit later.
- The one PlexPlayer directive that stays is the live session counter, whose cleanup must bump
  the CURRENT value: the rule's advice to copy it into a variable is precisely wrong there, and
  the comment now says why rather than pointing at another comment.

Suppressions 75 -> 70; exhaustive-deps 20 -> 17.
2026-09-21 00:27:15 +02:00
peter ce08fc2aec refactor(frontend): a stable useHistorySubview, and PlexBrowse can stop disabling the check (E27 S2)
useHistorySubview returned a fresh object with a fresh `open` on every render. Correct, but it
made "re-run when the sub-view moves" unwriteable: PlexBrowse had three effects that wanted
exactly that and had to disable the dependency check to get it, and a fourth that listed `sub`
honestly and therefore re-ran on EVERY parent render, saved only by a ref guard.

Fixed at the source, which is where the house rule says a principle belongs: `open` and `back`
are useCallbacks and the returned object is memoised. `url` is a module constant at every call
site (PLEX_SUB_URL, MODE_URL) and the body otherwise touches only module functions, so there is
nothing left to churn on. All three PlexBrowse dep lists are now the honest ones, and the
comment that claimed `open` was "behaviourally stable" is replaced by it actually being stable.

PlexBrowse's remaining four are escape hatches, and two of them are now ONE:
- `infoScrollRef` is written from four places, and the compiler objects to a ref being modified
  outside the effect that reads it. That objection is a conservative false positive — a scroll
  memory needs exactly that arrangement — but four bare mutations were worth concentrating
  anyway. One `rememberInfoScroll` writer now holds the "stash on the way out, reset on a fresh
  page" rule, and answers the checker once instead of three times.
- The language-commit effect cannot move into render: it resets PlexProvider's filters, and a
  render-phase setState may only touch the component's own state. Said so at the site.

Suppressions 78 -> 75; exhaustive-deps 23 -> 20.
2026-09-21 00:20:05 +02:00
peter ea6d829c29 refactor(frontend): the context providers adjust state in render, not after it (E27 S2)
Eleven suppressions across the five providers. The recurring shape here was "an effect that
undoes something when you navigate away", and every one of them ran a commit LATE — the new
page painted once while the old module's state was still live.

Adjusted during render instead (React discards that pass rather than committing it, so there
is no in-between frame):
- SavedViewsProvider reset the sticky view and closed its modal from an effect on `source`, so
  the first Plex render carried the feed's sticky id — long enough for a stale view chip. It
  also followed the active view into stickyId from a second effect; that one converges by
  construction, since preferSticky returns the sticky match when there is one.
- NavigationProvider ended a live YouTube search from an effect on `page`. Keyed on the page
  CHANGE rather than derived from it, because "ended" has to be permanent — deriving would have
  quietly brought the search back on return to the feed.
- ChannelsProvider's manager focus is now simply DERIVED from the page: it means nothing off
  the Channels page, and focusChannel sets it and navigates in one handler.

Honest deps by freezing what is genuinely mount-time:
- FeedFiltersProvider persisted a share link's filters with an empty dep list; listing `filters`
  honestly would have re-persisted on every edit and spun. The mount value is frozen into its
  own state, so all three deps are stable and the effect says what it does.
- ChannelsProvider's URL-seed layout effect and NavigationProvider's initial history stamp get
  the same treatment. The three slice setters they call were already stable useCallbacks
  (useFilterState / useSortState / useAccountPersistedState all return one).
- SavedViewsProvider reads `cfg` through a ref: it re-memoizes on every filter edit, and the
  per-source seed guard already enforces once. That also made the set-state finding go away —
  with the render value gone, the checker can see the seed for what it is.

WizardProvider keeps its one escape hatch, now inline with the invariant: auto-opening is a
one-way decision taken from server data as it arrives, and deriving it would reopen a wizard
the user had just closed.

Suppressions 89 -> 78; exhaustive-deps 27 -> 23.
2026-09-21 00:12:44 +02:00
peter 6a7ff330f6 refactor(frontend): the shared hooks in lib/ stop lying about their deps (E27 S2)
Ten more suppressions, and React 19 turns out to supply the missing primitive.

useEffectEvent (stable since React 19.2, which S1 brought in) is the canonical answer to the
"bind the listener once, but read the latest values" pattern these hooks were disabling the
checker for. Three of playerModalHooks' four escape hatches become honest dep lists: the
wheel-volume handler, the native-controls handover, and the fullscreen-change sync. The
contract is no longer prose — the compiler enforces it.

The fourth stays, deliberately, and now says why at the site: the YouTube player's lifecycle
effect must run once per video (re-running destroys and rebuilds the iframe), and YT.Player
takes its callbacks as an object literal, which an effect event may not be passed into.

Fixed, not suppressed:
- useDismiss captured onClose when the popover opened, so a caller that re-rendered with a
  different handler while it stayed open kept getting the OLD one called. The disabled dep list
  asserted the opposite ("read fresh from this run's closure"). Both the handler and the ref
  list are read through refs now; three tests, the two that matter mutation-checked.
- usePref wrapped its seed in a useCallback whose dep list had to be excused. useState calls a
  lazy initializer exactly once, so the memo was saving nothing; inlined.
- useCardPager clamped an out-of-range page from an effect. Adjusted during render instead —
  React discards that pass rather than committing it, so no out-of-range page is ever painted.
- plexPlayerHooks' auto-skip countdown reads its marker from the ref that already existed, so
  the dep list names its real inputs. That also made the set-state-in-effect finding go away:
  the progress is genuinely timer-driven, and the checker can now see that.
- useUrlMirror and useChipOverflowFit only needed their stable refs listed.

Two test-harness suppressions (a render-phase probe ref, a module-level setState handle) moved
inline with the reason they exist: both exist to observe a hook from outside without changing
what is being measured.

Suppressions 99 -> 89; exhaustive-deps 35 -> 27.
2026-09-21 00:07:12 +02:00
peter 7e1aeeb9fe refactor(frontend): honest effect deps in the app shell and the ui layer (E27 S2)
Burns the first 14 of the 113 hook suppressions. Three verdicts, applied per site:

A useEffect that should not exist (6):
- Avatar remembered "the image failed" as a boolean an effect reset on src change. The
  component is recycled across items in the virtualized feed, so after one avatar 429s the
  next item's slot committed the fallback initial over a perfectly good image and only the
  post-paint effect cleared it. The failure is now keyed BY URL; derived state cannot be stale.
- Combobox cleared the typed query from an effect on `open`. Moved into setOpenAnd, the single
  funnel every close goes through. That also stops a spurious onQueryChange("") AT MOUNT, which
  told a controlled caller its own initial query had been cleared before the user touched it.
- CommandPalette reset query + picked row from an effect. Closed now means UNMOUNTED, not
  hidden, so a fresh open starts blank because mounting already means that -- and the ranking
  memo over every channel, playlist and saved view stops running while the palette is shut.
- Pager's page box held a copy of the page that an effect re-synced; it now derives it and
  keeps a draft only while being edited.
- App's ?link= / ?oauth= / ?admin= one-shots asserted "once per mount" with an empty dep list.
  The ref guard the codebase already uses in 22 places ENFORCES it, so the honest deps can
  include `t` -- which a language switch legitimately changes, and which would otherwise have
  re-fired the toast.

Honest deps that also fix something (5):
- App's module preload was keyed on the account id, so a permission change that alters the
  module set without moving the id never re-preloaded. `preload()` is idempotent, so depending
  on `me` costs nothing.
- App's access-request nudge now re-issues on a language switch; it used to stay in the old
  language until the pending count changed.
- App's diagnostics effect: applyDiagnostics already no-ops on an unchanged effective level,
  so picking four fields out by hand was guarding nothing and missed any field added later.
- DataTable's two filter effects: a ref holds the latest onFiltersChange so commitFilters can
  be stable, which is what lets the effects name it instead of switching the check off.

Genuine escape hatches (7) moved OUT of eslint-suppressions.json into inline directives that
name the invariant at the site -- the toast handover's render-phase global, the live region's
deliberate arming tick, the docked one-slot component, the popover engine's floating-ui list
state, dnd-kit's per-drag `moved` flag, BackToTop's DOM read and the palette's debounced
announcement. A reader of the code now sees why; a JSON file no one opens does not.

Tests: Avatar (3, new file) and Pager (3) pin the derived-state frames with a layout-phase
probe -- layout effects run before any passive effect, so they read the frame the browser would
paint. Combobox (2) pins the close-clears-query contract and the no-callback-at-mount rule.
All three sets mutation-checked against the effect-based originals.

Suppressions 113 -> 99; exhaustive-deps 45 -> 35.
2026-09-20 23:57:08 +02:00
peter 7d682c90d5 build(frontend): React 18 -> 19 with the React Compiler (E27 S1)
The compiler auto-memoises, which is the point: it infers what the app's
112 `useCallback` and 48 `useMemo` call sites were hand-written for and
applies the equivalent itself. It is on repo-wide with no opt-out list —
it bails on anything it cannot prove safe, and says so through the lint
rules. Verified in the built bundle rather than assumed: 369
`react.memo_cache_sentinel` markers across 35 of the 76 chunks.

ONE transform, shared by vite.config.ts and vitest.config.ts through
reactPlugin.ts. Not two `react()` calls: if the compiler ran only in the
production build, every vitest run would exercise code the user never
receives and a compiler-induced change would ship green — the same
gate-vs-image split 0.93.2 was released to fix. The plugin is imported
rather than named by the usual string, because a string is invisible to
knip and, worse, a typo in it is a silent no-op that ships an uncompiled
bundle, while a bad import is a tsc error.

eslint-plugin-react-hooks 5 -> 7 brings the whole compiler rule set: 283
findings, triaged rather than bulk-suppressed, because the two halves
needed different answers.

  react-hooks/refs (217) is OFF. 153 of those 217 are this app's popover
  architecture, not its history: 79 are @floating-ui/react's documented
  `pop.refs.setReference` / `getReferenceProps` callback-ref setters, and
  74 more are passing the popover handle down as a prop, which is what
  components/ui's popover engine is for. Suppressing them would not help
  the next popover anyone writes. The remaining ~64 are genuine
  render-phase `ref.current = x` writes and are real, but the rule cannot
  tell them apart here; they are E27 S2's work.

  The other 66 go into eslint-suppressions.json as a ratchet — new
  violations fail the gate, and `--prune-suppressions` makes a fixed one
  fail too until it is removed. 45 of them are `set-state-in-effect`,
  which IS E27 S2's subject, so that sprint now has a measured worklist.

react-i18next deliberately stays on 14. It never declared a React-19
peer — every version from 14 to 17 asks only for `react >= 16.8.0` — and
what the newer lines actually require is i18next 25/26 against our 23.
That is an i18n runtime migration wearing a React hat, and it buys this
sprint nothing.

Two React 19 type changes: `useRef<T>(null)` is now `RefObject<T | null>`
(the honest shape — the element is null until it mounts), so
PlexPlayerSettings' prop follows; and reactPlugin.ts joins
tsconfig.node.json's include.

The compiler skips memoising VirtualGrid and reports it: TanStack
Virtual's `useVirtualizer()` returns functions that cannot be memoised
without going stale. That is the right trade for a component whose cost
is its row window, and it is the one such site in the app, so it carries
a targeted disable with the reason — a second incompatible library
anywhere else must still stop the gate.

Nine lanes green, vitest 1558. Browser UAT on the dev server against the
still-running React 18 build as a control: boot, feed (virtualized grid,
popover menu, a filter change that moved the list 195,187 -> 64 items
with the URL mirrored, so no stale memoised UI), command palette, player
modal — no console errors on any of it.
2026-09-20 23:21:08 +02:00
peter cc9fc3b544 build: pin one Python version across the app, the images and the gate
0.93.2 shipped a week's worth of confusion about which Node this repo
runs on; the Python side had the identical shape and nobody had looked.
Prod (Dockerfile), the test image (Dockerfile.test) and CI (check.yml)
each spelled 3.12 as an independent literal, there was no
`.python-version`, no `requires-python`, and ruff had no target-version
so it judged our syntax against some other interpreter. The dev machine
had no 3.12 installed at all — only 3.13 and 3.14 — so every ad-hoc host
script ran two majors ahead of production.

`.python-version` is now the single source of truth, and the contract is
held by tests rather than by prose. It is split in two because no single
runner can see every link: the pytest suite runs inside the test image,
which mounts backend/ alone and cannot read the repo-root Dockerfile or
the workflow.

  test_python_engine_contract.py : running interpreter == pyrightconfig
                                   == ruff.toml
  pythonEngine.contract.test.ts  : .python-version == pyrightconfig ==
                                   ruff.toml == Dockerfile ==
                                   Dockerfile.test == check.yml

Together they close the loop, and the backend half carries the assertion
that pays — that the interpreter running the suite is the pinned one. A
green suite on another minor does not mean the shipped image is green;
it means they have drifted and nobody has noticed yet.
2026-09-20 23:02:11 +02:00
peter c9101e3450 ci: type-check the backend — the gate's ninth lane (E27 S3)
Three tsc projects held the frontend and NOTHING held the backend: ~36k
lines of `app/`, annotated in 113 of 128 files, that no lane had ever
read. Asymmetric by accident, not by decision.

basedpyright, in basic mode. Measured against mypy on this tree with the
dependencies actually installed — without them basedpyright errors on
every import and mypy's --ignore-missing-imports quietly turns them into
Any and UNDER-reports. Both land ~190-200 errors with the same
distribution, so the choice fell to what differs: basedpyright is ~2x
faster (4.4s vs 7.4s, and this runs on every `siftlode lint`), resolves
our `app.*` imports from its own config with no MYPYPATH, and has a
first-class `--writebaseline`. That last one is the ratchet itself;
mypy would have needed a third-party tool for it.

198 pre-existing errors are baselined, so the lane fails on NEW errors
only and the list burns down epic by epic. A green lane means no new
damage, not a clean codebase.

It runs in the pytest test image rather than on the host, and that is
forced rather than chosen: `requirements.lock` cannot be installed on
Windows at all — uvloop refuses to build there — so a host venv could
only ever be the lock minus a package, and a type checker without the
real third-party packages reports nonsense rather than nothing. The
image installs the pin from requirements-dev.txt, so the lane's version
IS the pin and there is no host copy to drift. The cost is that
`siftlode lint` now needs Docker, which it did not before.
2026-09-20 23:02:02 +02:00
peter 51daf3e388 feat(security): cap every request body before auth runs (SEC-20)
FastAPI reads a declared body IN FULL before the first dependency, so
`POST /auth/password-login` drained an arbitrary upload from an anonymous
caller and only then answered 401. 70 of the 72 body-declaring routes
behaved the same way: one unauthenticated client could spend the
process's memory and time at will.

A raw ASGI middleware, not `@app.middleware("http")` — BaseHTTPMiddleware
cannot wrap `receive`, so it could only trust `Content-Length`, which a
client may omit (chunked) or understate. This refuses an oversized
declaration outright and counts the bytes of everything else as they
arrive, the same rule `capped_body` already applies per-route. Registered
directly inside the access log: a refusal is still logged with its
request id, but the session, CORS, the setup gate and every auth
dependency never run for a body that is already too large.

Two routes opt out, under one stated rule: an exemption is ONLY for a
route whose own cap is larger than the global one. That is why
`/api/diagnostics/events` is absent — it caps itself at 512 KiB, below
the ceiling, so exempting it could only widen what it accepts. The list
names route templates and a test resolves each against the live route
table, so a rename fails the gate instead of silently dropping an
exemption in prod.

The 413 is byte-identical to `capped_body`'s and the test compares the
two against each other rather than against a literal — two shapes for one
fact is how the `cancelled`/`canceled` bug happened.
2026-09-20 22:51:32 +02:00
peter 09d351a0e7 docs(ci): the two gate jobs queue, they do not run in parallel
Declared side by side, so they read as parallel; the lab runs a single
act_runner with one slot, so the backend job starts at the exact second the
frontend one ends. Measured across runs #86-#91: ~4 + ~6 = ~10 minutes from the
push, not ~6.

Written down because believing the comment cost something real — the ops
release read-back budgeted the max instead of the sum and timed out on its
first live run, reporting a still-running gate as a failing one.
2026-09-20 22:05:56 +02:00
peter b5078003ce release: 0.93.2 (bug/ci-node-version-split)
gate / frontend (push) Successful in 3m51s
gate / backend (push) Successful in 6m7s
2026-09-20 21:49:56 +02:00
peter f2c32cfb8e chore(release): 0.93.2 — a Plex amount reads the same in every browser 2026-09-20 21:49:36 +02:00
peter 1453fa4a33 ci: run the gate on branch pushes only, not on release tags
A release pushes main and then the v<ver> tag, and a bare `on: [push]` treated
the tag as a second push of the identical commit — every release ran the whole
gate twice to the same verdict (runs #81-#90 are five such pairs).

It has to be `branches`, not the `tags-ignore` this obviously wants to be:
Gitea's matchPushEvent requires every named filter to match and each one bails
on the wrong ref kind, so `tags-ignore` rejects every BRANCH push and silently
disables the gate. Measured in go-gitea v1.27.3, where it differs from GitHub.

A trigger filter that stops firing is invisible, so the ops `release` now reads
the verdict back for the sha it pushed and says so when there is no run for it.
2026-09-20 19:47:19 +02:00
peter efe106dfda build: pin one Node major across the dev, CI and build machines
The server gate ran node:22 while the dev gate ran node:24, and the two disagree about
Intl.NumberFormat's compact currency padding and about which realm's Blob a Response hands
back — so the frontend job had been red for 73 of its last 90 runs while the local gate was
green, and nothing said so.

Align everything on 24, with `.nvmrc` as the single source of truth: the CI container, the
prod build stage, `engines`, and `@types/node` (which was two majors ahead of the runtime, so
tsc was green over APIs the shipped engine does not have). The backend job's node only runs
the checkout action, so it tracks the pin as hygiene — node 20 is EOL — but deliberately
stays outside the contract.

The pin is held by a contract test that reads every one of those places AND the engine
running it, so the next drift is a red lane on its first run instead of a CI log nobody reads.

Verified on node:24-bookworm (the CI lane: 228 files / 1552 tests, typecheck, lint,
format:check, knip all exit 0) and on node:24-alpine (the prod build stage, v24.21.0, SPA
built). Each assertion of the new test was mutation-checked red.
2026-09-20 18:06:24 +02:00
peter ec63056bd7 test(api): assert the export body's brand, not which realm built its Blob
`expect(x).toBeInstanceOf(Blob)` asks which realm constructed the body, not
whether there is one. Under jsdom the ambient `Blob` is jsdom's class, while
`Response.blob()` hands back node's own on Node 22 and jsdom's on Node 24 — so
both assertions passed locally and failed on the server gate, for a reason that
says nothing about the code under test. `.text()` is the mirror image: jsdom's
Blob does not implement it, so reading the bytes back is green on 22 and red on
24.

Both now assert the brand (`[object Blob]`, agreed by both realms) plus size and
type. Mutation-checked in node:22-bookworm: dropping `{ raw: true }` from
`debugExport` still turns the test red, so the repaired assertion still catches
the defect it was written for.

core.test.ts also stops building its Response from a jsdom `Blob`: on Node 22
undici does not recognise it as a body and stringifies it, so the fixture's body
was the 13 bytes of "[object Blob]" rather than the zip header. A string body is
what the sibling test already used.
2026-09-20 16:33:26 +02:00
peter 09becb3d00 fix(plex): pin the compact currency format instead of inheriting the engine's
`{ notation: "compact", maximumFractionDigits: 1 }` leaves the MINIMUM to the
engine, and engines disagree: the same options render "$160.0M" on Node 22 and
"$160M" on Node 24 (measured 2026-09-20 in node:22-bookworm, the image the
server gate runs, against the local Node 24.17). Browsers split along the same
line, so a viewer on an older engine saw the padded form. `minimumFractionDigits:
0` makes both render "$160M"; the plain `compact` helper (vote counts) was
measured too and already agrees on both.

This is why the server gate's frontend job was red on PlexInfo since 09-17 while
the local gate was green: the two run different Node versions.
2026-09-20 16:33:26 +02:00
peter 8d2ec5e867 release: 0.93.1 (bug/preview-crashes-the-tracker)
gate / frontend (push) Failing after 4m10s
gate / backend (push) Successful in 5m50s
2026-09-20 07:55:37 +02:00
peter 0e9839e73d chore(release): 0.93.1 — the report preview works again 2026-09-20 07:51:16 +02:00
peter 05ab963951 fix(issues): the preview must not ask the tracker for a repository context
The composer's preview did not fail a call — it killed the Gitea process, and
with it the git server and the container registry. `POST /api/v1/markdown` with
`{"Mode": "gfm", "Context": "<repo>"}` makes Gitea resolve every hex-shaped word
as a commit link, and on the API's render path the commit checker it asks is nil:

  panic: runtime error: invalid memory address or nil pointer dereference
  renderhelper.(*commitChecker).IsCommitIDExisting(0x0)
   <- markup.hashCurrentPatternProcessor (html_commit.go:217)

Every report carries hex — a tab id, a request id — so this was not an edge case
but the only case: the preview answered "the tracker isn't answering" every time,
because the tracker had just died.

Measured on throwaway instances, not argued. Same body, same call:
  gfm + Context + "5645b1c0ae01"  -> 1.26.4 CRASH, 1.27.1 CRASH, 1.27.3 200
  gfm + Context, no hex           -> 200
  gfm, no Context                 -> 200, and the HTML keeps its tables, task
                                     lists and chroma-highlighted code blocks
Upstream fixed it in 1.27.2 ("fix: avoid markup render panic", #38698), but a
self-hoster runs whatever version they run, so the payload is what gets fixed.

Sending was never affected: the created issue's own page renders the same body
fine (measured) — a real repo context has a real commit checker. The only loss is
that a hash or a `#123` is not yet a link in the preview; the issue page links it.

The test pins the payload dict, because the defect is in what we send.
2026-09-20 07:51:08 +02:00
peter 8142484347 release: 0.93.0 (feature/e16-debug-issue)
gate / frontend (push) Failing after 4m11s
gate / backend (push) Successful in 5m58s
2026-09-20 05:54:26 +02:00
peter 0d91b82f86 chore(release): 0.93.0 — report a fault from inside the app
E16: a recorded debug session becomes a drafted issue with its own logs attached, sent to the private Gitea tracker or downloaded as a zip. Includes the four epic-end review rounds and the Plex player's focus trap.
2026-09-20 05:54:03 +02:00
peter 90fc86d437 fix(diagnostics): deep-review round 4 — a claim only its owner may write, and a player that keeps the Tab
Round 4 was targeted at the surfaces rounds 2 and 3 changed. 29 findings raised,
26 survived deduplication, 12 survived three adversarial readings each.

`set_delivery` renewed the claim but never checked it was still ours, while its
sibling `release_send` always did. `expire_on_commit` is off, so the token it
re-stamped came from the caller's own memory, not the row: a send that outlived
its 600 s lease came back, overwrote the take-over send's committed links with a
full-column write, and put its own dead token back. It now takes the token, reads
the row's claim with a targeted scalar (not a refresh, which would discard the
`issue_url` assigned one line earlier) and raises `SendClaimLost` rather than
writing. An issue created by a send that had already lost its claim is logged with
its number — that record is the only one that will exist.

The tracker's settings are read above the pre-call checkpoint instead of inside
`_client`. Reading them there was a SELECT one line after the commit that was
meant to end the transaction, and held it — idle, snapshot pinned — across the
repository check and the create. The comment and the module docstring said
otherwise; measuring it in pg_stat_activity is what settled it. Two more comments
were saying things the code had stopped doing: the `**state` spread does not carry
the claim (`set_delivery` strips and re-stamps it), and react-query does not
debounce the preview — the editor being unmounted is what paces it.

The composer stopped stranding the focus on `<body>`: a send that creates the
issue and then fails on an attachment unmounts the button that was just pressed,
and only the success path handed the focus on. Export no longer saves into a
refusal it does not need — the zip is the way out of a stuck send, and its
pre-save was the one step that could not survive one. The delivery banner
announces through a role instead of an `aria-live` created with its own message.

`historyState` returns a union, so `chipTone` matching on the same keys is checked
by tsc rather than by hope.

PlexPlayer was the only full-screen overlay in the tree with no focus containment
at all — Tab walked out of an opaque player into a page nobody can see. It now
carries the shared trap. Closing that hole exposed the one underneath it:
`useDismiss` registered as a layer that traps the Tab while installing no trap,
so the player stood its own down for its own menus. Measured in a real browser:
30 Tabs with the settings panel open, 9 escapes into the nav rail, and 0 after.
Escape ordering is unchanged — the panel first, then the player.

Three tests that did not hold their line: the advisory lock's call site (deleting
it left the suite green), the trail assertion whose `all()` was vacuous over an
empty filter, and a `layerStack` assertion the stand-in dialog already forced.
All eleven executable fixes mutation-checked red for the right reason; the two
comment corrections have nothing to pin.
2026-09-20 04:30:42 +02:00
peter 2fe58073e4 fix(diagnostics): deep-review round 3 — a keyboard owner that is not a Tab trap
Round 2 made the step prompt a blocking layer so that `j` and the `g` chords
would not reach the page behind it. The dialog it sits in gave up its Tab trap
because of that, while the prompt's own replacement trap covered only its own
two controls — so Tab from anywhere else in the dialog (the bar's Screenshot or
Stop, the dialog's own buttons) walked out behind the scrim. `layerStack` now
separates the two questions it had folded into one flag: a layer can own the
keyboard and still leave the Tab trap to the dialog below it. The mirror case,
a dialog opening over the prompt, falls out of the same split.

A send's claim is now a lease with an owner. Every progress report re-stamps
it, so a send slower than the lease cannot lose it while it is still running,
and only the send that took a claim can give it back. The issue_create audit
row is staged inside the send and rides the same checkpoint as `issue_url`, so
the trail cannot be missing for an issue that exists. The attachment-name
prefix has one definition, shared by the producer and both readers.

A running send is reported to the UI. A reload or a second tab used to render
it exactly like a send that STOPPED, and offered a retry the server was certain
to refuse; the composer now says the report is being sent, holds the draft, and
puts both the retry and Create issue out. The past-sessions list says the same
for the same reason — it is the list a user opens to check on a send, and both
"draft" and "half sent" describe one that died. `/preview` and the retry path
got a pace of their own, and the per-user byte cap took the user-scoped lock
that makes it a cap instead of a guess.

Verified in a real browser on localdev: Tab cycles the whole dialog with the
prompt open, the prompt still swallows the page's keys, and the issue link is
focusable where the composer puts it. All 24 regression tests mutation-checked.
2026-09-20 02:43:56 +02:00
peter bc03e82687 fix(issues): a send keeps its claim through every step, not just up to the issue
Creating the issue rebuilt the delivery state from scratch and dropped the
claim with it, so for a new issue the claim covered only the creation — a
draft, a screenshot or a second send could change the report under the uploads
and the body edit that follow, which is exactly what the claim is for. The
state is now built on what was read, and a test walks the send's checkpoints
to see the claim still held at each one.

Found by mutation-checking the round-2 tests: removing the claim's release
left them green, because for a new issue there was nothing left to release.
2026-09-20 01:03:23 +02:00
peter 3f983e6d76 fix(diagnostics): deep-review round 2 — a send that claims, not one that blocks
The send no longer holds the session's row lock while it talks to the tracker:
it claims the session, gives the lock back, and checkpoints each step. A Stop
is never held behind a send, while a draft, a screenshot or a second send are
refused at once instead of queueing. The claim ages out, so a request that
died mid-send does not strand the session.

What the composer says is what happened: an attachment that went is no longer
named as one that did not (the missing body edit is its own line), and the
issue's screenshot count comes from the server when the list cannot be read,
so a failed read no longer blocks saving the draft. The zip is fetched as a
request, so its pace is a message and not a saved error page. Screenshots of a
finished report can be removed again; only one still being sent is held.

A recording's log elevation can no longer outlive it. The step prompt keeps
the keyboard (the page's keys stay quiet) and traps Tab itself. An image
pasted into a dialog opened over the composer stays that dialog's.
2026-09-20 00:51:12 +02:00
peter 134ec9a68e fix(diagnostics): deep-review round 1 — a send that cannot race, lose or repeat itself
The send, the draft and the screenshots take the session's row lock, so an
autosave or a paste during a send waits for it and then sees it, and two sends
cannot open two issues. A save keeps the delivery's progress; a failed edit
that shows the screenshots is finished by the retry; an answer that is not the
tracker's API (a redirect, a proxy page) is refused, not a crash that loses the
issue's record. Only an attempt that opened an issue counts toward the pace.

Screenshots: deleting one after the send is refused, a user's storage is
capped across sessions, new sessions and zip exports are paced, images are
stored uncompressed in the zip. One liveness rule, one session view, one image
type table.

Logging: a session's elevation is tracked apart from the user's own, so the
browser's own requests (no header) no longer lower the floor a session needs;
Stop ends it at once.

Composer: an image pasted anywhere is taken; what is still unsaved is saved on
close, and "saved" is shown only when it is; the draft is held while it is
sent; a failed screenshot read says so instead of reading as none; focus moves
to what stays; the preview follows the text size. Pill: the step prompt and a
running Stop survive the pill moving between hosts, the prompt keeps the
dialog's Tab trap, the toast region lets clicks through, other tabs hear of a
Start or Stop, the last seconds are sent before the cap, a capture with no
frame is an error.
2026-09-20 00:09:52 +02:00
peter 9cf177b198 feat(diagnostics): send a bug report to a private Gitea issue tracker (E16 S4)
- admin config group "Bug reports": Gitea address, repository, token (secret),
  and "Check the connection" — the repository must answer, take issues and
  be PRIVATE (a report carries its reporter's log); checked on every send too
- POST /api/debug-sessions/{id}/issue sends the SAVED draft: create the issue,
  upload each file on its own request (the measured API), then show the
  screenshots inline; progress is kept in the draft, so a send that stops
  half-way keeps its issue and the retry sends only what is missing; 5 new
  issues an hour per user; audited as issue_create
- the composer: Create issue (only where a tracker is set up, naming the
  repository), a partial-send banner with retry, and a Preview — the tracker's
  own rendering in a sandboxed frame, or the zip's markdown without one
- the recorder's refusals answer in the localized family (debug_session_*,
  screenshot_*, issue(s)_*), not bare codes the UI could only call generic
- a tracker refusal logs the status only: its message can quote the title back
2026-09-19 22:48:20 +02:00
peter a5fae489e0 test: a request that raises is rolled back, as production rolls it back
The shared test session kept whatever a failing handler flushed and never
committed, so a later read in the test saw a write production loses when
get_db closes the session — and a handler whose failure path must commit
(E16: the tracker's issue link) looked right without the commit.
2026-09-19 22:48:20 +02:00
peter 8d0083d4e4 feat(diagnostics): the issue composer — draft, review, screenshots, zip export (E16 S3)
After Stop (or at the cap, or from Settings → Diagnosztika's past sessions)
one dialog drafts the issue: title, description, the steps drafted from the
session's markers and page changes (a marker without a label is named by its
time), the environment (version, browser, system, window, language). Beside
it, what goes with it: the browser and server logs — each reviewable line by
line, an unticked line is not sent — and the screenshots, removable, with
paste / drop / pick to add more.

- it saves as it goes (debug_sessions.draft, migration 0090): the rendered
  markdown in the user's language, the excluded rows, and its own fields to
  reopen as it was; a sent session is read-only
- the zip export (issue.md, client.log, server.log, screenshots) is built
  from the saved draft by app/issues/bundle.py — the one definition the Gitea
  delivery (S4) will share; .log, not .jsonl, which Gitea refuses
- the log line's level tags and clock move to components/logLineParts so the
  review list draws a level the way both log viewers do
2026-09-19 22:26:49 +02:00
peter 9f177557d2 docs(ui): the toast dock in the primitives catalog 2026-09-19 22:08:21 +02:00
peter 46fce4a767 feat(diagnostics): the debug recorder — start, the pill, steps, screenshots, stop (E16 S2)
- the start dialog is the consent: level (Részletes/Teljes), what is recorded,
  retention and the cap (now served as diagnostics.session_minutes)
- the pill docks under the toasts: elapsed time, Step (an optional label,
  its own Escape layer), Screenshot (tab capture where the browser has it),
  Stop; compact on a phone
- while recording, lib/log tags every event and every request path (req,
  beacon, uploadReq via one identityHeaders) names the session; applyDiagnostics
  raises the level for the session's run and never lowers a raised one, and
  lapses at whichever ends first
- Stop sends what is waiting before the server closes the session; the cap
  says so and opens the composer
- Settings → Diagnosztika and the command palette start/step/stop it
- the evidence leaves the log upload's own request lines out
- the step shortcut is deliberately palette-only: the keymap's D1 rule bars a
  new modifier chord, and a bare letter collides with the players'
2026-09-19 22:08:10 +02:00
peter 2d9595b0e5 feat(ui): a control can dock under the toasts and ride every toast host
The toast stack already has a host in the page and in every overlay (dialogs,
both players, the fullscreen player), the topmost one shows it, and inside an
overlay it sits above the player's control band. lib/toastDock lets one control
use exactly that placement; it renders outside the live region, so a ticking
control is not read out every second.
2026-09-19 22:08:10 +02:00
peter a7d7456279 feat(diagnostics): debug sessions — start, stop, the tag on every line, screenshots (E16 S1)
A session is one recording, capped at 60 minutes by the server. While it is
open, a request naming the caller's own session in X-Debug-Session is logged
at the session's level with every line tagged; a header naming anything else
is ignored. The browser's batches count a session as consent to its level and
keep the tag only on its own events. Screenshots are judged by their bytes,
capped in size and count, and served back to their owner only.

- debug_sessions / debug_screenshots tables, one open session per user
  (partial unique index), log_events.debug_session_id indexed
- /api/debug-sessions: start (joins a running one), stop, list, get, events,
  screenshots; /api/me reports the live session
- the hourly log job closes expired sessions and drops them with the logs
- logsink.serialize and routes._common.capped_body shared by their readers
2026-09-19 21:38:53 +02:00
peter 44a792c34f test(i18n): a 404 target the scan cannot read fails the backend-id contract
what=SOME_CONSTANT reached no locale check at all; only _common.py's own
pass-through may name the target by variable.
2026-09-19 21:38:53 +02:00
peter 3987cd4260 release: 0.92.0 (improvement/debt-burn)
gate / frontend (push) Failing after 4m30s
gate / backend (push) Successful in 5m25s
2026-09-19 19:53:37 +02:00
peter 95d9ed4aa6 chore(release): 0.92.0 2026-09-19 19:53:32 +02:00
peter 8d0063d514 fix(ui): the reader's own use of a stand-in stops the restore; the pill's name carries its cap
Round 2 of the deep review:
- A click on, or a key typed into, the focus keeper's stand-in fires no focusin, so a restore still
  polling for its list could take the focus from under the reader. A pointerdown or keydown now
  makes it the reader's own focus.
- The Jump-to pill hid its "Ctrl K" from its name (the voice command "click Ctrl K" still matched
  nothing); the key cap's tooltip now equals its name, so it is not read twice as a description.
- listNav's comment still cited DataTable's two layouts as the reason for its visibility filter.
2026-09-19 19:30:27 +02:00
peter 0ca0cac534 fix(api): a waiting reorder deals from the fresh order; the commit check sees staged writes
Round 2 of the deep review, on round 1's own fixes:
- `ORDER BY position … FOR UPDATE` sorts before it locks, so the reorder that waited got the
  fresh rows in its STALE order and dealt the pins it left out (a tab with Plex off) back to their
  old slots. It locks in id order — one global order, so two reorders cannot deadlock — and sorts
  the fresh rows itself. The race test now sends that partial list and fails unless tab B really
  blocked.
- The request check saw only writes the handler flushed: a `db.delete` left staged, or a flush
  followed by a released savepoint, passed. Staged changes count at the end of the request, and
  only the ROOT transaction's end clears it (a savepoint test drives the dependency directly).
- The `ok` test exempted every call as a serializer, hiding four domain REPORTS (YouTube sync,
  revert, push; Plex sync) without `ok`. They are spread into `{"ok": True, …}`; the exemption is a
  named list of real serializers.
2026-09-19 19:30:27 +02:00
peter cf0c55ba80 fix(a11y): names that carry what they show; the unused-key check stops covering whole files
- The palette launcher's name replaced its visible "Jump to…" (and the key cap's its "Ctrl K"):
  a voice-control "click Jump to" matched nothing (WCAG 2.5.3). Each name contains its text now.
- SegmentedToggle's `title` was only a tooltip on a non-focusable box — the live search's "how
  many per batch" hint reached no keyboard or screen-reader user. It is the group's accessible
  description, standalone and captioned alike.
- The unused-key contract let a template like `card.${status}` cover its whole namespace, which hid
  five dead keys (deleted). Dynamic keys are matched by shape (`editor.*.hint`); a bare namespace
  shape covers nothing, and the audit/quota namespaces are now held both ways by backendIds.
- CLAUDE.md's "FE control" row matches the lint rule it cites; each no-restricted-syntax entry has
  its reason directly above it.
2026-09-19 19:19:43 +02:00
peter fa4f823fc8 fix(ui): Back finds the item again past the focus keeper; nested drags stay in their list
Deep review of the debt burn, each measured in Chrome:
- Back from a sub-view whose focused control the swap removed: the focus keeper put the focus on a
  stand-in (the returning list's first item) and `restoreNavPosition`, finding the focus "already
  somewhere", gave up — Plex show → Back landed on tile 0, not the tile it was opened from. A
  stand-in the keeper placed now counts as untouched, like <body>. The keeper's walk up also stops
  at its container: a slot left with nothing focusable no longer sends the focus to the first
  control of the whole document.
- DataTable enabled both layouts' keyboard lists with the same flag, and `useListNav` reads its ref
  only when the flag changes: after a resize across `md`, the layout that mounted was never
  registered (no j/k) and the one that left stayed registered. Each is enabled with its layout.
- A Plex playlist nests blocks and each show's episodes in one DndContext; closestCenter and the
  keyboard coordinates weighed every droppable, so a movie landed "over" an episode — announced as
  a place in the other list, dropped somewhere else or nowhere. `sameListCollision` and
  `sameListKeyboardCoordinates` keep a drag among its siblings; onDragEnd's episode → block
  resolution is gone with the case.
- dnd-kit reports the item over itself in the pick-up's own tick; announced, "moved to position 1"
  replaced "picked up … use the arrow keys" before a screen reader read it.
2026-09-19 19:19:43 +02:00
peter 884060891e test: a handler that flushes a write and never commits it fails its test
Helpers flush and handlers commit — but every test request shared the test's session, so a
follow-up GET or an assertion read the flushed write and the suite stayed green over a handler
whose commit was missing (production's get_db closes the session: the write is rolled back behind
a 200). The db fixture now hands out `db.info["request"]`: the same session, recording each request
that ends normally with a flushed, uncommitted write, and failing the test on it at teardown. All
28 get_db overrides use it (a convention test keeps it so); the full suite passes under it, and
dropping the commit from add_item turns a playlist test red.
2026-09-19 19:19:30 +02:00
peter 955c8c3445 fix(api): a token hiccup is not "reconnect"; reorders and approvals are one unit each
Deep review of the debt burn:
- `refusal_reason` told the user to reconnect on ANY token-endpoint failure — a 5xx, a 429, a
  misconfigured client — while the grant stayed and the account still read as connected, with no
  connect button. Only `invalid_grant` (the one that drops the grant) means reconnect now.
- `pins.reorder` writes only the rows that change, so two overlapping reorders each skipped rows
  that matched in their own stale snapshot and interleaved into two pins on one slot. It locks the
  user's pins first; the second waits and deals from the first's result.
- Adding a whitelist entry committed the invite and the activation it implies separately, and its
  audit row was written before a new invite had an id (target NULL). One transaction, flushed for
  the id.
- The mutation convention test skipped async routes and dicts returned by name: request-access,
  purge-discovery and the subscription sync answered without `ok`. They do now, and the test
  covers both. The helper-commit check looked only at `_`-names; it reads every non-route
  function, which listed two deliberate committers (upsert_pending_invite, ensure_welcome) — now
  allowlisted with their reasons.
2026-09-19 19:19:29 +02:00
peter 99df44d7fb fix(api): mutations answer ok; route helpers flush and leave the commit to the handler
Two rows of the canonical-patterns table had drifted, found while closing the roadmap audit:

- 63 mutation responses carried no `ok` — `{"unsubscribed": true}`, `{"status": "ok"}`,
  `{"added": 3, …}` and so on. Each now leads with `"ok": true` (additive: every field a client
  reads is still there).
- Twelve route helpers committed on their own. Six were just the end of their handler's work and now
  flush: the playlist item add/remove and Watch later get-or-create, the Plex series/season bulk
  mark, and the invite decision + account activation — which an approval used to land in separate
  transactions. The other six commit on purpose (a token durable before its link is mailed, a
  single-use token burned as its own unit, the Google callback's tail, a background registration
  with its own session, the staged search ingest around YouTube calls), each listed with its reason.

`tests/test_route_conventions.py` holds both rules in code; CLAUDE.md's two rows point at it.
2026-09-19 18:21:06 +02:00
peter 30fbdc52b2 fix(ui): a focused, disabled FocusSafeButton no longer lights up on hover
A FocusSafeButton that holds the focus is disabled by aria-disabled, not the native attribute, so
every hover gated on `:disabled` / `enabled:` alone still fired on it — the glass lift, the primary /
danger / warn dim, and eight `enabled:hover:` chains (saved views, the player's list buttons, the
undo toolbar). Measured on localdev: the Pager's Next, focused at the last page, now keeps its resting
border and shadow under the pointer. The CSS selectors and the chains also exclude
`[aria-disabled="true"]`, and a lint rule refuses a bare `enabled:hover:` from now on.
2026-09-19 18:14:18 +02:00
peter 046ff735cf test: close the last coverage debt the roadmap still listed
- `overflow-clip` gets a safelist anchor in index.css (its only occurrence is a `.ts` constant),
  and the chip-lane contract keeps it there.
- PlexPlayer's Backspace ownership rule is pinned on all three branches: nothing above, a foreign
  layer above (it defers), and its own panel open under a layer (it still closes the panel).
- Removing a playlist item bumps the list's `updated_at`, like the three other item-only writes.
- Seen in a real browser on localdev: an exhausted comfort-shelf tile (an empty pinned playlist) is
  focusable, aria-disabled, and its Shift+F10 menu offers Play off and Remove from shelf.
2026-09-19 18:14:18 +02:00
peter 7afaba0547 feat(playlists): say which lists YouTube's API cannot give us
The module shows fewer lists than youtube.com, and without a word about it that read as a Siftlode
bug (the owner's ask, 2026-09-03). Owner's pick from the S7 mockups (2026-09-19): one quiet line at
the foot of the rail, "Missing a list? Why?", whose Why? opens three facts — YouTube's own Watch
later and History are closed to its API (the Watch later list here is Siftlode's own), only lists
you own sync, and an account with no YouTube channel has no playlists at all.
2026-09-19 17:58:40 +02:00
peter db8ed5a004 feat(ui): a visible way into the command palette
Ctrl/Cmd+K and the shortcut sheet had no entry point but a palette row, behind the other
undiscoverable chord. Owner's pick from the S7 mockups (2026-09-19): a key cap at the end of the
header's search field opens the palette, and a page with no search field shows a small "Jump to…"
pill with the same key cap in its place, so the entry is on every page. The cap reads the chord the
palette row binds from the keymap table (Ctrl K, or ⌘ K on a Mac) and stays out of a phone's
header, where there is no keyboard to press it on.
2026-09-19 17:58:39 +02:00
peter 0ef25a8037 feat(ui): the six native selects become the house controls
Owner's pick from the S7 mockups (2026-09-19): a short fixed set is a SegmentedToggle — the live
search's result count, the table's rows per page, a download profile's mode (on its own row, so the
three worded choices fit a phone) and audio format — and a longer set or the user's own list is a
FilterSelect: the profile's quality (seven heights) and the download dialog's profile.

A lint rule refuses a native <select> from now on (the canonical "FE control" row). The px-frozen
size rules also caught up with a fraction: `text-[9.5px]` had slipped past the integer-only pattern
in the Glass tuner, which is rem now.
2026-09-19 17:53:22 +02:00
peter 17a26894b0 fix(a11y): every sortable list speaks the user's language to a screen reader
dnd-kit's screen-reader text is hard-coded English and names an item by its id ("Draggable item
10357 was dropped", a role of "sortable"). Only the YouTube playlist replaced it; the Plex playlist,
the saved-views list and the side-panel groups read it out under a Hungarian UI — found while
checking the Plex playlist's keyboard reorder on localdev.

`ui/sortableA11y` is now the one place: `useSortableA11y` gives a DndContext its instructions and
announcements (HU/EN, `common.dnd.*`), `useSortableItem` announces the role. The YouTube playlist's
own announcements move onto it, which also fixes them: its lists reorder on drop, so reading the
dragged row's own index said "moved to position 1" at every step and "dropped at position 1"
wherever the row went. The place announced is now the slot the row would land in.

A `local/sortable-a11y` lint rule (with its contract test) refuses a DndContext without
`accessibility` and a direct `useSortable`.
2026-09-19 17:37:21 +02:00
peter 946c8f26db test(plex): drive the playlist's reorder and rollback through a hook
The Plex playlist's reorder is a dnd-kit drag, and neither of its sensors activates under jsdom,
so `commit()`'s failure path could not be reached from a test. The query, the optimistic copy and
the two writes (reorder, remove) move into `usePlexPlaylistEdits`, unchanged, and are driven
directly: a failed reorder is said and the rows go back; an accepted order lands in the detail
cache; a failed removal comes back.

It also settles the `putBack` question that could not be driven before: with the refreshed list
taken in BEFORE the reorder fails, a rollback bound to the render that started the reorder puts a
removed row back, and the deep-equal refetch never clears it. The live cache read the code has now
is what keeps it out; the test fails if it goes back to the closure's `q.data`.
2026-09-19 17:30:31 +02:00
peter 8759b5860d test(be): every dependency override goes through override_dep
22 test files set `app.dependency_overrides[...]` by hand and tore down with a blanket `.clear()`
in a `finally`, which wipes every override rather than the test's own and stops fixtures composing.
They now take the `override_dep` fixture (helpers get it passed in), whose teardown restores each
key's prior value; no clear() carried meaning between two phases of a test. A convention test fails
any test file that writes `dependency_overrides` directly.
2026-09-19 17:26:07 +02:00
peter dc902d2c0e test(fe): cover the palette's content, AltGr, the banner's name, share colours, playlist headers
- AppCommandPalette had no test: modules gated by the account, hidden channels left out, the
  playlist row selecting before it navigates, and the surprise riding setPage's `then`.
- keymap: an Alt/AltGr-modified key (Ctrl+Alt on Windows, a constant on the HU layout) fires no
  bare-key or Mod row.
- Banner: the dismiss button falls back to "Close" rather than going unnamed.
- ShareDialog: a protected link's lit lock and Revoke's danger colour stay on the icon.
- Both playlist headers are rendered now: the YouTube one needed the toolbar slot it portals into.
  Meta line sums known runtimes and treats an ABSENT duration as nothing (null never exercised the
  guard: `3000 + null` is 3000); a one-row list offers no view controls.
2026-09-19 17:23:55 +02:00
peter cedcbb1434 test(i18n): every locale key must be read by the app; 75 dead keys removed
A contract test scans the source (tests excluded) for each key as a whole literal, or under a
dynamic prefix like `plex.sort.${k}`, with plural forms counted through their base key. It found 75
keys that had outlived the code showing them (the old sidebar sort vocabulary, the old login page, a
pager the DataTable replaced, …); they are gone from EN and HU. A key read in a way the scan cannot
see goes in an explicit ALLOWED map with its reason.
2026-09-19 17:15:18 +02:00
peter 4f72b193aa fix(i18n): every label a screen reader or tooltip says is translated
- The Plex playlist rows' "reorder"/"remove", the Plex filter chip's "remove" and the player
  slider's "reset" were English literals; each now names its row or setting in HU/EN.
- The playlist show header's chevron had no accessible name; its decorative thumbnail button left
  the tab order.
- The opt-in Glass tuner panel was English only: every word is translated, and each slider's label
  now names its number box and range.
- New `local/literal-a11y-text` lint rule (with its contract test): a literal word in aria-label,
  title, placeholder, alt and the other spoken/shown attributes fails lint.
2026-09-19 17:12:47 +02:00
peter 7b94e9a651 fix(home): shelf honours the Plex gate; hero unpins its origin; palette asks for a surprise
- A13: with Plex off, the Home shelf and the hero's comfort tier go YouTube-only like the rails
  beside them (owner's call: hide the tiles). The pins are kept; GET /pins still lists them.
- Reorder deals the listed pins into the slots they already hold, so a partial list (hidden Plex
  pins, a stale tab) no longer puts two pins on one position; repeats are ignored.
- A21: a comfort hero carries `pinned_from`, the shelf entry it was resolved from; its menu acts on
  that pin, so a pinned show's next episode offers "Unpin" instead of creating a second pin.
- B12: the palette's "Meglepetés" asks for the surprise tier (the next press leading with it); the
  rotation carries on from there.
- B13: the surprise token names the history entry it was requested for (minted by setPage's new
  `then`, after the push) and is dropped on any other entry, so a navigation that never mounted
  Home cannot replay it later.
2026-09-19 17:07:11 +02:00
peter c6bf84a578 fix(home): a shelf move acts on the order shown, one write at a time
- During a budget switch the rail shows the previous budget's payload
  (keepPreviousData) while the new key is empty; the move read that empty
  cache, found no tile and silently did nothing. It now acts on the order
  the rail rendered.
- A refused move was undone only by a re-read; when that read failed too,
  the shelf kept showing the order the user had just been told was not
  saved. Moves are now written one at a time (the move rows wait, with a
  "saving" note), which makes restoring the shown order correct — the
  overlapping-moves problem that ruled a rollback out cannot occur.
2026-09-19 08:13:07 +02:00
peter 444f9b8166 fix(ui): a control removed by its own action leaves the focus in its slot
The second half of the pending-button sweep: where the pressed control does
not merely disable but LEAVES the page — an invite row gone after Approve,
the inbox's Clear all gone with the last notification, a section gone once
its last row is handled — the browser dropped the focus to <body> and the
next Tab started over at the top.

useFocusKeeper, on the page scroller: when the element holding the focus is
removed and the focus fell to <body>, the first focusable thing in its slot
takes it (what followed, else what preceded, else the nearest ancestor still
on the page). Portaled layers (menus, dialogs) sit outside the scroller and
return focus themselves; listNav's keyboard lists are left alone — their `j`
already goes on from the slot, and a row scrolled out of a virtualized list
must not drag the focus after it.
2026-09-19 08:10:03 +02:00
peter 7a47223a58 fix(ui): no button drops the keyboard focus when its own action disables it
A focused button that turns natively disabled loses the focus to <body> in
Chrome. The app disables the very button that was pressed on ~60 sites — a
mutation pending, a pager at its last page, undo with nothing left, a player
at the end of its queue, a form gone clean — so a keyboard user was thrown
out of the page on every such press.

One primitive instead of a fix per site: ui/FocusSafeButton renders a
focused-and-disabled (or busy) button as aria-disabled with a click guard,
and applies the native attribute again once the focus is elsewhere. It asks
the document at render time as well as listening for focus events, because
a window without focus fires none (measured). ui/Button and ui/form's Switch
are built on it; 57 hand-rolled buttons swapped their tag for it keeping
their classes, and three hand-copies of the rule (the channel page's Block
and Hide, ChannelDiscovery's Subscribe) now pass `busy`. The soft state's
dim look is one rule in index.css.

`local/focus-safe-disabled` refuses a new raw <button disabled>, pinned by
focusSafe.contract.test.ts. lib/plexDetailUi moved to components/: it is
components, and lib may not import the primitive.

Measured on localdev in Chrome: the audit log's Next button keeps the focus
on reaching the last page, reads aria-disabled, and the guarded click does
nothing.
2026-09-19 08:07:25 +02:00
peter bc138e3b37 fix(ui): a disabled menu row says why it is off, under its label
The reason for a disabled menu row lived in a `title` on a natively disabled
button: no pointer events, so no tooltip, and roving focus skips the row —
four translated sentences reached nobody (the Watch later rename/delete
lock, "no other list to move to", the reorder gate). An aria-disabled row was
tried and reverted in E10's review. ActionMenu items now take a `note`,
rendered as a muted line under a disabled row's label: seen, and readable in
a screen reader's browse mode. Only the icon and label dim — row-level
opacity would dim the explanation too. The reorder group states its reason
once, under its first row.
2026-09-19 07:57:20 +02:00
peter df7c3db085 test(ui): the px-box probe does not trip its own rule; tsc lib-safe last()
The pxBox contract test held the literal w-[150px] it probes, which the new
lint rule refuses, and stickyTop's test used Array.at, outside the tsconfig
lib. Both landed because a local lane check piped its exit code into tail.
2026-09-19 07:53:01 +02:00
peter 6fda49369f fix(ui): a sticky table header turns the scroller's top fade off itself; DataTable renders one layout
- "A page with a sticky ColumnHead must declare fadeTop: false" lived in
  prose: a registry flag on three pages plus an App special case for the
  Channels card layout, and the next table page would have re-acquired the
  fade-on-the-header defect with every lane green. ColumnHead now registers
  itself with the enclosing PageScroller (ui/stickyTop) while mounted; the
  registry flag, the App branch and the prop plumbing are gone. Measured on
  localdev: the top mask is 0px under Channels' and Audit's headers, 20px on
  Home and the Feed.
- ui/DataTable mounted its table AND its card list at every width, one
  hidden by CSS: double DOM and render cost, duplicate ids, two keyboard
  copies of every row. It now mounts the one the `md` media query calls for
  (lib/useMediaQuery); the keyboard test asserts one layout at each width.
- The Plex module's content width went px → rem with the rest of the page.
2026-09-19 07:51:33 +02:00
peter 8018cd4685 fix(ui): the platform layer speaks platform words; boxes scale with the text
- PriorityStepper labelled itself with channels.row.* and told every caller
  it orders the Feed; Combobox said plex.loading / plex.filter.moreRefine /
  sidebar.match on the feed sidebar. The generic strings moved to common.*,
  and what a priority MEANS arrives as the stepper's `hint` prop.
  uiI18n.contract.test.ts pins components/ui/ to the platform namespaces —
  the import boundary could never see an i18n key.
- 32 px-frozen box utilities (w-[150px], max-w-[1600px], left-[18px], …)
  became spacing-scale steps (w-37.5, max-w-400, left-4.5): identical at the
  default text size, and now they grow with the Settings slider instead of
  clipping or re-wrapping their own labels. An eslint rule refuses the px
  form beside the older text-[Npx] one, pinned by pxBox.contract.test.ts;
  measured in the browser that every new step is generated at its old px.
- chipLane's import-strip regex ran past a side-effect import into the next
  statement's `from`, hiding a lane between them; it now stops at the
  import's own `;`.
2026-09-19 07:46:33 +02:00
peter 29875b7367 fix(ui): stable shelf-card menu triggers, a named Button by type, true layout comments
- Home's rail handed each card a fresh `() => menu.triggerProps(c)` per
  render, defeating memo(VideoCard) on every tile whenever the menu's own
  state changed. `useItemTriggers` (ui/useContextMenu) keeps one trigger per
  item, index-aligned; tested for identity across renders.
- The Button accessible-name union had one hole: `<Button icon={…} />` with
  no children and no size typechecked into a nameless button. A textful
  button now needs children or a name; pinned with @ts-expect-error.
- comfortPin's header said the pin belongs "on every card and row", which
  the code never meant (an episode, a season, a person are not pin kinds).
  It now says what is pinnable — and the show page's Related strip, whose
  shows ARE pinnable, gets the pin menu it was missing (and a rem width).
- gridColumns / feedView / VirtualGrid claimed layouts 1024–2560 are
  "byte-identical" under the column cap; the small cards are capped at 2560
  with the filter rail collapsed. The comments say so.
2026-09-19 07:40:48 +02:00
peter 7ae5c57b6b fix(i18n): every refusal the API answers is structured and said in the reader's language
~150 HTTPExceptions carried a hand-written English sentence as their detail,
shown verbatim under the Hungarian UI — in the blocking error dialog and on
every inline surface that printed HttpError.detail (sign-in, setup wizard,
change password, the passphrase gate, live search). Four also quoted an
upstream exception's text back to the user.

- app/refusal.py: `refused(status, reason, **params)` → {code: "refused",
  reason, …params}; localizeDetail translates errors.refused.<reason> with the
  params interpolated. "Unknown X" 404s use the existing not_found(what).
- sysconfig.set_value raises InvalidSetting (reason + bounds) instead of an
  English ValueError; the three writers answer it through setting_refused.
- The Plex connection/write/image failures log what the upstream said and
  answer a reason; the search scraper's failure is logged and answered as
  youtube/unavailable (it crashed refusal_reason before).
- HttpError.detail now only ever holds a translated reason: a string detail
  (only the framework's own can remain) is left out, so reading .detail is
  safe everywhere — including destructured, the hole the raw-detail source
  scan could not see. That scan and its five-file exception list are gone.
- Enforced: test_http_details.py fails on any HTTPException with a sentence
  for a detail; the contract test requires every refused reason and every
  not_found "what" in both languages, and no key for one that is gone.
- local/jsx-comment-text: a // inside JSX children renders as text; the rule
  is ten lines in eslint.config.js rather than eslint-plugin-react's ~1800
  lockfile lines.
2026-09-19 07:34:17 +02:00
peter dc4e8b9fcd fix(plex): playback sessions per viewer, bounded in total; a fair share of subtitle passes
HLS sessions were keyed by the item, so a second account opening the same
film killed the first one's session; the displaced player reclaimed at its
playhead and evicted the other in turn. The registry is now keyed by
(viewer, rating_key), segment requests resolve by both, and the session
directory carries the viewer (the orphan sweep still recognises the older
shapes).

Only transcodes counted toward a cap, so remuxes — each a whole segmented
copy of the film on the scratch volume — were bounded by the library size.
Three bounds now apply after every start, each dropping the least recently
used: two sessions per account, the transcode cap, and a new admin knob,
plex_max_sessions (default 8), over every session. A superseded session
request returns at once, since it waits on the killed ffmpeg, so an account
cannot park more request workers than it may hold sessions.

The subtitle extractor's full-pass slots were global and minutes long each;
one account opening subtitles on film after film could hold them all. An
account may now hold three of the six, and still gets windows past that.
2026-09-19 07:16:17 +02:00
peter 6f9ba428d5 fix(playlists): the move's conflict fallback says why it is silent
The house guard requires a silent handler to carry a silent-ok note; the
savepoint fallback added in e74986cc did not, and the gate's pytest lane
caught it.
2026-09-19 07:10:43 +02:00
peter c60359bfed fix(plex): subtitles — a retried full pass resumes, a stalled track is polled slowly
A full pass that ran out of time kept what it reached, but its retry an hour
later started again at 0, so a file too big to read inside the extraction
timeout never completed. The pass now starts where every embedded track's
coverage from the head ends (less the window pre-roll, so a cue across the
join is read whole). The seek needs -avoid_negative_ts on this read as on a
window, measured on a real file: without it the cue running at the start
shifts the whole output.

A partial track whose full pass had failed never reached a settled state, so
the player polled it every 2 s for the rest of the film. The status now says
whether the track is still growing, and a stalled one is polled every 15 s —
still inside the 60 s lookahead that reads the next window before an edge.
2026-09-19 07:07:53 +02:00
peter e6b76e156a refactor(discover): one shelf query per kind, one RAIL_LIMIT, a true rollup comment
The comfort shelf resolved each pinned channel with three queries (the
row, its unwatched count, its newest video) and each playlist with two.
channels_summary gets the count and the newest row per channel as window
functions over one filtered set, playlists_summary reads every pinned list
in one query, and the rows come in one IN() each — so the shelf's cost no
longer grows with its length. A test counts the statements.

RAIL_LIMIT had two definitions held in step by a comment; the Plex Discover
route now imports the domain's. And rollup's "continue the latest-started
episode" was false: it takes the last in-progress one in broadcast order,
which is also what Home's next_episode picks.
2026-09-19 07:03:35 +02:00
peter e74986cc9a fix(playlists): a racing move is not a 500; ownership through owned_or_404 everywhere
move_item checked "already in the target?" and then re-parented without a
lock, so a second tab's add or the scheduler's YouTube merge committing the
same video in between hit uq_playlist_video and answered a raw 500. The
re-parent now runs in a savepoint and a conflict falls back to the
already-present outcome. The target is touched only when the row landed in
it (an untouched list was promoted in the rail's "recently changed" sort),
and _touch stamps the database clock like the mixin's own onupdate.

The canonical-patterns table names owned_or_404 against ad-hoc ownership
helpers; six were left (_own_playlist, _own_playlist_or_404, _own_view,
_own_tag, notifications._owned, downloads._own_job), two of them
hand-rolled with a bare English 404. All call sites now use owned_or_404,
and the download/link/notification 404s are structured like the rest. The
contract test reads every "what is gone" the routes name, and the YouTube
refusal reasons, and requires both languages to name each.
2026-09-19 07:01:18 +02:00
peter 2df45b27d3 fix(plex): a refused finish restores no resume point at the media's own end
The rollback of a refused watched mark guarded against Plex's REPORTED
duration. On a file shorter than its metadata the end sits outside the
finish margin, so playing to the end and having the mark refused stored the
very end as a resume point on an unwatched row: a ~99% bar that reopened in
the last seconds. The media's own ended flag now decides.
2026-09-19 06:58:11 +02:00
peter bacd6aec4f fix(plex): one scrobble per finish, and no resume push lands after it
Two near-end checkpoints arrive together routinely (the 10 s tick and the
debounced seeked flush); both read "not watched" before writing and both
scrobbled, bumping Plex's viewCount twice. The transition is now decided by
the write: an insert that did not conflict, or an update conditional on the
old status.

The finish also popped the push-ordering entry that _resume_push_superseded
reads, so an older resume push still in flight could land at Plex after the
scrobble and revive a resume point on a watched item. It now stamps the
entry instead; the existing eviction bounds the map.
2026-09-19 06:57:20 +02:00
peter fad301e7a4 fix(youtube): a refused YouTube action answers a 422 that says what to do
The two manual syncs (subscriptions import, playlist pull) had no YouTubeError
handler, so a quota, token or transport failure was a raw 500 and the blocking
"Server error" dialog (E9 F1). The other YouTube routes answered either the
upstream's English or a playlist-scoped "youtube_failed".

One helper, routes/_common.youtube_refusal: 422 {code: "youtube", reason} with
the reason sorted by what the user can do — quota (wait for the reset),
reconnect (the token step refused), unavailable (retry). Used by every route
that surfaces a YouTube failure; localizeDetail translates it.

"Reconnect" had no path: a grant Google revoked still read as granted, so
Settings and the wizard offered no connect button while every call failed.
An invalid_grant from the token endpoint now clears the stored grant.
2026-09-19 06:55:40 +02:00
peter 1cfb19bdc7 release: 0.91.0 (bug/prod-uat-2026-09-19)
gate / frontend (push) Failing after 4m0s
gate / backend (push) Successful in 5m23s
2026-09-19 05:56:19 +02:00
peter 90c30d596f chore(release): 0.91.0 2026-09-19 05:56:12 +02:00
peter 7ed6ccf171 fix(feed): the card menu's reset uses the card's own reset string
Two keys for one action had already drifted once; the menu now reads card.resetState like the
card's button.
2026-09-19 05:54:19 +02:00
peter ce30b99327 docs(nav): restoreNavPosition's doc back on restoreNavPosition 2026-09-19 05:54:19 +02:00
peter a7891d03c5 fix(sync): only a refusal about the channel spends its fallback window
A failed connection or token step answered nothing about the channel, yet stamped it: one proxy blip
during an RSS blackout delayed every due channel by a whole window. YouTubeError now names the
endpoint that answered; a failure no endpoint gave, or a spent quota, ends the round and stamps
nothing.
2026-09-19 05:54:19 +02:00
peter 038da5374f test: the boundary stays quiet while reloading for a new build; Back restores Home's whole question 2026-09-19 05:47:17 +02:00
peter 92979fa920 refactor(ui): one rule for resettable, role key and display name; an honest reset label
The card menu's reset read 'Clear watch progress' but also un-hides and un-watches; it now says so.
The resettable rule, the demo-first role key and the display-name fallback each lived in several
components tied by comments; they are shared helpers now, and the menu reuses the card's own
strings instead of duplicate keys.
2026-09-19 05:47:17 +02:00
peter 2d5bd275fa fix(diagnostics): a pivot or Back applies the search text at once
The query read the typing-debounced search, so for 300 ms after a jump it mixed the new view's
filters with the old view's text and fetched rows neither view shows. useDebounced takes a settle key
whose change applies the value immediately.
2026-09-19 05:47:16 +02:00
peter 49fe56b6e7 fix(nav): Back's focus keeps its place among items of the same name
Restoring by name picked the first item of that name, so Back on the log console (repeated
http.request lines) or two same-titled downloads landed on the wrong one. The saved place wins while
it still holds the name; otherwise the nearest item of that name.
2026-09-19 05:47:16 +02:00
peter 1fa6a0bea8 fix(sync): a failed feed-fallback read waits its window out, and spent quota ends the round
A channel whose uploads playlist also failed was never stamped, so a terminated channel was charged
again on every 20-minute poll instead of once per rss_fallback_hours. An upstream quotaExceeded now
stops the round instead of charging every remaining channel the same refusal.
2026-09-19 05:47:16 +02:00
peter 582f20fab6 fix(sync): the stored RSS warning says what the API fallback read
An INFO line outside a request never reaches log_events, so the fallback's
own summary was invisible in the Server log. Its counts ride on the
sync.rss.failed warning, the one line of a poll the console keeps.
Measured on localdev: 375 of 375 dark channels read, 403 new videos.
2026-09-19 05:05:13 +02:00
peter 6cb8231a9f fix(logs): the browser's copy of a polled read is quiet too
The server now logs polled reads at DEBUG, and the browser's copy of each
request line promises the server's level: at INFO the unread-count polls
filled My log within minutes and, at Detailed, the shipped log. The server
marks a line it logged at DEBUG with X-Log-Quiet, so the polled-route list
stays in one place.
2026-09-19 05:03:31 +02:00
peter 6bbf532b70 fix(diagnostics): count the 24 hours from the moment the level was raised
The countdown's clock was the moment Settings mounted, so a level raised
two minutes after opening the page read "24 h 2 m" until the first tick
(measured on prod). The clock is read at render; the interval only
re-renders.
2026-09-19 05:03:31 +02:00
peter 2fa5c8c394 fix(ui): nothing past the right edge of a phone
The Feed's chips + Sort + View row did not fit one line at 375px and pushed
the page 21px wide; the toast stack capped its width by the viewport while
it sits right of the sidebar. The toolbar rows (Feed, Plex browse) wrap,
and the toasts are capped by their host.
2026-09-19 04:54:18 +02:00
peter 373415a2eb feat(account): show the account its own last sign-in
Settings → Account names the latest sign-in on any device (only the
admin's user list did, though the 0.88 notes said otherwise), and the role
in the reader's language instead of the raw `user`.
2026-09-19 04:51:43 +02:00
peter 582c033e1c feat(feed): the card menu carries the card's own actions
Right-click, long-press or Shift+F10 on a feed card offered only the
comfort-shelf pin; watched, saved, hidden and the progress reset were
reachable only by hovering or Tabbing through every card.
2026-09-19 04:51:43 +02:00
peter ef652ee5d8 fix(ui): honest tab titles and a download field that names every site
Signed out, the tab read Home's question over the sign-in form; the legal
pages read just "Siftlode". The download field asked only for a YouTube
link although Facebook and the rest download too.
2026-09-19 04:51:43 +02:00
peter 250e3a9f90 fix(home): Back keeps the question, and the focus finds its card by name
Opening a card left Home, and Back came back to "Doesn't matter" with a
different hero, whose card then dropped out of the Continue rail and moved
the keyboard onto its neighbour. The budget, reroll seed and seen list live
on the history entry now, and a returning list focuses the item by its
name before its place.
2026-09-19 04:51:43 +02:00
peter 6096dd6927 fix(diagnostics): Back from a request's lines returns to the view it came from
Every filter change replaced the history entry, so Back after a request or
job pivot left the console altogether (the 0.90 notes promised otherwise).
A pivot follows a link and is its own entry now; the console reads the
landed entry back on popstate. The other filters still adjust in place.
2026-09-19 04:51:42 +02:00
peter 51e98bfc38 fix(app): reload onto a new build instead of crashing on a missing chunk
A tab left open across a release asked for a lazy chunk the server no
longer has (prod: DownloadDialog 404 an hour after 0.89) and fell to the
crash screen. `vite:preloadError` now reloads the tab once a minute at
most; a second failure is let through to the error boundary, which now
wraps every entry — the public /watch, /privacy and /terms pages crashed
to a blank page.
2026-09-19 04:51:31 +02:00
peter 3ee00fb051 fix(logs): keep polling reads and a channel-less account out of the warnings
The reads the frontend polls (unread counts, /api/me, the download index,
sync status, subtitle progress) are logged at DEBUG: at INFO they were ~95%
of the stored log on prod. A scheduler job failure and a playlist sync
failure carry event codes, and a Google account without a YouTube channel
has an empty playlist list instead of a warning every six hours.
2026-09-19 04:51:31 +02:00
peter 29094f5f51 fix(sync): read a dark RSS feed through the API instead
YouTube's RSS goes dark per channel for hours (prod 2026-09-18/19: 393 of
396 feeds answered 404 for four hours, sticky per channel, so a retry does
not help), and nothing else read a followed channel's new uploads. A
channel whose feed has not answered for `rss_fallback_hours` (admin knob,
default 2, 0 = off) now gets one page of its uploads playlist through the
Data API (1 unit), at most once per window, yielding to the backfill
reserve. Spend is attributed to its own quota action.
2026-09-19 04:51:31 +02:00
peter 16a44dec79 release: 0.90.0 (feature/e22_6-diagnostics-console)
gate / frontend (push) Failing after 4m16s
gate / backend (push) Successful in 5m19s
2026-09-19 03:57:44 +02:00
peter 4e3e68d6e6 chore(release): 0.90.0 2026-09-19 03:57:29 +02:00
peter eb29f22eb7 fix(diagnostics): say which read failed, and let Live restart
- "Load older" is not offered off a loading filter's placeholder rows,
  whose cursor belongs to the previous filter.
- A failed older page, stale rows and missing counts each get their own
  message (HU/EN).
- Live shows as off once a failed read stopped the polling; pressing it
  again reads now.
2026-09-19 03:48:01 +02:00
peter 8f6f89832f fix(diagnostics): honest live console after review
- Live stops polling on a failed read and marks the list as possibly
  stale (with Retry), instead of re-raising the error dialog every tick.
- While live, a refetch reads the newest page only, whatever the cache
  holds (a late "Load older", a revisited filter set).
- Copy is disabled and the list dimmed while a filter change loads.
- Dates follow the UI language; the pivot chip uses the shared .chip.
- Tests: query-string wire test, Copy, every facet's skip key.
2026-09-19 03:42:26 +02:00
peter 1c49870a7a fix(sync): don't try a timed-out feed again
A request that hung for the full timeout says the way out is hanging, not
that the endpoint is flaky; three of them per channel stretched a poll over
a dead egress to about three times its interval.
2026-09-19 03:42:26 +02:00
peter 142593083f feat(diagnostics): walk the log lines from the keyboard; name the console "Server log"
Both log viewers put their lines in a NavGrid: j/k (and the arrows) step
from line to line, and Enter opens one exactly as a click does (the line's
button is its `data-nav-open`). A focused line is named by its code and
message.

The console's nav entry is "Szervernapló" / "Server log", its page title:
"Diagnosztika" was already the Settings tab that holds the user's own log,
and two places with one name read as one place.
2026-09-19 02:02:45 +02:00
peter 8cfab498cf fix(sync): try a channel's feed again before counting it as failed
YouTube's feed endpoint answers the same channel with 404, 500 and 200 in
turn: on prod 182, then 296 of 396 channels failed a poll, and a probe of
24 channels saw 15 fail a first request while 16 had succeeded by the
fifth. A channel that failed once waited for the next poll, 20 minutes
later, so new uploads showed up late.

A feed is now tried up to three times (after 1 s and 2 s) on a transport
error, a 404, a 429 or a 5xx; a refusal such as 403 is not retried. The
last failure is what gets counted. A feed that is really gone costs two
more requests per poll.
2026-09-19 01:58:40 +02:00
peter b5996a0e56 feat(diagnostics): the admin log console
Admin → Diagnostics reads the server's stored log: one list, newest first,
where a line opens in place to its full record (time, source and logger,
account, request and job ids, version, message, attributes, stack). A
request or job id narrows the list to that request or job with every other
filter off, shown as a removable chip. Filters: level group, source,
account, event, period and a free-text search; all of them live in the URL
(`l*` keys, which setPage clears like the Plex and Channels keys), so a
filtered view is a link and Back restores it. "Live" re-reads the newest
page every few seconds while the tab is visible; paging into older rows
turns it off. Copy puts the loaded lines on the clipboard as JSON lines.

The facet counts now honour every filter but their own, as the feed's do:
a request pivot narrows them, and a picked level does not zero its
siblings. A value in use stays offered even when the counts drop it.

"Saját napló" and the console draw a line with one shared component,
`LogLine`, whose phone layout gives the message its own full-width line
(sharing the code's line left it a few characters). The level groups
moved to lib/log.ts for both.

The console's own reads are logged at DEBUG on success, like the
healthcheck: at INFO its live tail filled the log with itself.
2026-09-19 01:54:48 +02:00
peter 5e208353e3 feat(diagnostics): admin query API over the stored log
GET /api/admin/logs pages `log_events` newest first on a (ts, id) keyset:
a browser row's ts is clamped into its consent window, so ts alone is not
unique. Filters AND together: level, source, account (or `none`), event
code (or `none`), request id, job id, a free-text search over the
message, code, request id, stack and attributes (typed text is a literal),
and a time window. Each page names the accounts it mentions.

GET /api/admin/logs/facets counts what the filter chips offer over the
window alone, so a chip row does not shrink as filters are picked.

Admin-only and read-only; reading is not audited (the console's live tail
polls). There is deliberately no "ids after N" tail: several processes
write this table and a batch takes its ids at insert, not at commit, so a
tail keyed on ids could pass a row that commits late.
2026-09-19 01:40:46 +02:00
peter 66dbd0af81 fix(search): typed text is a literal in every LIKE pattern
The feed's channel-name match and the live search's term fold built their
LIKE pattern from the typed text as it was, so `_` and `%` acted as
wildcards: `build_log` also matched `buildXlog`. The term fold also tested
a substring, so a term already present inside a longer word (`cat` in
`category`) was never folded into search_terms. It now compares whole
terms, escaped.

`like_escape` moves from the Plex query module to `app.utils`, beside
`to_tsquery_str`, and every pattern built from typed text goes through it.
2026-09-19 01:33:32 +02:00
peter 59fd068711 release: 0.89.0 (improvement/e22_5-backend-logging)
gate / frontend (push) Failing after 3m28s
gate / backend (push) Successful in 5m1s
2026-09-19 00:05:53 +02:00
peter 8e12eee996 chore(release): 0.89.0 2026-09-19 00:05:47 +02:00
peter 89c77ae6b5 fix(logs): deep-review round 3 — a failure nobody could record is one scrubbed line
- The download and edit failure lines go back after their DB write: a job the
  write could not fail is not a failed job, and the worker's child_exit line
  already says it failed.
- The download child's runner is the last stop before Python's hook: an
  escaping exception is one download.child.failed line with its chain scrubbed,
  then exit 1, instead of a raw stderr traceback carrying the URL.
- A crash the database will not record carries its stack once, and the
  fail-write line has an event code.
- The job's own generic-extractor name is taken out as a literal (formats.hiding),
  so brackets and colons in a direct link's file name no longer leak; the
  pattern stays as the fallback. `Downloading just the video <id>` is covered.
- Tests: scrubbed_stack's cause, suppress, unprintable and text-format paths,
  the image steps' failure kinds, the watchdog error through the real
  _fail_running, exact poster/storyboard messages, ffmpeg skip marker.
2026-09-18 23:44:42 +02:00
peter 0b672ed6e7 fix(logs): deep-review round 2 — a stack names code, not data, and survives a failed write
- A failure keeps its traceback unless it is yt-dlp's or our own StatedFailure (a
  message that is the whole story). A plain RuntimeError, RecursionError or OSError
  is also what a bug of ours raises, and its stack is the only lead.
- The stack is scrubbed link by link (logs.scrubbed_stack): a chained DownloadError
  under a DB error no longer prints yt-dlp's text unscrubbed.
- Engine hide_parameters: a DB error's message no longer carries the title or path
  it was writing.
- The download and edit failure lines go out even when their DB write fails.
- The worker's child_exit/crash/supervisor lines say download.job.failed only when
  the job failed there; a crash the DB will not record no longer ends the thread.
  Stopping a paused child is download.child.stopped, not a second abort.
- log_safe also covers generic ids with spaces, playlist titles and bare ids.
- The ffmpeg image steps log the failure kind, not the argv with its path.
- The consent contract accepts only the body of a plain consented_trace() check,
  and any level that may be TRACE.
- Tests for each; every one mutation-checked.
2026-09-18 22:50:37 +02:00
peter 54d19cc386 fix(logs): deep-review round 1 — say each failure once, and scrub it everywhere
- yt-dlp's text is scrubbed wherever it is logged, not only in the adapter:
  `formats.log_safe` takes out the URL, the `[extractor] <id>:` prefix and
  file paths, and the job-failed, refused, edit-failed and backfill lines use
  it. Done lines log the asset id, not the title-built path. The PII contract
  now bans `rel`/`rel_path`/`title`.
- A traceback goes with a download/edit failure only when it is our own code
  failing; for yt-dlp's DownloadError, our RuntimeErrors and OSErrors its foot
  is the unscrubbed message.
- yt-dlp's `error` maps to WARNING: it fires on attempts the fallback client
  set recovers from; the job's verdict is `download.job.failed`.
- The sink groups a bare `%s` pass-through by its text (numbers folded), and
  a process's last batch closes the repeat window, so a short-lived download
  child still reports what it dropped.
- Worker-side failures (stall, wall-clock, no asset, cached asset error) log
  `download.job.failed` with a cause; they left no line at all before. The
  crash, supervisor-error and non-zero-exit lines carry the code too.
- An edit failure is one coded line in `_fail_edit`, for every path.
- `logs.get_logger("__main__")` resolves to the `-m` module, so the worker
  and the download child keep their names.
- TRACE lines carrying user content are gated on `logs.consented_trace()`,
  not on the process level; a contract test pins it.
- The OAuth token refresh goes through `_upstream_failure`; transport errors
  carry reason `transport`. Subscribe recognises a duplicate by reason code
  only (the prose fallback was dead).
- Once-only guards for a stale sysconfig override and a refusing HLS session
  directory; item_detail splits Plex failures from our own parse bugs.
- The playlist breaker line names the playlist and the action; comments that
  were false (runner noqa, `_merge_vtt`, sink grouping) are corrected.
2026-09-18 03:16:15 +02:00
peter 04b58e5c6f feat(logs): give the log the events that no request line already carries
The gap this sprint was scoped against turned out to be smaller than the survey
said, and in a way worth writing down: `log_requests` already records every HTTP
call with its route template, status, duration, request id and user id, and the
audit log already records every admin act on someone else's account. Putting a
line in each of routes/downloads.py's twenty mutating endpoints would have added
a second copy of the first and nothing else. So this instruments what neither of
them can see.

yt-dlp now explains itself to our log. `quiet: True` was doing more than quieting:
with no `logger` its whole account of a download went to the child's stdout, so
"Requested format is not available", a signature-extraction failure or a POT
refusal reached us only as whatever text ended up in the raised exception — the
real reason a download failed was routinely one line the app never kept. Measured
against the pinned yt-dlp 2026.07.04 before wiring it: with `logger` set it calls
`debug` for extraction and progress lines and `warning`/`error` for the rest, and
neither `quiet` nor `no_warnings` gates those calls. `debug` lands at TRACE, being
several lines a second, so the noisy half is the half the user's own consent
gates. The full source URL is trimmed to its host on the way in — it is a row of
someone's download history, and `logs.set_job` already puts the job id on every
one of these lines. The option is set AFTER the adapter merge, like `cookiefile`
and the size cap: a source adapter must not be able to take away the one account
of what went wrong that a failed job leaves behind.

An HLS session now says which pipeline it got (transcode/remux, hw, tonemap,
variants, offset) — everything downstream is about that choice, and it was only
inferable from an ffmpeg command line nobody keeps. A session culled by the
concurrent-transcode cap says so: somebody's playback stops there, the route turns
it into a retryable 404, and "my film keeps rebuffering" had no other explanation
anywhere. The HW→SW fallback keeps its line and gains an event code.

A live search refused by a quota cap says WHICH cap: the 429 is in the request
line either way, but the per-user daily cap is answered by tomorrow and the shared
budget by the admin.

The download and edit job lifecycle lines gain event codes, which is what E22.6's
console filters on — the job id was already a structured field.

tests/test_ytdlp_logging.py; three of its four cases mutation-checked. A fourth
(a `%` in a progress line) survived its mutation — stdlib only applies `%`
formatting to a record that has args — so it is gone rather than kept as a case
nothing can break; the reasoning moved into the comment on `_emit`.
2026-09-18 01:28:52 +02:00
peter 6b0401b922 fix(logs): rule on every swallowed exception in the backend
114 exception handlers neither logged nor re-raised. The defect was never that
they are silent — plenty of them should be — it is that their silence said
nothing, so nobody could tell a decision from an oversight. Each one has now been
read. 44 got a line; the other 70 carry `# silent-ok: <why>`.

What the silent ones had been hiding, in the cases that got a line:
- A failed ffprobe sent every HDR file down the 8-bit pipeline, so playback
  degraded to a software transcode with nothing saying why; a failed audio probe
  collapsed a multi-audio file to one track and the player showed no language
  picker; a failed first-PTS probe left subtitles seconds out of sync.
- A subtitle sidecar that existed but would not parse made a PARTIAL track read as
  complete, permanently — the viewer loses every cue past the cached window.
- An unreadable crop/trim/cut-list in an edit spec was dropped, so the clip came
  out uncropped, untrimmed, or shorter than the one the user built.
- A dropped `max_height` downloaded at best quality against an explicit cap.
- A failed videos.list enrich made every candidate undroppable-from-judgement and
  therefore dropped: a working search rendered as "no results".
- An unusable password hash answered as "wrong password", locking the owner out
  and telling them they mistyped; a rotated TOKEN_ENCRYPTION_KEY turned every
  stored token into "not connected".
- A stored admin setting that would not coerce fell back to the env default while
  the settings page went on showing the saved value.
- A one-off InnerTube config failure pinned the whole process to the hard-coded
  fallback key, and searches return nothing when that goes stale.
- The HLS sweep reported 0 swept — like a clean run — when it could not list its
  own root, leaving every crashed session's segments on disk forever.

Four of them are counted rather than logged per item (dropped cues, dropped cut
list segments, unreclaimed GC files) and reported once per call, because the
alternative is one line per cue over a whole subtitle track.

ruff gains S110 and BLE001. Every blind `except Exception` that remains carries
`# noqa: BLE001 — <why>`, which is the point: catching everything is sometimes
right, and always worth a sentence. plex/sync.py's three best-effort backfill
catches now keep the traceback — a blind catch over a batch that fetches AND
writes could be Plex, the database or our own bug, and `%s` of the exception told
them apart badly. main.py's boot-time log-level read likewise.

logs.event's `code` is positional-only now: `event("x", code=...)` was a TypeError,
and `code` is exactly the word a caller reaches for when the thing that failed has
one of its own.

tests/test_swallowed_exceptions.py is the wider rule ruff cannot see: a handler
that neither logs nor re-raises must say why. Both cases mutation-checked — a
marker removed and a log line removed each fail it, naming the handler.
2026-09-18 01:20:42 +02:00
peter 0cff964ff2 refactor(logs): name every logger after its module, and hold it there
25 literal names covered 34 modules. plex/client.py, plex/stream.py,
plex/subtitles.py, plex/sync.py and two Plex route modules all called themselves
`siftlode.plex`; downloads/pipeline.py called itself `siftlode.worker`;
routes/channels.py called itself `siftlode.api`. The `logger` column is in every
stored row so E22.6's console can filter on it, and so an operator can pin a level
to one part of the system — neither works when six modules share a name.

`logs.get_logger(__name__)` derives it: app/plex/sync.py -> siftlode.plex.sync.
One name per module, and it cannot drift from the file it sits in.
tests/test_logger_names.py pins the derivation, that everything stays under the
one `siftlode` tree the handler and the tests attach to, and that no module
hand-rolls a name again unless the line says why (`# logger-name-ok: <why>`).
Two do: get_logger's own call, and `siftlode.http`, which is named for the traffic
it carries rather than the module it lives in — it replaced uvicorn's access log
and is what an operator pins when they want request lines and nothing else.

app/scheduler.py called its logger `logger`; every other module calls it `log`.

ruff gains LOG, G, T20, TRY400, TRY401 and S112. Four findings cleared with them:
worker.py logged `str(exc)[:120]` where the traceback was the whole question, and
three prints/f-strings that are deliberate now say so on the line. S110 and BLE001
stay off until S3 — 23 handlers are outstanding and each is a judgement about one
of them; enabling the rules first would just mean 23 blanket noqas.
2026-09-18 00:57:57 +02:00
peter dc221338be fix(logs): keep what a person typed out of the backend log lines
The E22 redactor is a safety net over addresses and credential params, and every
one of these call sites was leaning on it to do the whole job. What it does not
cover went in whole, and what it does cover came out as a constant.

Not covered, so a real leak:
- routes/search.py logged the raw search term at ERROR. The privacy policy shipped
  in 0.88.0 says a search string is kept only at Teljes/TRACE, with the user's own
  consent. The ERROR line now carries the shape of the failure (source, candidate
  and collected counts) and the term follows on a TRACE line, which nothing emits
  unless that consent is in force.
- downloads/backfill.py logged the full source URL at INFO — a row of the user's
  download history. Now the asset id and the host.
- youtube/client.py logged the upstream error body, which is prose that quotes the
  request back. Every failing YouTube call now goes through one _upstream_failure()
  that logs the API's `reason` code, keeps the body for the same consented TRACE
  line, and raises a YouTubeError carrying `reason` as a field — callers put that
  error in log lines of their own (routes/channels.py) and in a 422 answered to the
  user, so the body must not travel in it either.

Covered, so merely useless: routes/auth.py named the account by an address that
reached both sinks as `[email]`, and app/email.py printed recipient lists that read
as `['[email]']`. Both now carry the uid (or a count) and a reason code, which is
also the first half of E22.5 S4's failed-sign-in coverage.

routes/channels.py branched on the text of that error message to recognise an
already-existing subscription. It now branches on `exc.reason`, with the substring
check kept beside it for a body that carried no reason at all.

tests/test_log_pii.py pins the call sites and the family: a source-level contract
test walks every log call in app/ and fails on an argument naming user-typed
content unless the line says why it is safe (`# log-pii-ok: <why>`). Eight sites
carry that marker today, each with its reason. Gate 8/8 green; all five cases
mutation-checked.
2026-09-18 00:49:43 +02:00
peter 8c10e57208 release: 0.88.0 (feature/e22-activity-trail)
gate / frontend (push) Failing after 3m31s
gate / backend (push) Successful in 4m44s
2026-09-18 00:05:50 +02:00
peter dd040adc96 chore: 0.88.0 release notes
E22 (activity trail): a consented, self-expiring diagnostics level with the user's own log visible
before anything is sent, last sign-in, bilingual privacy policy and terms, an admin diagnostics
section, and pickers for every closed-set setting. Plus what the epic's measurements turned up on
the way: doubled chat delivery, Settings on a phone, locale-ignoring dates and numbers, and the
feed poll narrowed to followed channels.
2026-09-18 00:05:46 +02:00
peter 4ab128ccab Merge branch 'main' into feature/e22-activity-trail 2026-09-18 00:02:12 +02:00
peter a28193a00c test: pin the focus restore, the choice labels and the phone layout to their fix
Three round-1 fixes whose tests passed against the broken version too.

The focus restore keeps watching until the element that closed the box is GONE, rather than spending
itself on the first render after the change — the distinction Chrome exposed and jsdom did not,
because jsdom commits the save in one render where Chrome takes two. Driven now through the general
case instead: the user moved the focus elsewhere while the request was out, renders keep coming
while that element is still there, and the restore happens only once it disappears.

The choice test's i18n mock returned an option's bare value, which is exactly what dropping the
`config.choices.*` lookup renders — a Hungarian admin would have seen `on`/`off` instead of
`GPU`/`Szoftver` with the gate green. Marked labels now, still short enough not to move the
segmented-bar budget the same file measures.

The admin config row's stacking had no test at all, only its sibling fix did — and stacking is the
only fix for a text or number key, whose fixed-width input is the wide control. Class contract, as
SearchBar.dom.test.tsx already does for the narrow-header rules, since jsdom does no layout.

Also: the time-zone cleanup deleted TZ, and Node re-reads it on assignment only, so the process kept
the last zone the file set. Harmless until someone appends a test below it.
2026-09-17 23:45:41 +02:00
peter 986bc8d01e fix: a batch the user themselves cancelled is not called a lapse
Picking "Alap" revokes the consent, and a batch still in flight is then answered by the server with
the same 409 a real expiry gets. The 409 arm only checked that the consent session had not changed,
not that this tab was still streaming — so the user's own log gained a "diagnostics level lapsed"
line for a level they had just turned off themselves, plus a pointless `me` refetch. The same guard
also stops a lapse this tab has already announced being announced a second time.

Measured in Chrome by holding the POST at the network layer, switching to Alap and only then
answering 409: the line appears without the guard and not with it.

Three tests that named the session counter never reached it — they settled the refusal while the
stream was already stopped, where `unsubscribe` does the work, so the counter could be deleted from
both arms with the suite green. The refusal now settles after the re-raise, which is the ordering it
exists for. Same for the queue trim on the 429 path: the old test unshifted a batch into an empty
queue, so the trim was a no-op and only the subscribe path was ever measured.
2026-09-17 23:45:29 +02:00
peter 07e3ea3737 docs: the deep-backfill card counts the backlog, not the next pass
Two reviewers in a row read the admin card's selection against `run_deep_backfill`'s, saw it was
missing the job's `recent_synced_at` filter, and called it a card that counts channels the job will
never touch. It is the opposite: the card is the same backlog figure /api/sync/my-status shows the
user, and a channel still missing its recent pull is one the same job tick fetches first. Narrowing
it would hide genuinely queued work. Written down where the next reader will look.
2026-09-17 23:45:29 +02:00
peter b2eaa9e00b fix: a credential, an erased account and a stale level stay out of the log
Round 2 of the E22 review, on what round 1 changed.

`redact_query` decodes a parameter before the text rules see it, which is what lets `%40` be
recognised as an address — but `_SECRET_PARAM_RE` was written for the raw, encoded form, where a
value holds no space, `&` or quote. On decoded text its value class stops at the first one, so
`?state=a%20b` kept its tail and `?code=%20abc` was not matched at all. The parameter-NAME rule now
decides, on the key, before any text rule runs.

`forget_user` dropped the account's level elevation without recomputing the root level, so a process
whose only elevated account had just deleted itself kept building TRACE records to throw away, until
some later request happened to recompute it. Every other path that drops an elevation recomputes.

The sink's writer took the erasure lock and THEN waited for a pooled connection, while every request
path already holds a connection when it reaches that lock — so under pool pressure the writer held a
process-global lock against the very requests that could hand a connection back. Same order on both
sides now; the lock still spans the check, the insert and the commit.

The storage-budget notice said the rest of the hour was not kept, and then smaller batches kept
landing underneath it: the window slides and a refused batch is not charged to it. Says what it does.

Tests: the route's half of the erasure rule had no test — only the sink's did — and a source-level
contract test now covers the third insert nobody has written yet. `logs._erased` is process-global
with an hour's memory while the DB fixture hands serial ids back out from 1, so it is swept before
each test like the rate limiters are.
2026-09-17 23:45:16 +02:00
peter 791ba5f507 fix: a calendar date is the same day everywhere; legal pages use the shared language picker
formatDate read a date-only value as UTC midnight and showed it in the viewer's zone, so the legal pages' last update read a day early west of UTC. Date-only values are shown in UTC. The legal pages' hand-rolled language buttons are the landing page's LanguageSwitcher. formatDateTime and formatDate get the two-language test formatNumber already had.
2026-09-17 03:53:40 +02:00
peter 7789db8240 fix: admin config pickers fit a phone
A segmented bar cannot shrink or wrap, and the five log levels (~300 px) are wider than a phone leaves beside the navigation rail. A choice is a bar only when its labels are short; the rows stack below sm.
2026-09-17 03:53:40 +02:00
peter f92cd9017f fix: the diagnostics tab keeps its focus, its level and its consent
Deep-review round 1 of E22.

- After a 429 the stream sent one POST per new event while a full batch
  waited; it now stays quiet for 15 s. Past the queue cap the oldest
  events go, as the comment always said, a refused batch included.
- A batch refused after the level was lowered came back into the queue
  of a later consent in the same tab; each consent is its own session.
- A `me` fetch that left before a level or preference change could put the
  old value back for up to a minute. `updateMeCache` writes the cache and
  cancels that fetch; usePref goes through it too.
- The consent box's buttons and "revert now" took the focus with them to
  <body>; it returns to the level picker. Watched until the button is
  gone — in Chrome the saved level arrives in two renders, and a one-shot
  check spent itself on the first (jsdom never showed it).
- The countdown line is no longer a live region re-read every minute.
- "Saját napló" rows were keyed by index on a newest-first list, so every
  new event remounted every row and dropped the focus off a row button.
2026-09-17 03:53:40 +02:00
peter 4bfba4aa86 refactor: one definition of a followed channel and of a choice spelling
The explore purge rebuilt followed_channel's two EXISTS clauses inline, so what is polled and what may be deleted could drift; it now uses the predicate, which moved to sync/explore.py. The env validator and sysconfig each folded a choice value on their own; both call config.canonical_choice. The deep-backfill comment no longer calls recent backfill unconditional.
2026-09-17 03:53:29 +02:00
peter 86bc008a81 fix: a crashed job's crash line carries its job id
The bind sat inside the try, so it was already reset when the crash line and the cleanup were logged — the one line most worth finding by job id had none.
2026-09-17 03:53:29 +02:00
peter fac5c4465a fix: stored logs keep bad rows, erased accounts and unconsented queries out
Deep-review round 1 of E22.

- A NUL character (`GET /assets/%00` logs the decoded path) made Postgres
  refuse the row and with it the sink's whole batch, every user's rows
  included. `to_row` replaces NUL in every text column and in attrs.
- An erased account's lines kept arriving after purge_user's DELETE: the
  erasing request's own access line, a queued row, a background task.
  `logs.forget_user` runs first; later rows are stored without the account,
  and the check and the insert share a lock with the erasure.
- An admin setting the whole process to TRACE logged every user's query
  string. The query is now logged only under the user's own Teljes consent,
  and decoded before redaction, so `%40` no longer hides an address.
- The browser log ingest has an hourly storage budget per account (5 MiB,
  one notice when spent) and reads only a JSON Content-Type, so a
  same-site text/plain form post cannot write into a user's log.
  RateLimiter takes a per-attempt cost for the byte budget.
- The root level is applied under the elevation lock: two concurrent
  changes could leave it at the base while an elevation was active.
- Tests for the running sink (level gate, drain on stop) and for the
  admin log level applied at runtime and at boot.
2026-09-17 03:53:29 +02:00
peter 9c9b1cf6d5 test: the gate's output carries no noise to hide a real warning in
- Home tests mocked the api without downloadIndex, so every card's download button logged a
  missing-queryFn error (64 stderr blocks per run).
- QueryState rendered without an i18n instance; Modal.fullscreen awaited a synchronous act,
  which React reports as an act environment it does not support.
- pytest: Starlette 1.x's TestClient is built on httpx2 and warned on every run without it
  (pinned in requirements-dev); alembic.ini declares its path separator.
2026-09-17 03:03:37 +02:00
peter c3d88d9b20 refactor: component files export only components; lint fails on a warning
The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a
provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh
reloads a module that also exports hooks or helpers.

- Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling
  context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the
  component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts),
  following navigationInit/playlistsInit.
- Helpers that sat in component files have their own modules: welcomeMode, downloadTitle,
  plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent,
  ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns
  and the registry's page wrappers are components files of their own (channelCells, modulePages).
- usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original.

npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
2026-09-17 02:59:09 +02:00
peter 569190480d refactor: hook dependencies name everything the hooks read
The 12 react-hooks/exhaustive-deps warnings the gate let through:
- ShareDialog and YearFilter built a fresh [] per render while their query loaded, so their
  memos recomputed on every render; they share one empty list now.
- VideoEditor's drag effect called a seek/moveBoundary recreated each render; both are stable
  callbacks in its deps.
- WizardProvider re-checks onboarding on exactly the fields it reads (not the me object, whose
  60 s refetch would reopen a closed wizard).
- Channels, Feed and PlexPlayer list the stable values they already used.
- The player's keydown effect is bound once on purpose (its helpers read refs): said so, with a
  disable, as the codebase's other deliberate sites do.
- dialogFocus and the YouTube player's cleanup read refs React may already have let go of; they
  use what they captured when the effect ran.
2026-09-17 02:45:19 +02:00
peter 5883ea509d feat: Settings → Diagnostics, a consented log level and the tab's own log
A user can raise their diagnostics level to Részletes (DEBUG) or Teljes (TRACE) for 24 hours from a
new Settings tab, after a consent box that says what is recorded, that the server keeps it and for
how long (the instance's own retention), and that it lapses. Alap is the base and needs no consent.

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

Fixed on the way:
- A sync auth dependency ran logs.set_user on a copied context, so the account never reached a
  handler's own log lines (measured); a per-request mutable scope carries it now.
- The Settings tab rail left a phone no room (clipped labels, buttons over text): below `sm` it is
  a scrolling row above the content.
- PUT /api/me/preferences took a bare dict body; it is a Pydantic model now.
2026-09-17 02:42:12 +02:00
peter a210992bfc feat: client log with request ids, joined to the server's
The browser now keeps its half of the one event shape logs.py defines:
lib/log.ts is a levelled, redacted, bounded (2000 events) in-memory ring
with the server's level names and severity numbers. Nothing leaves the
browser yet; sending is the consented diagnostics level (S5).

- lib/http.ts `send` is the one fetch: every request carries an
  X-Request-Id and becomes one http.request event. The server echoes the
  matched route template in X-Route (only for a matched route, so a raw
  path with a value in it is never sent back); without one the client
  templates the path itself. req, beacon, the upload XHR, the public
  watch page and the legal pages all go through it.
- Hooks: app start snapshot, uncaught errors and rejections, History
  push/replace/popstate (a scroll-only replace is not an event; parameter
  values only at DEBUG, typed search only at TRACE), notifications, the
  error dialog, the crash boundary, failed queries and mutations (key
  head only), and the messages socket (lifecycle, frame types at DEBUG).
- Redaction is one contract: backend/tests/fixtures/log_redaction_cases.json
  runs against both redact() implementations.
- Lint: fetch, XMLHttpRequest and WebSocket each belong to one logging
  module, console to none; a contract test proves the rules fire.

Fixes a socket takeover: the close of a socket released by the last
subscriber cleared the newer socket a quick re-subscribe had opened and
scheduled a third, leaving two live sockets that delivered every message
twice (measured in Chromium: 3 created / 2 live before, 2 / 1 after).
2026-09-17 01:44:29 +02:00
peter 0517de1f59 fix: poll and backfill only channels somebody follows
The scheduled RSS poll and recent backfill covered every channel row,
but rows also come from search results, playlist sync and dropped
subscriptions, and nothing deletes them. On prod 582 of 979 polled
channels had no follower, three of them dead feeds answering 404 on
every poll. Both jobs now take subscribed or explored channels only,
and the scheduler card counts the same set. As a deliberate trade-off,
the shared Library no longer receives new uploads from channels nobody
follows.
2026-09-17 00:38:46 +02:00
peter b59fd38a9c feat: store warnings and request lines in log_events
The log now has a stored, queryable copy for the diagnostics console:
every WARNING and above plus the request line of every request, kept
for log_retention_days (default 7, no "forever") and pruned hourly by
the new log_gc job.

Rows are built on the logging thread from the same redacted fields as
the stdout line, queued without blocking and written in batches by one
background thread on its own connection. A full queue or a failed write
drops and reports instead of retrying. Flood control keeps 30 of any
one message per minute plus a count of the rest; anonymous request
lines are capped at 60 a minute per client, and the client address is
never stored. Deleting an account deletes its stored events.

Measured on prod first: a YouTube RSS outage wrote 11k warnings in four
hours, one per channel per poll. The poll now writes one warning with
per-reason counts and a few examples, and a failure on our side gets
one traceback per poll instead of one per channel.
2026-09-17 00:20:14 +02:00
peter 88a166e4aa fix: call the audit log "Auditnapló" in Hungarian
"Naplózás" (logging) named the audit log in the nav, its page and its
Configuration tab, right beside the new "Naplózási szint" (log level)
under Diagnosztika — two different things under one word. The scheduler
hint also pointed at a "Beállítások → Naplózás" path that does not
exist; it is Konfiguráció → Auditnapló, as the English already says.
2026-09-17 00:04:01 +02:00
peter 07766c43e8 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.
2026-09-17 00:03:56 +02:00
peter bb3c6dec45 release: 0.87.1 (bug/youtube-cookie-host)
gate / frontend (push) Failing after 3m13s
gate / backend (push) Successful in 4m22s
2026-09-16 04:41:40 +02:00
peter 3cf8f04d73 chore: 0.87.1 release notes 2026-09-16 04:41:10 +02:00
peter 2b56c5c2eb fix: name the error yt-dlp actually raises for a cookie-emptied client set
A review round read yt-dlp's source and found the comment behind the previous commit's safety-net
marker did not match it. Measured in the worker with a real account cookie and
`player_client=android_vr`: yt-dlp logs `Skipping client "android_vr" since it does not support
cookies`, drops it, and the request left with no client fails with "Requested format is not
available" — not "No player clients have been requested". That raise sits EARLIER in yt-dlp, before
the cookie-capability removal, so the cookie path can never reach it; and an unknown or renamed
client is skipped with a warning and replaced by yt-dlp's defaults, so the other scenario the
comment named cannot reach it either.

So the marker bought nothing: the symptom that does occur was already retriable here before this
branch. It is dropped, the comment now states what was measured, and the test asserts the real
symptom instead of a message the code cannot produce. The cookie-capability filter itself is
vindicated by the same measurement — without it every signed-in download would burn its first
attempt on a client yt-dlp discards.

Also from the round: an enqueue test for a `source_kind="youtube"` job, the one join nothing else
made (a YouTube job stores a bare 11-char ref and the URL the cookie gate keys on is rebuilt by the
adapter — both ends were pinned apart, the join was not, and a break there would stop every YouTube
cookie silently while signed-out downloads kept working). And copy corrections: the group's only
unhedged claim is hedged, "described the video" loses the pipeline vocabulary, and four Hungarian
phrasings that read as translated English are rewritten.
2026-09-16 04:29:13 +02:00
peter c06a456a9a fix: a YouTube stream refused after extraction is the gate, not a network fault
Measured on the isolated test stack the same night, on the very same video ~90 minutes after it was
answering with the bot message: yt-dlp got the player response and picked its formats, and the CDN
then refused the stream itself — "unable to download video data: HTTP Error 403: Forbidden".
Signed out it did that on every attempt; with the owner's cookie attached the video downloaded in
full. So the wall moves between the player response and the stream, and the text it wears there was
costing the download twice.

retriable: the format URLs belong to the client that minted them, so the next client set is a real
second chance — but this text classifies as `network` and nothing in the YouTube marker list matched
it, so the ladder ended the job on attempt 1 and never tried the fallback.

catalog: `youtube.media_forbidden` for the cookie-less case, so the UI stops calling it a network
error when nothing was wrong with the network, and names what actually gets past it. Scoped exactly
as narrowly as the measurement: another host keeps the generic `network` category, and so does a
download that already carried a cookie — telling a signed-in user to sign in is the failure the
catalog exists to avoid. EN + HU copy.
2026-09-16 03:43:29 +02:00
peter 864f673ef5 fix: a YouTube video behind the bot wall is downloadable with cookies
A YouTube link can fail with "Sign in to confirm you're not a bot" while every
other YouTube download works — YouTube gates INDIVIDUAL videos behind a
signed-in session. Measured on prod job 273: all twelve of yt-dlp's player
clients fail on that one video while its neighbours download fine, the bgutil
PO-token sidecar is live and answering, and the newest yt-dlp fails identically.
So the download is not broken by our stack or a stale yt-dlp, and no retry,
client switch or upgrade gets past it. A cookie is the only way in.

The failure copy already sends the user to Settings -> Account -> Download
authentication, but YouTube was not in the cookie host registry, so that
dropdown could not help them: the app asked for something it did not offer.

credentials: YouTube joins SUPPORTED_HOSTS (youtube.com, youtu.be,
youtube-nocookie.com). The Settings dropdown, the SEC-12 host scope and the
private-asset cache salt all derive from the row, so that is the whole wiring.

adapters: a signed-in YouTube download runs only cookie-capable player clients.
Measured against yt-dlp's own INNERTUBE_CLIENTS table: with account cookies
attached yt-dlp REMOVES every client whose SUPPORTS_COOKIES is False, and a set
it empties that way raises "No player clients have been requested" — which is
not a per-client flake it retries past. Our primary set is android_vr, one of
those, so without this the cookie would have failed the download on attempt 1
and never reached the POT-backed web fallback it exists for. A test pins the
client list against the installed yt-dlp so a version bump turns the gate red
instead of silently un-authing every YouTube download, and the empty-set message
joins the retriable markers as the second line of defence.

errors: catalog entries for the wall with and without a cookie, so a user who
has already added cookies is told they were rejected (YouTube rotates exported
ones) rather than to add some. The marker is quote-agnostic: YouTube writes the
apostrophe as U+2019, so "you're not a bot" with a straight quote never matches
the real text. EN + HU copy.
2026-09-16 02:03:00 +02:00
peter 6c63df6704 feat: one structured log for every process, with a request id
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.
2026-09-16 01:37:02 +02:00
peter a5bcd9fca2 fix: numbers follow the UI language, not the browser's
`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.
2026-09-16 01:05:53 +02:00
peter 618ef812e6 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.
2026-09-16 00:49:23 +02:00
peter e299c66121 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.
2026-09-16 00:45:03 +02:00
peter 11aef4ab7a 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.
2026-09-16 00:34:50 +02:00
peter 4e52bdd4ca 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.
2026-09-15 23:45:13 +02:00
1022 changed files with 90157 additions and 20354 deletions
+1
View File
@@ -4,6 +4,7 @@
*.env.local *.env.local
**/__pycache__/ **/__pycache__/
**/*.pyc **/*.pyc
**/.coverage
.venv/ .venv/
venv/ venv/
# Docker patterns are anchored at the context root with NO implicit `**/` (unlike .gitignore, where # Docker patterns are anchored at the context root with NO implicit `**/` (unlike .gitignore, where
+8
View File
@@ -28,6 +28,14 @@ ALLOWED_EMAILS=
# Admin emails (subset of the above) get the admin role. # Admin emails (subset of the above) get the admin role.
ADMIN_EMAILS= 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, 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
# Optional: origin of a separately-served frontend dev server (enables CORS). Leave empty in production. # Optional: origin of a separately-served frontend dev server (enables CORS). Leave empty in production.
FRONTEND_ORIGIN= FRONTEND_ORIGIN=
-71
View File
@@ -1,71 +0,0 @@
# Server-side mirror of the local `siftlode check` gate (E1 S3 / DX-1), so `main` is always green
# independent of any one machine. Two parallel jobs match the two local lanes:
# frontend — tsc (3 projects) + eslint + prettier --check + knip + vitest
# backend — ruff + the full pytest suite (pure-logic + the DB-backed lane) against a real Postgres
#
# Runner: the lab act_runner (LXC 115) mounts the Docker socket, so service containers work. The backend
# runs pytest NATIVELY against a `postgres` service (not the local docker-compose.test.yml, whose
# bind-mount is unreliable under a mounted-socket runner); fidelity is kept by installing the SAME pinned
# requirements, Postgres 16, and ffmpeg, and by setting SIFTLODE_TEST_REQUIRE_DB=1 so a DB/ffmpeg that
# failed to come up is a hard error, never a silent skip. node 22 matches the prod build image
# (Dockerfile `node:22-alpine`).
name: gate
on: [push, pull_request]
jobs:
frontend:
runs-on: ubuntu-latest
container: node:22-bookworm
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run typecheck
- run: npm run lint
- run: npm run format:check
- run: npm run knip
- run: npm run test
backend:
runs-on: ubuntu-latest
# A combined python+node image: python 3.12 (matches the prod Dockerfile) for the tests, and node
# so the JS actions (checkout) run. actions/setup-python is NOT usable here — the runner can't reach
# GitHub's python-versions download, so it must be baked into the image.
container: nikolaik/python-nodejs:python3.12-nodejs20
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: siftlode
POSTGRES_PASSWORD: siftlode
POSTGRES_DB: siftlode_test
options: >-
--health-cmd "pg_isready -U siftlode -d siftlode_test"
--health-interval 2s
--health-timeout 3s
--health-retries 30
env:
DATABASE_URL: postgresql+psycopg://siftlode:siftlode@postgres:5432/siftlode_test
SIFTLODE_TEST_REQUIRE_DB: "1"
steps:
- uses: actions/checkout@v4
# ffmpeg for the poster/thumbnail tests; postgresql-client for the readiness wait below.
- run: apt-get update && apt-get install -y --no-install-recommends ffmpeg postgresql-client
- name: Wait for Postgres
run: until pg_isready -h postgres -U siftlode -d siftlode_test; do sleep 1; done
- run: pip install --no-cache-dir -r backend/requirements.lock -r backend/requirements-dev.txt
- name: ruff
run: python -m ruff check --config backend/ruff.toml backend scripts
- name: ruff format --check
run: python -m ruff format --check --config backend/ruff.toml backend scripts
- name: pytest
run: python -m pytest
working-directory: backend
# Dependency CVE audit over the pinned lock. Non-blocking for now (informational) — a real CVE
# shows up in the run without failing the gate; tighten to a hard fail once the noise is known.
- name: pip-audit (advisory)
run: pip install --no-cache-dir pip-audit && pip-audit -r backend/requirements.lock
continue-on-error: true
+5
View File
@@ -9,6 +9,7 @@ __pycache__/
venv/ venv/
.mypy_cache/ .mypy_cache/
.pytest_cache/ .pytest_cache/
.coverage
.ruff_cache/ .ruff_cache/
# Node / frontend # Node / frontend
@@ -37,3 +38,7 @@ backups/
Thumbs.db Thumbs.db
.idea/ .idea/
.vscode/ .vscode/
# TypeScript incremental build artifacts — machine-local, absolute-path-bearing, and no gate lane
# reads or regenerates them, so a committed one is stale from the commit that adds it.
*.tsbuildinfo
+1
View File
@@ -0,0 +1 @@
24
+1
View File
@@ -0,0 +1 @@
3.12
+15
View File
@@ -0,0 +1,15 @@
# The CVE gate's allowlist (E32). The publish gate scans the built image and the committed tree with
# Trivy and fails on any HIGH or CRITICAL finding that has a fix available. A finding that cannot be
# fixed yet is let through ONLY by an entry here, and every entry carries both:
# statement — why it is acceptable for now (what is exposed, what is not)
# expired_at — the date it stops being accepted; after it, the gate fails again on its own
# A contract test (frontend/src/lib/gates.contract.test.ts) refuses an entry without either, and
# docs/gates.md lists them.
#
# Example:
# vulnerabilities:
# - id: CVE-2026-00000
# statement: in libfoo's XML path, which nothing in the image calls; no fixed Debian build yet
# expired_at: 2026-12-31
vulnerabilities: []
secrets: []
+925 -1240
View File
File diff suppressed because it is too large Load Diff
+51 -33
View File
@@ -7,17 +7,21 @@ worse than no rule — if you change how the code works, change this file in the
Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a download center). It is a Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a download center). It is a
**modular monolith**: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process **modular monolith**: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process
(yt-dlp/ffmpeg), one PO-token sidecar. React 18 + react-query frontend with a router-free navigation (yt-dlp/ffmpeg), one PO-token sidecar. React 19 (with the React Compiler) + react-query frontend, with a
provider. router-free navigation provider.
## The gate — never merge or ship red ## The gate — never merge or ship red
The local gate is **ruff (`check` + `format --check`) + typecheck (3 tsc projects) + eslint + prettier The local gate is **ruff (`check` + `format --check`) + basedpyright (backend) + the lock check + typecheck (3 tsc projects) +
--check + knip + vitest + pytest**. It runs on every `publish`, which **refuses to build on failure — there is deliberately no eslint + prettier --check + knip (both modes) + vitest + vite build + pytest + pip-audit + npm audit**, then a coverage report (no threshold). It runs
`--skip` flag.** Run it and get it green **before** any merge, release, or handing work to review. before every `release` merges and on every `publish`, which **refuses to build on failure — there is deliberately no `--skip`
flag**; a release also requires the Playwright suite, and the image must pass a Trivy scan before it is pushed. Run it and get it
green **before** any merge, release, or handing work to review.
- Full gate (agents with the private ops dispatcher): `siftlode check`. - Full gate: `bash scripts/check.sh` (`lint` for the static lanes only); the private ops dispatcher's `siftlode check` runs the same script. Single lanes: [`docs/development.md`](docs/development.md) § The gate.
- Directly, without the dispatcher: `cd frontend && npm run typecheck && npm run lint && npm run knip && npm run test`, plus backend `ruff check backend` + `ruff format --check backend` and `pytest` (the DB-lane needs Postgres). - **There is no CI** (owner, 2026-10-04): this gate, run here, is the only one — green before every hand-over, and `release` refuses to merge without it.
- **A gate with any real hit is never switched off or narrowed** — not a lint rule, a compiler flag, a lane or a release step. Fix the sites, or suppress each one inline naming its invariant (`-- why`, lint-enforced). What stays narrowed is in [`docs/gates.md`](docs/gates.md), and a contract test fails when a narrowing is missing from it. The effective ESLint config of every file and the gate/lane lines are pinned in `frontend/src/lib/gates.snapshot.json`: a new code file or any gate change fails until you regenerate it (`GATE_SNAPSHOT=update`, see gates.md) — then read that diff as a gate change. A comment saying "X is enforced by Y" is checked by running Y (E32: one such comment hid 95 functions the React Compiler skipped).
- **The backend type lane runs in the test image, not on the host** — `requirements.lock` cannot be installed on Windows (uvloop), and a type checker without the real packages reports nonsense. It fails on NEW errors only; the pre-existing ones sit in `backend/.basedpyright/baseline.json` and burn down epic by epic. Fixed some? Regenerate with `--writebaseline`.
- New DB-backed test? It must actually run, not silently skip — assume `SIFTLODE_TEST_REQUIRE_DB=1`. - New DB-backed test? It must actually run, not silently skip — assume `SIFTLODE_TEST_REQUIRE_DB=1`.
## Workflow ## Workflow
@@ -45,20 +49,22 @@ how it gets built. Concretely:
behind each option and the weight of the decision. These decision points are where the product owner behind each option and the weight of the decision. These decision points are where the product owner
engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one. engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one.
- **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to - **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to
choose from before building. choose from before building. How the UI looks and behaves is decided in
[`docs/DESIGN.md`](docs/DESIGN.md) (its variants render in the dev-only `/design-lab`): build to it,
and change it only through its decision log.
- **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real - **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real
arguments, push back or propose an alternative — don't comply blindly. arguments, push back or propose an alternative — don't comply blindly.
- **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction - **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction
bugs; do a real visual check of any UI change. bugs; do a real visual check of any UI change, and run DESIGN.md's four-step Conformance check.
- **Be terse everywhere else.** Outside those decision points, lead with the answer or decision. During - **Be terse everywhere else.** Outside those decision points, lead with the answer or decision. During
implementation and debugging especially: no restating the same point reworded, no filler. implementation and debugging especially: no restating the same point reworded, no filler.
## Architecture shape (don't fight it) ## Architecture shape (don't fight it)
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`, `routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
`downloads/`, `youtube/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`, `downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `bodylimit`, `progress`, `collation`,
`security`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.** `security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
@@ -74,27 +80,39 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
## Canonical patterns — when you have a choice, pick THIS ## Canonical patterns — when you have a choice, pick THIS
| Area | Use | Not | | Area | Use | Not |
| ----------------------- | ------------------------------------------------------------ | ------------------------------------------------------ | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` | | ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
| Request body | a Pydantic model | `payload: dict` | | Request body | a Pydantic model | `payload: dict` |
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` | | Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
| Admin gate import | `app.auth` | the `app.routes.admin` re-export | | Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) | | Admin gate import | `app.auth` | the `app.routes.admin` re-export |
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI | | Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`) | ad-hoc `{"unsubscribed"}`/`{"cleared": n}`/echo | | User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
| Transaction commit | the handler/job-wrapper commits; helpers `flush` | helpers that commit (only `quota` owns its session) | | Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
| Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter | | Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` owns its session) |
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals | | Admin knob storage | `sysconfig` (values) | env / `app_state` / `scheduler_settings` scatter |
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers | | Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks | | Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` | | FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals | | FE button | `ui/Button` (a `SIZES` rung) · `IconButton` · `Pressable` (a clickable card/row) · `Chip` · `DragHandle` | a raw `<button>` or `FocusSafeButton` outside the control primitives (lint-enforced) |
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler | | FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` | | FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` | | FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals | | FE text field / checkbox / slider | `ui/Input` · `Textarea` · `InnerInput` in a `fieldClass` frame · `Checkbox`/`CheckRow` · `Slider` | a raw `<input>`/`<textarea>` anywhere but those primitives (lint-enforced) |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached | | FE glass surface | a role: `SURFACE.panel` / `card` / `menu` / `popover` / `tooltip` (`ui/surfaces.ts`) | a bare `glass`/`glass-card`/`glass-menu` beside a hand-picked radius (lint-enforced) |
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
| FE control | `SegmentedToggle`; a long or user's list: `FilterSelect` | a native `<select>` (lint-enforced) |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
| FE network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) |
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
| FE effect that must not re-subscribe | `useEffectEvent` (React 19.2+); a handler or timer: `useLatest` / `useStableCallback` | a render-phase `ref.current = x` — the React Compiler skips the component |
| FE hook suppression | an inline `eslint-disable-next-line` NAMING the invariant | `eslint-suppressions.json` (deliberately absent — E27 S2) |
| FE popover / scroll-fade ref | destructure it: `const { setReference } = pop.refs`, then `ref={setReference}` | `ref={pop.refs.setReference}` / `ref={fade.ref}` — the compiler then treats the whole object as a ref and skips |
| FE async handler with cleanup | `try { … } catch { … }`, then the cleanup after it (steps that `return` early go in their own function); a conditional, loop or `?.` the `try` needs goes before it or into a module-level helper | `try/finally`, or a `?:` / `??` / loop / `?.` inside `try` — the React Compiler skips the whole component (and `react-hooks/todo` misses most of these) |
## House rules that bite (learned from real bugs) ## House rules that bite (learned from real bugs)
+12 -4
View File
@@ -10,7 +10,9 @@ ARG GIT_SHA=unknown
ARG BUILD_DATE= ARG BUILD_DATE=
# Stage 1: build the frontend SPA # Stage 1: build the frontend SPA
FROM node:22-alpine AS frontend # The major is pinned to `.nvmrc` and held there by frontend/src/lib/nodeEngine.contract.test.ts:
# this image and the dev machine the gate runs on must be ONE engine (see that test for why).
FROM node:24-alpine AS frontend
WORKDIR /fe WORKDIR /fe
# Deps first, keyed on the lockfile, so editing app source never re-resolves node_modules. # Deps first, keyed on the lockfile, so editing app source never re-resolves node_modules.
COPY frontend/package.json frontend/package-lock.json ./ COPY frontend/package.json frontend/package-lock.json ./
@@ -51,8 +53,11 @@ RUN adduser --disabled-password --gecos "" appuser \
# Harmless on hosts with no /dev/dri (the driver just goes unused). Deno: the JavaScript runtime yt-dlp # Harmless on hosts with no /dev/dri (the driver just goes unused). Deno: the JavaScript runtime yt-dlp
# uses to solve YouTube's "n" signature challenge for the web player clients (required alongside the PO # uses to solve YouTube's "n" signature challenge for the web player clients (required alongside the PO
# token, else only storyboards are offered). yt-dlp auto-detects deno on PATH. # token, else only storyboards are offered). yt-dlp auto-detects deno on PATH.
# `apt-get upgrade` (E32): the base image is a snapshot, and the Debian security fixes released since it
# was cut are only in the archive — the first Trivy scan found one (pcre2) fixed upstream but not in it.
ARG DENO_VERSION=v2.9.1 ARG DENO_VERSION=v2.9.1
RUN apt-get update \ RUN apt-get update \
&& apt-get upgrade -y --no-install-recommends \
&& apt-get install -y --no-install-recommends ffmpeg intel-media-va-driver unzip curl ca-certificates \ && apt-get install -y --no-install-recommends ffmpeg intel-media-va-driver unzip curl ca-certificates \
&& curl -fsSL "https://github.com/denoland/deno/releases/download/${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \ && curl -fsSL "https://github.com/denoland/deno/releases/download/${DENO_VERSION}/deno-x86_64-unknown-linux-gnu.zip" -o /tmp/deno.zip \
&& unzip -o /tmp/deno.zip -d /usr/local/bin \ && unzip -o /tmp/deno.zip -d /usr/local/bin \
@@ -74,11 +79,14 @@ RUN printf 'Types: deb\nURIs: http://deb.debian.org/debian\nSuites: bookworm\nCo
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Install from the PINNED lock (requirements.lock, generated from requirements.txt) so the image is # Install from the PINNED lock (requirements.lock, generated from requirements.txt) so the image is
# byte-reproducible and the exact versions are the ones pip-audit scanned in CI — not whatever floats # byte-reproducible and the exact versions are the ones the gate's pip-audit scanned — not whatever
# up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header). # floats up from the floor ranges on build day. Regenerate the lock when requirements.txt changes (see its header).
# pip itself is removed once it has installed the lock (E32): nothing runs it at runtime, and it carries
# its own VENDORED copies of urllib3, msgpack and setuptools, which Trivy reports as the image's own —
# an attack surface and a CVE stream for a tool the running app never calls.
COPY backend/requirements.lock . COPY backend/requirements.lock .
RUN --mount=type=cache,target=/root/.cache/pip \ RUN --mount=type=cache,target=/root/.cache/pip \
pip install --upgrade pip && pip install -r requirements.lock pip install --upgrade pip && pip install -r requirements.lock && pip uninstall -y pip
COPY --chown=appuser backend/ . COPY --chown=appuser backend/ .
# Read at runtime by config.py as the app_version (a committed single source of truth). # Read at runtime by config.py as the app_version (a committed single source of truth).
+1 -1
View File
@@ -1 +1 @@
0.87.0 0.99.2
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,6 +1,8 @@
[alembic] [alembic]
script_location = alembic script_location = alembic
prepend_sys_path = . prepend_sys_path = .
# Split the path list the way the OS does (alembic's legacy space/comma/colon split is deprecated).
path_separator = os
[loggers] [loggers]
keys = root,sqlalchemy,alembic keys = root,sqlalchemy,alembic
+4 -4
View File
@@ -1,17 +1,17 @@
from logging.config import fileConfig
from alembic import context from alembic import context
from sqlalchemy import engine_from_config, pool from sqlalchemy import engine_from_config, pool
from app.config import settings from app.config import settings
from app.db import Base from app.db import Base
from app.logs import configure_logging
import app.models # noqa: F401 (import so models register on Base.metadata) import app.models # noqa: F401 (import so models register on Base.metadata)
config = context.config config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url) config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None: # Migrations run at container start (entrypoint.sh), so their output is part of the API's log
fileConfig(config.config_file_name) # stream — one format with everything else, not alembic.ini's own fileConfig shape.
configure_logging("alembic")
target_metadata = Base.metadata target_metadata = Base.metadata
@@ -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")
@@ -0,0 +1,48 @@
"""add log_events — stored log events for diagnostics (E22 S3)
Revision ID: 0086_log_events
Revises: 0085_users_last_login_at
Create Date: 2026-09-17
Written by app/logsink.py's background writer (WARNING and above, request lines), pruned by the log_gc
job after `log_retention_days`. Sized against prod on 2026-09-16: ~2k rows on a normal day, ~14k on a
YouTube-RSS-outage day before flood control — a few MB over the 7-day window. `user_id` carries no
foreign key on purpose (see models.LogEvent).
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0086_log_events"
down_revision: str | None = "0085_users_last_login_at"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.create_table(
"log_events",
sa.Column("id", sa.BigInteger(), primary_key=True),
sa.Column("ts", sa.DateTime(timezone=True), nullable=False),
sa.Column("level", sa.String(10), nullable=False),
sa.Column("sev", sa.SmallInteger(), nullable=False),
sa.Column("source", sa.String(16), nullable=False),
sa.Column("logger", sa.String(128), nullable=False),
sa.Column("event", sa.String(64), nullable=True),
sa.Column("msg", sa.Text(), nullable=False),
sa.Column("attrs", postgresql.JSONB(), nullable=True),
sa.Column("exc", sa.Text(), nullable=True),
sa.Column("request_id", sa.String(64), nullable=True),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("debug_session_id", sa.String(64), nullable=True),
sa.Column("job_id", sa.Integer(), nullable=True),
sa.Column("version", sa.String(64), nullable=True),
)
op.create_index("ix_log_events_ts", "log_events", ["ts"])
op.create_index("ix_log_events_request_id", "log_events", ["request_id"])
op.create_index("ix_log_events_user_ts", "log_events", ["user_id", "ts"])
def downgrade() -> None:
op.drop_table("log_events")
@@ -0,0 +1,31 @@
"""add users.diagnostics_level + diagnostics_until — a user's consented diagnostics level (E22 S5)
Revision ID: 0087_users_diagnostics_level
Revises: 0086_log_events
Create Date: 2026-09-17
Both NULL = the base level (Alap). A raised level is ALWAYS paired with its expiry, computed by the
server at consent time (`app/diagnostics.py`), so no client can ask for a level that never ends.
Columns rather than `users.preferences`: that JSON is written wholesale by the client, and a consent
with a lifetime must not be something a client can write.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0087_users_diagnostics_level"
down_revision: str | None = "0086_log_events"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column("users", sa.Column("diagnostics_level", sa.String(8), nullable=True))
op.add_column(
"users", sa.Column("diagnostics_until", sa.DateTime(timezone=True), nullable=True)
)
def downgrade() -> None:
op.drop_column("users", "diagnostics_until")
op.drop_column("users", "diagnostics_level")
@@ -0,0 +1,30 @@
"""add channels.feed_fallback_at — when a channel's uploads were last read through the API because
its RSS feed would not answer
Revision ID: 0088_channels_feed_fallback_at
Revises: 0087_users_diagnostics_level
Create Date: 2026-09-19
YouTube's RSS feeds go dark for hours at a time, per channel (2026-09-18/19: 393 of 396 answered 404
for four hours; the same channel answers 404 to every retry). The RSS poll then reads the newest
uploads through the Data API instead, at a quota cost, and at most once per `rss_fallback_hours` per
channel: this column is that clock. NULL = never needed it.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0088_channels_feed_fallback_at"
down_revision: str | None = "0087_users_diagnostics_level"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column(
"channels", sa.Column("feed_fallback_at", sa.DateTime(timezone=True), nullable=True)
)
def downgrade() -> None:
op.drop_column("channels", "feed_fallback_at")
@@ -0,0 +1,81 @@
"""add debug_sessions + debug_screenshots, and index log_events.debug_session_id (E16)
Revision ID: 0089_debug_sessions
Revises: 0088_channels_feed_fallback_at
Create Date: 2026-09-19
A debug session is one recording: Start → reproduce → Stop (app/debugsession.py). Every log line
of that user's requests while it runs carries its id in `log_events.debug_session_id` — the column
E22 created and left NULL — and the issue it ends in is built from exactly those rows, so the column
gets its index now. Partial: nearly every row has no session, and an index of NULLs would be most
of the table for nothing.
At most one OPEN session per user, held by a partial unique index rather than by the start handler
alone: two tabs pressing Start at once would otherwise both pass a "none open" check.
Screenshots are rows, not files: bounded (size and count per session, app/debugsession.py), gone
with their session by cascade, and a self-host has no file volume to configure for them.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0089_debug_sessions"
down_revision: str | None = "0088_channels_feed_fallback_at"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.create_table(
"debug_sessions",
sa.Column("id", sa.String(16), primary_key=True),
sa.Column(
"user_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("level", sa.String(8), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("until", sa.DateTime(timezone=True), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("ended_reason", sa.String(16), nullable=True),
sa.Column("issue_url", sa.String(512), nullable=True),
)
op.create_index(
"uq_debug_sessions_open_per_user",
"debug_sessions",
["user_id"],
unique=True,
postgresql_where=sa.text("ended_at IS NULL"),
)
op.create_table(
"debug_screenshots",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column(
"session_id",
sa.String(16),
sa.ForeignKey("debug_sessions.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("mime", sa.String(16), nullable=False),
sa.Column("size", sa.Integer, nullable=False),
sa.Column("data", sa.LargeBinary, nullable=False),
)
op.create_index(
"ix_log_events_debug_session",
"log_events",
["debug_session_id"],
postgresql_where=sa.text("debug_session_id IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("ix_log_events_debug_session", table_name="log_events")
op.drop_table("debug_screenshots")
op.drop_index("uq_debug_sessions_open_per_user", table_name="debug_sessions")
op.drop_table("debug_sessions")
@@ -0,0 +1,27 @@
"""add debug_sessions.draft — the issue being written from a session (E16 S3)
Revision ID: 0090_debug_session_draft
Revises: 0089_debug_sessions
Create Date: 2026-09-19
The composer saves as it goes: the title, the rendered markdown body that an export or an issue
carries, the log rows the user took out, and the form's own fields so reopening it later (Settings →
Diagnosztika lists past sessions) shows what was written. NULL until the composer first saves.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision: str = "0090_debug_session_draft"
down_revision: str | None = "0089_debug_sessions"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column("debug_sessions", sa.Column("draft", JSONB, nullable=True))
def downgrade() -> None:
op.drop_column("debug_sessions", "draft")
@@ -0,0 +1,27 @@
"""add tagline_hu to plex_items + plex_shows — the HU overlay's tagline (E30 S1)
Revision ID: 0091_plex_tagline_hu
Revises: 0090_debug_session_draft
Create Date: 2026-09-29
The Hungarian overlay section lists a tagline beside the title and summary; the info page showed the
English one to a Hungarian reader. NULL until the next overlay sync fills it (serving falls back to EN).
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0091_plex_tagline_hu"
down_revision: str | None = "0090_debug_session_draft"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column("plex_items", sa.Column("tagline_hu", sa.Text(), nullable=True))
op.add_column("plex_shows", sa.Column("tagline_hu", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("plex_shows", "tagline_hu")
op.drop_column("plex_items", "tagline_hu")
@@ -0,0 +1,31 @@
"""add biography_hu to plex_people — TMDB's Hungarian biography (E30 S4)
Revision ID: 0092_plex_person_biography_hu
Revises: 0091_plex_tagline_hu
Create Date: 2026-09-29
The person page showed a Hungarian reader the English biography although TMDB has a Hungarian one for
most people. The person fetch now appends `translations` and keeps the Hungarian biography beside the
English one. Already-enriched people are un-stamped, so their next page open refetches them once (the
same lazy path as a first open) and fills it; until then the English biography stands in.
"""
import sqlalchemy as sa
from alembic import op
revision: str = "0092_plex_person_biography_hu"
down_revision: str | None = "0091_plex_tagline_hu"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.add_column("plex_people", sa.Column("biography_hu", sa.Text(), nullable=True))
op.execute(
"UPDATE plex_people SET tmdb_enriched_at = NULL "
"WHERE tmdb_person_id IS NOT NULL AND tmdb_enriched_at IS NOT NULL"
)
def downgrade() -> None:
op.drop_column("plex_people", "biography_hu")
@@ -0,0 +1,29 @@
"""re-fetch linked people — "Known for" ranked by votes, without guest visits (E30 S6)
Revision ID: 0093_plex_person_known_for_votes
Revises: 0092_plex_person_biography_hu
Create Date: 2026-09-30
A person's "Known for" pool was their credits ranked by TMDB popularity, so talk-show visits as
themselves crowded out the films (Tom Cruise: 14 of 15 were talk shows, none in the library). The pool is
now ranked by vote count, without guest visits, with directed works, and deeper. Already-enriched people
are un-stamped so the people_enrich job fetches them again; their biography stays on the row meanwhile.
"""
from alembic import op
revision: str = "0093_plex_person_known_for_votes"
down_revision: str | None = "0092_plex_person_biography_hu"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.execute(
"UPDATE plex_people SET tmdb_enriched_at = NULL "
"WHERE tmdb_person_id IS NOT NULL AND tmdb_enriched_at IS NOT NULL"
)
def downgrade() -> None:
pass # the watermark only says "fetched"; a refetch under the old ranking is not worth undoing to
@@ -0,0 +1,45 @@
"""Hungarian genres on every Plex row, not only on overlaid ones (E30 review)
Revision ID: 0094_plex_genres_hu_everywhere
Revises: 0093_plex_person_known_for_votes
Create Date: 2026-09-30
The HU genre facets and filter read `genres_hu`, which only the overlay pass wrote — so a library with
no HU twin, or a guid-less row, offered English genres a Hungarian filter could never match. The sync
now writes `genres_hu` for every row (plex/sync.py `_apply_facet_fields`); this fills the rows it has
not reached yet, from their own English list through the same map.
"""
import json
import sqlalchemy as sa
from alembic import op
from app.plex.genres import hu_genres
revision: str = "0094_plex_genres_hu_everywhere"
down_revision: str | None = "0093_plex_person_known_for_votes"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
conn = op.get_bind()
for table in ("plex_items", "plex_shows"):
rows = conn.execute(
sa.text(
f"SELECT id, genres FROM {table} "
"WHERE genres_hu IS NULL AND jsonb_typeof(genres) = 'array'"
)
).fetchall()
updates = [
{"id": rid, "g": json.dumps(hu)} for rid, genres in rows if (hu := hu_genres(genres))
]
if updates:
conn.execute(
sa.text(f"UPDATE {table} SET genres_hu = CAST(:g AS jsonb) WHERE id = :id"), updates
)
def downgrade() -> None:
pass # derived from `genres`; the sync writes the same values again
@@ -0,0 +1,83 @@
"""videos: the per-channel index covers what the channel manager and the feed sidebar read (E31 S4d)
The channel manager (`GET /api/channels`) sums each subscribed channel's videos: count, last upload, total
duration. The feed sidebar (`/feed/summary`, `/feed/years`) counts the reader's videos per channel and per
year under the default filters: available, not via explore, not a short, not live. Every one of them found
its rows through `ix_videos_channel_pub_desc` (channel_id, published_at DESC NULLS LAST, id) and then read
each video's heap page for the rest: ~270k rows at ~2.5 KB each, 681 MB on prod. That stays in cache only
while nothing else needs the memory. Measured on prod (2026-10-02): `/api/channels` 51–61 ms warm, but
2.25 s and 1.96 s on a first open in the browser and 6.4 s under EXPLAIN with 30k blocks read from disk;
`/feed/summary` and `/feed/years` ~140 ms warm, ~800 ms cold.
The same key with those columns INCLUDEd makes them Index Only Scans. On a prod-copy localdev, after a
VACUUM, the manager's sums read 0 heap pages and the summary reads the 25 MB index instead of the heap.
The visibility map is what lets them skip the heap; prod's autovacuum kept it complete (relallvisible ==
relpages). The key columns are unchanged, so every reader of the old index (the per-channel feed, the FK
cascade, the sync upsert, channel detail) rides this one. The old index is dropped.
tests/test_channel_list_index_only.py holds the queries and this column list together.
CONCURRENTLY (autocommit_block) builds it without blocking a live prod. IF (NOT) EXISTS keeps the drift
test's downgrade → re-upgrade round trip idempotent. A re-run after a build that failed part-way rebuilds
the INVALID leftover, and the old index is dropped only once the new one is valid: a re-run that kept the
leftover and dropped the old one left `videos` with no usable channel_id index (E31 S5 review).
"""
from alembic import op
from sqlalchemy import text
revision: str = "0095_videos_channel_cover_index"
down_revision: str | None = "0094_plex_genres_hu_everywhere"
branch_labels: str | None = None
depends_on: str | None = None
def _valid(name: str) -> bool | None:
"""The index's `indisvalid`, or None when there is no index of that name."""
row = (
op.get_bind()
.execute(
text(
"SELECT i.indisvalid FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid "
"WHERE c.relname = :name"
),
{"name": name},
)
.first()
)
return None if row is None else bool(row[0])
def _build(name: str, ddl: str) -> None:
"""CREATE INDEX CONCURRENTLY `name` (`ddl` is what follows the name), re-runnable. A build that
failed part-way leaves an INVALID index under the name, and `IF NOT EXISTS` would keep it: the
planner never uses it, and nothing says so (E31 S5 review). Such a leftover is dropped and built
again."""
if _valid(name) is False:
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {name}")
op.execute(f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {name} {ddl}")
def _swap(build: str, ddl: str, drop: str) -> None:
_build(build, ddl)
if not _valid(build):
raise RuntimeError(f"{build} did not build valid; {drop} is kept")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {drop}")
def upgrade() -> None:
with op.get_context().autocommit_block():
_swap(
"ix_videos_channel_pub_cover",
"ON videos (channel_id, published_at DESC NULLS LAST, id) "
"INCLUDE (duration_seconds, unavailable_since, via_explore, is_short, live_status)",
"ix_videos_channel_pub_desc",
)
def downgrade() -> None:
with op.get_context().autocommit_block():
_swap(
"ix_videos_channel_pub_desc",
"ON videos (channel_id, published_at DESC NULLS LAST, id ASC)",
"ix_videos_channel_pub_cover",
)
@@ -0,0 +1,86 @@
"""videos: an index per feed sort, so a sorted page stops after its rows (E31 S4e)
The feed's views, duration and title sorts had no index. Every page (the first and each next cursor)
read the whole videos heap, filtered it and kept a top-N: on prod ~350 ms a page for views/duration,
550–780 ms for title (the ICU comparison on every row), 2.3 s for page 2 when the copies of one request
queued. With an index in the exact ORDER BY (`feedquery.order_by`: the key, NULLS LAST, then id) the
planner walks it and stops after the page. On a prod-copy localdev every plain sort fell from 180–310 ms
to ~1 ms, deep pages (20) to 3–5 ms, and narrow filters (a tag, a channel, a year, nothing found) stayed
at 0.3–25 ms.
One index per direction: a backward scan of `DESC NULLS LAST` gives `ASC NULLS FIRST`, not the
`ASC NULLS LAST` the asc sorts ask for. The title pair is per reader language (`app.collation.ICU`):
an index only serves an ORDER BY in its own collation.
Cost: ~130 MB (localdev: 10 + 10 + 8 + 8 + 4 × 23). Writes stay cheap: view_count and duration are
written by the enrich, which rewrites indexed columns already (the revalidation job only touches
last_checked_at), so no HOT update is lost. A search combined with one of these sorts can now walk the
index too: a term matching ~2 % of the videos took 130–200 ms instead of 7 (still under the plain sort it
replaces); rarer and broader terms stay fast. Fencing the index off under a search made broad terms
(`m`, `the`) take 2 s, so it is left to the planner.
The title indexes are the schema's first ones on an ICU collation: an ICU upgrade in the Postgres image
reorders them, and `app.collation.heal_drift` rebuilds them at startup when it sees one.
tests/test_feed_sort_indexes.py holds each sort and its index together.
CONCURRENTLY (autocommit_block) builds them without blocking a live prod; IF (NOT) EXISTS keeps the drift
test's downgrade → re-upgrade round trip idempotent.
"""
from alembic import op
from sqlalchemy import text
revision: str = "0096_videos_sort_indexes"
down_revision: str | None = "0095_videos_channel_cover_index"
branch_labels: str | None = None
depends_on: str | None = None
# name → the indexed ORDER BY (feedquery.sort_keys + order_by: key NULLS LAST, then id).
INDEXES = {
"ix_videos_views_desc": "view_count DESC NULLS LAST, id",
"ix_videos_views_asc": "view_count ASC NULLS LAST, id",
"ix_videos_duration_desc": "duration_seconds DESC NULLS LAST, id",
"ix_videos_duration_asc": "duration_seconds ASC NULLS LAST, id",
"ix_videos_title_en": '(lower(title) COLLATE "en-x-icu") ASC NULLS LAST, id',
"ix_videos_title_en_desc": '(lower(title) COLLATE "en-x-icu") DESC NULLS LAST, id',
"ix_videos_title_hu": '(lower(title) COLLATE "hu-x-icu") ASC NULLS LAST, id',
"ix_videos_title_hu_desc": '(lower(title) COLLATE "hu-x-icu") DESC NULLS LAST, id',
}
def _valid(name: str) -> bool | None:
"""The index's `indisvalid`, or None when there is no index of that name."""
row = (
op.get_bind()
.execute(
text(
"SELECT i.indisvalid FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid "
"WHERE c.relname = :name"
),
{"name": name},
)
.first()
)
return None if row is None else bool(row[0])
def _build(name: str, ddl: str) -> None:
"""CREATE INDEX CONCURRENTLY `name` (`ddl` is what follows the name), re-runnable. A build that
failed part-way leaves an INVALID index under the name, and `IF NOT EXISTS` would keep it: the
planner never uses it, and nothing says so (E31 S5 review). Such a leftover is dropped and built
again."""
if _valid(name) is False:
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {name}")
op.execute(f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {name} {ddl}")
def upgrade() -> None:
with op.get_context().autocommit_block():
for name, columns in INDEXES.items():
_build(name, f"ON videos ({columns})")
def downgrade() -> None:
with op.get_context().autocommit_block():
for name in INDEXES:
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {name}")
+14 -9
View File
@@ -13,16 +13,19 @@ Three layers, by *who* runs the code — not by feature:
response models, status-code mapping, serialization. It owns logic that runs *only* on a request. response models, status-code mapping, serialization. It owns logic that runs *only* on a request.
Business logic that a request happens to trigger does **not** live here if anything else runs it too. Business logic that a request happens to trigger does **not** live here if anything else runs it too.
2. **`plex/`, `downloads/`, `sync/` — domain logic shared by more than one caller.** Anything the 2. **`plex/`, `downloads/`, `sync/`, `issues/` — domain logic shared by more than one caller.** Anything the
**scheduler or the worker** runs (not just a request) lives in one of these domain packages, so the **scheduler or the worker** runs (not just a request) lives in one of these domain packages, so the
route, the worker, and the scheduler share one definition. Examples from this codebase: route, the worker, and the scheduler share one definition. Examples from this codebase:
`downloads/service.py` owns enqueue + the job state transitions (routes call it); `plex/sync.py` is `downloads/service.py` owns enqueue + the job state transitions (routes call it); `plex/sync.py` is
run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline. run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline;
`issues/bundle.py` is the one definition of a bug report's files, for the zip export and the tracker.
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`, 3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`,
`notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, … are single-file primitives many `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `logs` (the one logging
layers import. They depend on nothing above them (a core module must never import a `routes/…` configuration), `logsink` (its `log_events` table), `diagnostics` (a user's consented log
module). level), `debugsession` (a recording that tags its user's lines, E16) and `collation` (A→Z in the
reader's alphabet, E30), … are single-file primitives many layers import. They depend on nothing above them (a core
module must never import a `routes/…` module).
**Extraction trigger: the SECOND consumer.** Keep new logic inline in its first caller. The moment a **Extraction trigger: the SECOND consumer.** Keep new logic inline in its first caller. The moment a
*second* caller needs it (a worker as well as a route; a scheduler as well as a route), extract it down *second* caller needs it (a worker as well as a route; a scheduler as well as a route), extract it down
@@ -69,7 +72,9 @@ mutation it records, so an audit entry can never exist without its change (or vi
## The gate ## The gate
Everything below must be green before a change is done (run via `siftlode check`): Everything below must be green before a change is done (run via `siftlode check`):
`ruff` (backend) · `tsc` ×3 + `eslint` + `prettier` + `knip` (frontend) · `vitest` · `pytest`. The `ruff` + `basedpyright` (backend) · the lock check + `tsc` ×3 + `eslint` + `prettier` + `knip` +
pytest lane runs in `backend/docker-compose.test.yml` — an ephemeral Postgres + the backend tree `vite build` (frontend) · `vitest` · `pytest`. The pytest lane runs in `backend/docker-compose.test.yml`
bind-mounted — so the DB-backed integration tests (the `db` fixture) actually run; `SIFTLODE_TEST_ — an ephemeral Postgres + the backend tree bind-mounted — so the DB-backed integration tests (the `db`
REQUIRE_DB=1` there turns a would-be silent skip into a hard failure. See `docs/development.md`. fixture) actually run; a missing database is a hard failure, not a silent skip, unless a hand run asks
for pure-logic only with `SIFTLODE_TEST_REQUIRE_DB=0`. See `docs/development.md`, and `docs/gates.md`
for what each gate deliberately leaves out.
+3
View File
@@ -58,6 +58,9 @@ class AuditAction:
DOWNLOAD_CREDENTIAL_DELETE = "download_credential_delete" DOWNLOAD_CREDENTIAL_DELETE = "download_credential_delete"
# The log itself was cleared (a single row survives the clear as tamper-evidence) # The log itself was cleared (a single row survives the clear as tamper-evidence)
AUDIT_CLEAR = "audit_clear" AUDIT_CLEAR = "audit_clear"
# E16: a user sent a debug session's issue to the tracker — the one act that takes their log
# off this instance, so it is recorded (target = the session, summary = the issue's URL).
ISSUE_CREATE = "issue_create"
def record( def record(
+43 -13
View File
@@ -1,19 +1,20 @@
import logging
import httpx import httpx
from fastapi import BackgroundTasks, Depends, HTTPException, Request from fastapi import BackgroundTasks, Depends, HTTPException, Request
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.requests import HTTPConnection from starlette.requests import HTTPConnection
from app import debugsession
from app import email as email_mod from app import email as email_mod
from app import logs
from app import mailtext from app import mailtext
from app import sysconfig from app import sysconfig
from app.config import settings from app.config import settings
from app.db import get_db from app.db import get_db
from app.models import Invite, User from app.models import Invite, LogEvent, User
from app.security import decrypt_optional from app.security import decrypt_optional
from app.utils import canonical_email from app.utils import canonical_email
from app.refusal import refused
PASSWORD_MIN_LEN = 10 PASSWORD_MIN_LEN = 10
@@ -37,7 +38,7 @@ READ_SCOPES = f"{BASE_SCOPES} {READ_SCOPE}"
WRITE_SCOPES = f"{BASE_SCOPES} {READ_SCOPE} {WRITE_SCOPE}" WRITE_SCOPES = f"{BASE_SCOPES} {READ_SCOPE} {WRITE_SCOPE}"
_YOUTUBE_SCOPES = frozenset({READ_SCOPE, WRITE_SCOPE}) _YOUTUBE_SCOPES = frozenset({READ_SCOPE, WRITE_SCOPE})
log = logging.getLogger("siftlode.auth") log = logs.get_logger(__name__)
def google_enabled(db: Session) -> bool: def google_enabled(db: Session) -> bool:
@@ -220,7 +221,7 @@ def revoke_google_token(token: str | None) -> None:
timeout=10, timeout=10,
) )
log.info("Google token revoke: status=%s", resp.status_code) log.info("Google token revoke: status=%s", resp.status_code)
except Exception: except Exception: # noqa: BLE001 — a revoke we cannot make must not fail the sign-out
log.warning("Google token revoke failed", exc_info=True) log.warning("Google token revoke failed", exc_info=True)
@@ -245,6 +246,12 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None:
) )
# Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table. # Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table.
db.execute(delete(User).where(User.id == user_id)) db.execute(delete(User).where(User.id == user_id))
# Stored log events name the account by id with no foreign key to cascade from (models.LogEvent),
# so they go explicitly — they are request history, i.e. personal data. Forgotten FIRST: this very
# request's access line, a queued row, a later background task would otherwise put rows back
# under the id after this DELETE (logs.forget_user).
logs.forget_user(user_id)
db.execute(delete(LogEvent).where(LogEvent.user_id == user_id))
# Every canonically-equal invite goes, not just the literal spelling: is_allowed matches on the # Every canonically-equal invite goes, not just the literal spelling: is_allowed matches on the
# canonical form, so leaving a sibling row behind would keep granting access to a mailbox whose # canonical form, so leaving a sibling row behind would keep granting access to a mailbox whose
# account was just erased — and prod really does carry approved pairs for one mailbox. # account was just erased — and prod really does carry approved pairs for one mailbox.
@@ -297,6 +304,13 @@ def resolved_user_id(conn: HTTPConnection, *, strict: bool = True) -> tuple[int
try: try:
hid = int(hdr) hid = int(hdr)
except ValueError: except ValueError:
# Under `strict` this becomes a 401 and the tab authenticates as nobody, which reads
# in the browser exactly like a session that expired. The value itself stays out —
# it came from a request, and `wallet` says which accounts were actually on offer.
log.debug(
"the active-account header could not be read as an id",
extra=logs.event("auth.account_header.invalid", accounts=len(wallet)),
)
hid = None hid = None
if hid is not None and hid in wallet: if hid is not None and hid in wallet:
return hid, hid == default_id return hid, hid == default_id
@@ -328,19 +342,19 @@ def current_user(request: Request, db: Session = Depends(get_db)) -> User:
def _session_user(request: Request, db: Session, user_id: int | None, is_default: bool) -> User: def _session_user(request: Request, db: Session, user_id: int | None, is_default: bool) -> User:
if not user_id: if not user_id:
raise HTTPException(status_code=401, detail="Not authenticated") raise refused(401, "not_authenticated")
user = db.get(User, user_id) user = db.get(User, user_id)
if user is None or not user.is_active or user.is_suspended: if user is None or not user.is_active or user.is_suspended:
# Suspended/deactivated mid-session → end it so the block takes effect at once. # Suspended/deactivated mid-session → end it so the block takes effect at once.
_end_account_session(request, user_id, is_default) _end_account_session(request, user_id, is_default)
raise HTTPException(status_code=401, detail="Not authenticated") raise refused(401, "not_authenticated")
# SA4: reject a cookie whose recorded epoch is behind the account's current one (revoked by a # SA4: reject a cookie whose recorded epoch is behind the account's current one (revoked by a
# password reset/change or "log out everywhere"). A cookie with no recorded epoch (pre-SA4, or an # password reset/change or "log out everywhere"). A cookie with no recorded epoch (pre-SA4, or an
# account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too. # account signed in before this shipped) is treated as epoch 0 — so the FIRST bump revokes it too.
epochs = request.session.get("epochs") or {} epochs = request.session.get("epochs") or {}
if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0): if int(epochs.get(str(user_id), 0)) != (user.session_epoch or 0):
_end_account_session(request, user_id, is_default) _end_account_session(request, user_id, is_default)
raise HTTPException(status_code=401, detail="Not authenticated") raise refused(401, "not_authenticated")
# Always keep the session default in the switchable list — covers sessions created before # Always keep the session default in the switchable list — covers sessions created before
# multi-session existed (their account_ids was never seeded), so the switcher isn't empty. # multi-session existed (their account_ids was never seeded), so the switcher isn't empty.
default_id = request.session.get("user_id") default_id = request.session.get("user_id")
@@ -348,6 +362,17 @@ def _session_user(request: Request, db: Session, user_id: int | None, is_default
if default_id and default_id not in accounts: if default_id and default_id not in accounts:
accounts.append(default_id) accounts.append(default_id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] 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. The user's consented
# diagnostics level rides along: their requests are logged — and stored — in that detail (S5).
# So does a debug session the request names (E16): only the caller's own open one counts, and a
# header naming anything else is ignored rather than refused — a stale tab must keep working.
session = debugsession.from_header(db, user, request.headers.get(debugsession.HEADER))
level, until = debugsession.level_for(user, session)
logs.set_user(user.id, level, until, session.id if session else None)
request.state.user_id = user.id
return user return user
@@ -361,12 +386,20 @@ def optional_current_user(request: Request, db: Session = Depends(get_db)) -> Us
user_id, is_default = resolved_user_id(request, strict=False) user_id, is_default = resolved_user_id(request, strict=False)
try: try:
return _session_user(request, db, user_id, is_default) return _session_user(request, db, user_id, is_default)
# silent-ok: this is the anonymous case as much as the refused one — every landing page hit
# comes through here. The second attempt below is the handling, and it reports.
except HTTPException: except HTTPException:
if is_default: if is_default:
return None return None
try: try:
return _session_user(request, db, *resolved_user_id(request, strict=False)) return _session_user(request, db, *resolved_user_id(request, strict=False))
except HTTPException: except HTTPException:
# Both the pinned account AND the default one were refused, so the tab is signed out
# without being told why — and it got here from a session that named a real account.
log.info(
"a session was resolved to nobody: neither the named nor the default account held",
extra=logs.event("auth.session.probe_rejected", user_id=user_id),
)
return None return None
@@ -390,10 +423,7 @@ def require_write_scope(user: User = Depends(require_human)) -> User:
checks that had drifted into three different 403 messages across channels/playlists. Layers on checks that had drifted into three different 403 messages across channels/playlists. Layers on
`require_human` because writing to YouTube needs a real, non-demo identity.""" `require_human` because writing to YouTube needs a real, non-demo identity."""
if not has_write_scope(user): if not has_write_scope(user):
raise HTTPException( raise refused(403, "youtube_editing_off")
status_code=403,
detail="Enable YouTube editing in Settings first.",
)
return user return user
@@ -401,7 +431,7 @@ def admin_user(user: User = Depends(current_user)) -> User:
"""Dependency: require the signed-in user to be an admin. Single source of truth for the """Dependency: require the signed-in user to be an admin. Single source of truth for the
admin gate — every admin route/action depends on this rather than re-checking the role.""" admin gate — every admin route/action depends on this rather than re-checking the role."""
if user.role != "admin": if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin only") raise refused(403, "admin_only")
return user return user
+231
View File
@@ -0,0 +1,231 @@
"""The global request-body cap (SEC-20).
**The hole.** FastAPI reads a declared body IN FULL before auth or any other dependency runs — the
body parameter has to be parsed to be passed in. So `POST /auth/password-login` drains a 32 MiB
upload from an anonymous caller and only then answers 401. 70 of the 72 body-declaring routes behave
the same way: one unauthenticated client can spend the process's memory and time at will.
**Why a middleware and not `capped_body` on every route.** `routes/_common.py:capped_body` is the
right tool where a route genuinely takes bulk bytes, and the two routes that use it use it
correctly. But putting it on all 70 others would (a) fight the canonical "Request body → a Pydantic
model" row, since capping means taking the body as raw bytes and parsing it by hand, and (b) leave
the NEXT route added uncovered, because nothing would remind its author. A default that has to be
opted into is not a default. This module inverts it: the cap applies to everything, and the few
routes that need more opt OUT, in one list, next to the code that reads it.
**Why raw ASGI and not `@app.middleware("http")`.** Starlette's BaseHTTPMiddleware cannot wrap
`receive`, so it can only read `Content-Length` — which a client may omit entirely (chunked) or
simply understate. The bytes have to be counted as they arrive, which is the same rule `capped_body`
already applies per-route, and counting needs the receive channel.
**Why the refusal is byte-identical to `capped_body`'s.** Two shapes for one fact is how the
`cancelled`/`canceled` bug happened. The 413 body here is built through the same `JSONResponse`
FastAPI renders `HTTPException(413, {"code": "too_large"})` with — not a hand-rolled `json.dumps`,
whose default separators differ by two bytes — and `tests/test_body_limit.py` compares the RAW BYTES
of the two against each other rather than against a literal, so they cannot drift apart. The same
reasoning decides where the middleware is registered (`main.py`): just inside CORS, so this 413
carries the same headers `capped_body`'s does.
**No admin knob, deliberately.** The ceiling is a constant, not a `sysconfig` value: an attacker-
facing limit that an admin can raise from the UI is a limit that can be raised by whoever gets the
admin session, and nobody has asked to tune it. If a self-hoster ever needs a different ceiling it
belongs in `config.py` (env, set before the process starts), not in the database.
"""
import re
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from app import debugsession
from app.config import settings
# 1 MiB. The largest body any route on the default ceiling legitimately takes is 512 KiB (the client
# log sink, `routes/diagnostics.py:BODY_MAX_BYTES`), so this leaves 2x headroom — a ceiling that
# refuses a legitimate request would be a worse bug than the one this module fixes. That ratio is
# the load-bearing fact, so `test_body_limit.py` holds it rather than the figure above.
DEFAULT_MAX_BODY_BYTES = 1024 * 1024
# The routes that need MORE than the default, as (method, path TEMPLATE) → their own ceiling. The
# template, not a concrete path, so the list reads like the route decorators it mirrors and
# `test_body_limit.py` can assert that each one still resolves to a real route: a renamed route
# fails the gate instead of silently losing its allowance in prod.
#
# These are RAISED CEILINGS, NOT EXEMPTIONS, and the difference is the whole point. An exemption
# would mean "this route is counted by something else" — which is only true when the route's own
# limit applies to the bytes as they ARRIVE. That, in turn, is only true of a route with NO DECLARED
# BODY PARAMETER: FastAPI parses and spools a declared body BEFORE the first dependency runs, so a
# route that declares one has already stored the bytes by the time its own cap — or its auth — gets
# a say.
#
# Both listed routes now satisfy that. The screenshot route always did (`capped_body` streams it,
# and the owner check resolves first). The upload route did NOT until P1: it declared
# `file: UploadFile = File(...)`, so an anonymous caller could make the process write the whole
# per-file allowance to temp before `require_human` refused them — this middleware bounded that
# number but could not stop the writing. It now reads its own body from a dependency declared after
# the auth one, so its in-handler cap measures bytes as they arrive, like everything else here.
#
# That rule is no longer only written here: `test_body_limit.py` parses the routes and fails the
# gate if any route in this dict declares a body parameter. A comment could not have stopped the
# defect it describes; the test can.
#
# A route belongs here ONLY IF ITS OWN CAP IS LARGER THAN THE DEFAULT. That is why
# `POST /api/diagnostics/events` is absent: its `capped_body` limit is 512 KiB, already below the
# default, so listing it could only widen what it accepts.
ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = {
# Ingesting a local file. The body IS the file since P1 — no multipart envelope to allow for —
# so this ceiling and the route's own cap count exactly the same bytes. `sysconfig`'s
# `download_upload_max_bytes` is bounded by this same constant (see sysconfig.SPECS), so an
# admin can only ever LOWER the route's cap below the edge, never raise it past one.
#
# WHICH LAYER ANSWERS, measured rather than assumed (deep-review round 1):
# * knob LOWERED (an override below the default) — the route's cap is the smaller number, so
# `capped_stream_to` trips first and answers 422 `upload_too_large` carrying the admin's own
# figure, after draining the rest of the body so the client can read it.
# * knob at the DEFAULT — the two numbers are EQUAL, and the edge always wins: a declared
# Content-Length over the cap is refused here before a byte is read, and an undeclared body
# trips this counter on the same chunk the route would have tripped on. The route's 422 is
# unreachable in that configuration, and no amount of headroom would change it — the
# declared-length check fires first for any body big enough to matter, and the route's drain
# pushes the count past the edge for the rest.
#
# So this 413 is a refusal a real user meets on a default install, not a theoretical edge, and
# it has to be as readable as the route's own: the client localizes `too_large` (`errors.tooLarge`)
# exactly as it does `upload_too_large`, and the upload dialog refuses an over-cap file before
# sending it at all, off the `upload_max_bytes` that `GET /api/downloads/usage` reports.
("POST", "/api/downloads/upload"): settings.download_upload_max_bytes,
("POST", "/api/debug-sessions/{sid}/screenshots"): debugsession.SCREENSHOT_MAX_BYTES,
}
# The refusal, byte-for-byte what `capped_body`'s `HTTPException(413, {"code": "too_large"})` renders
# as — built through the same JSONResponse so the separators match too, and asserted against a real
# route's raw bytes in `test_body_limit.py` rather than against a literal here.
TOO_LARGE_BODY = JSONResponse({"detail": {"code": "too_large"}}, status_code=413).body
_PATH_PARAM = re.compile(r"\{[^/{}]+\}")
def template_to_regex(template: str) -> re.Pattern[str]:
"""`/api/debug-sessions/{sid}/screenshots` → a regex matching one concrete path.
A path parameter matches `[^/]+` — one segment, never across a `/`, so `{sid}` cannot swallow a
deeper path and hand an exemption to a route that was never granted one.
"""
literals = [re.escape(part) for part in _PATH_PARAM.split(template)]
return re.compile("^" + "[^/]+".join(literals) + "$")
def _declared_length(scope: Scope) -> int | None:
"""The `Content-Length` header as an int, or None when absent or unparseable (a chunked body has
none, and a malformed one is not a number we may trust — both fall through to counting)."""
for name, value in scope.get("headers", ()):
if name == b"content-length":
try:
return int(value)
except ValueError:
# silent-ok: a malformed Content-Length is the caller's doing, not a fault of ours,
# and it is about to be answered anyway — returning None drops the request into the
# counting path, which refuses on the real bytes. Logging it would hand an anonymous
# client a free line in our log for every request it cares to malform.
return None
return None
async def _send_too_large(send: Send) -> None:
await send(
{
"type": "http.response.start",
"status": 413,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(TOO_LARGE_BODY)).encode()),
],
}
)
await send({"type": "http.response.body", "body": TOO_LARGE_BODY})
class BodyLimitMiddleware:
"""Refuses any request body over its route's ceiling, before the application sees it.
Registered just inside CORS in `main.py` — so the 413 is decorated like every other response
and a browser can actually read it — but outside the session, the setup gate and every auth
dependency, none of which run for a request that is already too large.
Every request gets a finite ceiling: `ROUTE_MAX_BODY_BYTES` raises it for the two routes that
genuinely take bulk bytes, and there is no path that skips counting altogether.
"""
def __init__(
self,
app: ASGIApp,
*,
limit: int = DEFAULT_MAX_BODY_BYTES,
routes: dict[tuple[str, str], int] | None = None,
) -> None:
self.app = app
self.limit = limit
self._routes = tuple(
(method, template_to_regex(path), cap)
for (method, path), cap in (ROUTE_MAX_BODY_BYTES if routes is None else routes).items()
)
def limit_for(self, method: str, path: str) -> int:
"""This request's ceiling: the route's own if it has one, else the global default. Every
request gets a finite number — there is no path through here that is simply not counted."""
for m, rx, cap in self._routes:
if m == method and rx.match(path):
return cap
return self.limit
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
limit = self.limit_for(scope.get("method", ""), scope.get("path", ""))
declared = _declared_length(scope)
if declared is not None and declared > limit:
# Refused on the declaration alone: nothing downstream is called, and not one byte of
# the body is read.
await _send_too_large(send)
return
seen = 0
refused = False
started = False
async def counting_receive() -> Message:
nonlocal seen, refused
message = await receive()
if message["type"] != "http.request":
return message
seen += len(message.get("body", b""))
if seen > limit:
# The app is blocked on this receive, so it has not answered yet. Telling it the
# client went away is the only in-band way to stop it reading: `request.stream()`
# turns an `http.disconnect` into ClientDisconnect, which unwinds the handler. Our
# own 413 goes out below, once the app has finished unwinding.
refused = True
return {"type": "http.disconnect"}
return message
async def guarded_send(message: Message) -> None:
nonlocal started
if refused and not started:
return # discard the app's answer to the disconnect; the 413 below is the response
started = True
await send(message)
try:
await self.app(scope, counting_receive, guarded_send)
except Exception: # only swallowed when WE caused it, see below
# ClientDisconnect (or whatever a handler raises on a truncated read) is the expected
# consequence of the disconnect we just faked, and the caller must get the 413 rather
# than a 500. Anything raised when we did NOT refuse is a real failure and is re-raised
# untouched.
if not refused:
raise
if refused and not started:
await _send_too_large(send)
+83
View File
@@ -0,0 +1,83 @@
"""Alphabetical order in the reader's language (E30 S3).
Why not the database default: the Postgres image is Alpine, and musl's `en_US.utf8` collation is BYTE
order — every accented initial sorted after "Z" ("Zorró < Életrajzi < Ötödik"), in every A→Z list the
server orders. Postgres ships the ICU collations on the same image (dev, test, prod alike), so a text
sort names one: Hungarian puts "Ö" after "O" and "Cs" after "Cu", English folds the accents in.
`collated` wraps an ORDER BY expression — and because a keyset cursor compares with the SAME expression
(`feedquery.sort_keys`), the cursor pages in the same order it sorted in. `sorted_words` orders a Python
list the same way through the database, so a list built in Python matches one built in SQL exactly.
The feed's title sorts ride indexes built in these collations (migration 0096), and an index keeps the
order of the ICU that built it. `postgres:16-alpine` is a floating tag: an image update can bring a newer
ICU, after which those indexes disagree with the comparisons a query makes (a keyset page skips or
repeats a title). `heal_drift` rebuilds them at startup once Postgres reports the version moved."""
from typing import Any
from sqlalchemy import Engine, SQLColumnExpression, column, func, select, text, values
from sqlalchemy.orm import Session
from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.types import String
from app import logs
from app.mailtext import normalize
log = logs.get_logger(__name__)
ICU: dict[str, str] = {"en": "en-x-icu", "hu": "hu-x-icu"}
def collation(lang: str | None) -> str:
"""The ICU collation for a UI language; anything unknown sorts as English."""
return ICU[normalize(lang)]
def collated(expr: SQLColumnExpression[Any], lang: str | None) -> ColumnElement[Any]:
"""`expr` compared in the reader's alphabet. Case-folded, so "apple" and "Apple" sit together."""
return func.lower(expr).collate(collation(lang))
def sorted_words(db: Session, words: list[str], lang: str | None) -> list[str]:
"""`words` in the reader's alphabetical order, by the same ICU collation as `collated`."""
if not words:
return []
w = values(column("w", String), name="words").data([(x,) for x in words])
return list(db.scalars(select(w.c.w).order_by(collated(w.c.w, lang), w.c.w)))
def heal_drift(engine: Engine) -> list[str]:
"""Rebuild every index on an ICU collation whose library version moved, then record the new version.
Returns the rebuilt indexes. CONCURRENTLY: the feed keeps reading while it runs."""
with engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn:
drifted = conn.execute(
text(
"SELECT oid, collname FROM pg_collation WHERE collname = ANY(:names) "
"AND collversion IS DISTINCT FROM pg_collation_actual_version(oid)"
),
{"names": list(ICU.values())},
).all()
if not drifted:
return []
indexes = list(
conn.execute(
text(
"SELECT DISTINCT d.objid::regclass::text FROM pg_depend d "
"JOIN pg_class c ON c.oid = d.objid AND c.relkind = 'i' "
"WHERE d.classid = 'pg_class'::regclass "
"AND d.refclassid = 'pg_collation'::regclass AND d.refobjid = ANY(:oids)"
),
{"oids": [row.oid for row in drifted]},
).scalars()
)
log.warning(
"ICU collation version changed (%s): rebuilding %d indexes",
", ".join(row.collname for row in drifted),
len(indexes),
)
for name in indexes:
conn.execute(text(f"REINDEX INDEX CONCURRENTLY {name}"))
for row in drifted:
conn.execute(text(f'ALTER COLLATION "{row.collname}" REFRESH VERSION'))
return indexes
+93 -6
View File
@@ -1,6 +1,7 @@
from pathlib import Path from pathlib import Path
from typing import Literal, get_args, get_origin
from pydantic import model_validator from pydantic import Field, ValidationInfo, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# Shipped placeholder; production must override it. Used by the startup guard below. # Shipped placeholder; production must override it. Used by the startup guard below.
@@ -15,11 +16,40 @@ def _version_from_file() -> str:
v = p.read_text(encoding="utf-8").strip() v = p.read_text(encoding="utf-8").strip()
if v: if v:
return v return v
# silent-ok: this runs at import, and `app.logs` imports THIS module — a log call here
# would be a circular import. The loop tries the other location, then falls back to "dev".
except OSError: except OSError:
continue continue
return "dev" 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 ()
def canonical_choice(options: tuple[str, ...], raw: object) -> str | None:
"""The option `raw` names, in its canonical spelling (any letter case, surrounding space
ignored), or None. The ONE matching rule for a choice value from the env and from the admin
API, so a spelling one accepts the other cannot refuse."""
folded = str(raw).strip().casefold()
return next((option for option in options if option.casefold() == folded), None)
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@@ -85,10 +115,13 @@ class Settings(BaseSettings):
search_daily_limit_per_user: int = 70 search_daily_limit_per_user: int = 70
# Source for live YouTube search: "scrape" (InnerTube, no API quota) or "api" (search.list, # 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. # 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. # Recent-first backfill: how far back to fetch on the first pass per channel.
backfill_recent_max_videos: int = 100 backfill_recent_max_videos: int = 100
backfill_recent_max_days: int = 365 backfill_recent_max_days: int = 365
# A channel whose RSS feed has not answered for this many hours gets its newest uploads read
# through the Data API instead (1 unit), at most once per this many hours. 0 = never.
rss_fallback_hours: int = 2
# Shorts are confirmed by probing youtube.com/shorts/<id>. Only videos at or below # Shorts are confirmed by probing youtube.com/shorts/<id>. Only videos at or below
# this duration (and not livestreams) are probed; longer videos are never Shorts. # this duration (and not livestreams) are probed; longer videos are never Shorts.
@@ -155,6 +188,9 @@ class Settings(BaseSettings):
# per-client responses are flaky, and android_vr can get bot-flagged under heavy load) the # per-client responses are flaky, and android_vr can get bot-flagged under heavy load) the
# worker retries with the FALLBACK set — the POT-capable web clients (web needs the token + # worker retries with the FALLBACK set — the POT-capable web clients (web needs the token +
# Deno) plus android for audio. Both admin-tunable when YouTube shifts what works. # Deno) plus android for audio. Both admin-tunable when YouTube shifts what works.
# A SIGNED-IN download (the user added YouTube cookies) keeps only the cookie-capable clients
# of these two sets — yt-dlp drops the rest, so android_vr / android / ios never run there.
# See adapters.YouTubeAdapter._COOKIE_CAPABLE.
download_player_clients: str = "android_vr" download_player_clients: str = "android_vr"
download_player_clients_fallback: str = "web_safari,web,android" download_player_clients_fallback: str = "web_safari,web,android"
@@ -211,7 +247,11 @@ class Settings(BaseSettings):
# Per-file cap for local uploads (E7). Unlike a download (whose size is unknown until the worker # Per-file cap for local uploads (E7). Unlike a download (whose size is unknown until the worker
# finishes), an upload's size is known at ingest, so this bounds a single file directly; the # finishes), an upload's size is known at ingest, so this bounds a single file directly; the
# user's overall byte footprint is still governed by download_default_max_bytes. # user's overall byte footprint is still governed by download_default_max_bytes.
download_upload_max_bytes: int = 524_288_000 # 500 MiB # ge=1: this number is also the EDGE ceiling (`bodylimit.ROUTE_MAX_BODY_BYTES`), where 0 is a
# real limit of zero rather than the route's "no per-file cap" sentinel — an instance set to 0
# would refuse every upload, including the admin page's own way back. To lift the cap in
# practice, set a large number.
download_upload_max_bytes: int = Field(default=524_288_000, ge=1) # 500 MiB
# Retention: an asset expires this many days after its last access; the GC job then # Retention: an asset expires this many days after its last access; the GC job then
# deletes it (once no job references it) after an additional grace window. # deletes it (once no job references it) after an additional grace window.
download_retention_days: int = 30 download_retention_days: int = 30
@@ -220,11 +260,31 @@ class Settings(BaseSettings):
# Admin audit log: how long to keep rows (days; 0 = keep forever) + how often the GC runs. # Admin audit log: how long to keep rows (days; 0 = keep forever) + how often the GC runs.
audit_retention_days: int = 180 audit_retention_days: int = 180
audit_gc_minutes: int = 1440 audit_gc_minutes: int = 1440
# YouTube quota history (quota_events): days kept (0 = forever). A year, so the admin usage view's
# longest window (365 days) stays whole; the same daily cleanup job prunes it.
quota_retention_days: int = 365
# On-disk layout: "plex" (Channel/Season YYYY/… [id].ext + .nfo/poster) or "flat". # 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. # Default SponsorBlock (skip sponsor segments) for new custom profiles.
sponsorblock_default: bool = False 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: 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: LogFormat = "json"
# Also write events to the `log_events` table (app/logsink.py): WARNING and above, the request
# lines, and — once they exist — client batches. Off only where no table is wanted (the tests).
log_db_sink: bool = True
# Days a `log_events` row is kept (the privacy policy promises this), and how often the log_gc job
# enforces it — hourly, so a row never outlives the promise by more than an hour.
log_retention_days: int = 7
log_gc_minutes: int = 60
# --- Plex integration (optional) --- # --- Plex integration (optional) ---
# The Plex module plays the LOCAL physical media file directly (our own ffmpeg/direct-serve); # 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 # Plex is used only for metadata + optional watch-state sync. Requires the Plex server to be
@@ -262,11 +322,15 @@ class Settings(BaseSettings):
# re-encode is CPU-heavy (the VAAPI hardware path is far cheaper and could sustain more); direct-serve # re-encode is CPU-heavy (the VAAPI hardware path is far cheaper and could sustain more); direct-serve
# and remux (stream-copy) are not capped by this. # and remux (stream-copy) are not capped by this.
plex_max_transcodes: int = 4 plex_max_transcodes: int = 4
# Max concurrent playback sessions of ANY kind — remux (stream-copy) included. Cheap on the CPU but
# each one is a whole segmented copy of the film on the scratch volume, written faster than
# realtime. One account holds at most two regardless (plex/stream.py `_MAX_SESSIONS_PER_USER`).
plex_max_sessions: int = 8
# --- Transcode (playable="transcode") re-encode tuning (E6 S1) --- # --- Transcode (playable="transcode") re-encode tuning (E6 S1) ---
# Hardware-encoder policy: "auto" = VAAPI (h264_vaapi) when the iGPU render node is present AND the # 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 # 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`. # 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` # 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 # 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 # height (0 = keep source) so a CPU re-encode of a tall source stays affordable — the GPU path always
@@ -276,7 +340,7 @@ class Settings(BaseSettings):
# call, not a fix. Only affects files that go through remux/transcode — a direct-played file is # 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. # served untouched, but those are browser-native stereo anyway, not the 5.1 mixes this is for.
plex_audio_loudnorm: bool = False plex_audio_loudnorm: bool = False
plex_transcode_preset: str = "veryfast" plex_transcode_preset: TranscodePreset = "veryfast"
plex_transcode_crf: int = 20 plex_transcode_crf: int = 20
plex_transcode_max_height: int = 1080 plex_transcode_max_height: int = 1080
# Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls` # Where on-the-fly HLS remux segments are written (transient, reaped). Empty → a `.plex-hls`
@@ -294,10 +358,22 @@ class Settings(BaseSettings):
tmdb_api_base: str = "https://api.themoviedb.org/3" tmdb_api_base: str = "https://api.themoviedb.org/3"
# Metadata language for TMDB fetches (keyword/overview locale). Stored EN by default. # Metadata language for TMDB fetches (keyword/overview locale). Stored EN by default.
tmdb_language: str = "en-US" tmdb_language: str = "en-US"
# --- Bug reports (E16): where a debug session's issue goes. All three empty = no tracker; the
# composer still exports a zip. The repository must be PRIVATE — an issue carries the reporter's
# log — and app/issues/gitea.py refuses a public one. Runtime knobs (sysconfig, admin UI). ---
issues_gitea_url: str = ""
issues_gitea_repo: str = ""
issues_gitea_token: str = ""
# How often the TMDB enrichment job fetches a batch of not-yet-enriched Plex titles, in minutes. # How often the TMDB enrichment job fetches a batch of not-yet-enriched Plex titles, in minutes.
tmdb_enrich_interval_min: int = 120 tmdb_enrich_interval_min: int = 120
# Titles fetched per TMDB enrichment run (TMDB is fast + generously rate-limited). # Titles fetched per TMDB enrichment run (TMDB is fast + generously rate-limited).
tmdb_enrich_batch: int = 100 tmdb_enrich_batch: int = 100
# How often the people job (E30 S6) fetches a batch of person bios from TMDB and portraits through Plex,
# in minutes, and how many of each per run: ~18k linked people at 300 per 10 min is about ten hours
# once, then only the newcomers.
people_enrich_interval_min: int = 10
people_enrich_batch: int = 300
@property @property
def allowed_email_set(self) -> set[str]: def allowed_email_set(self) -> set[str]:
@@ -325,6 +401,17 @@ class Settings(BaseSettings):
https OAUTH_REDIRECT_URL as the signal for 'real deployment' vs local dev.""" https OAUTH_REDIRECT_URL as the signal for 'real deployment' vs local dev."""
return self.oauth_redirect_url.lower().startswith("https://") 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):
return canonical_choice(options, value) or value
return value
@model_validator(mode="after") @model_validator(mode="after")
def _enforce_production_secrets(self) -> "Settings": def _enforce_production_secrets(self) -> "Settings":
"""Fail fast rather than boot a public instance with the shipped placeholder """Fail fast rather than boot a public instance with the shipped placeholder
+55 -2
View File
@@ -1,6 +1,6 @@
from collections.abc import Iterator from collections.abc import Iterator
from sqlalchemy import create_engine from sqlalchemy import create_engine, text
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings from app.config import settings
@@ -12,7 +12,9 @@ class Base(DeclarativeBase):
# Pool sized above the sync-endpoint threadpool (~40) so a burst of short DB sessions can't starve on # Pool sized above the sync-endpoint threadpool (~40) so a burst of short DB sessions can't starve on
# checkout. The image proxies deliberately DON'T hold a connection during their slow external fetch # checkout. The image proxies deliberately DON'T hold a connection during their slow external fetch
# (see routes/plex/), so this headroom is only for the many brief sessions, not long ones. # (see routes/plex/), so this headroom is only for the many brief sessions, not long ones. The one
# long hold — a playlist sync's advisory claim, for a whole push — opens its own unpooled connection
# (sync/playlists.py `_claim_engine`) instead of drawing a third one from here.
engine = create_engine( engine = create_engine(
settings.database_url, settings.database_url,
pool_pre_ping=True, pool_pre_ping=True,
@@ -20,6 +22,10 @@ engine = create_engine(
max_overflow=30, max_overflow=30,
pool_timeout=30, pool_timeout=30,
future=True, future=True,
# A database error renders its bound parameters into its message — a title, an uploader, a
# file path, a URL — and that message is logged and stored as a job's error text. The SQL
# stays; the values are data, and the ids an operator needs are on the line already.
hide_parameters=True,
) )
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
@@ -30,3 +36,50 @@ def get_db() -> Iterator[Session]:
yield db yield db
finally: finally:
db.close() db.close()
def release_read_connection(db: Session) -> None:
"""End a request's READ transaction so its pooled connection goes back BEFORE a long await.
SQLAlchemy 2.0 autobegins on the first SELECT, so a request that has merely authenticated is
already holding one of the connections above — `idle in transaction` to Postgres, and nothing
times it out. A route that then awaits something slow (a request body still arriving, an
external fetch) pins it for the whole wait, which is precisely the invariant the pool is sized
on in the comment above. Measured: the next checkout after this call reuses the very backend
that was released, and a 1.8 s upload goes from one sustained `idle in transaction` to none.
Every route that reads its own body from a dependency needs this, and `test_body_limit.py`
holds that — the three that do are the ones with a raised (or self-imposed) body ceiling, and
they got here one at a time, which is how the upload route ended up the only one fixed.
The caller must have NOTHING PENDING, and that is checked rather than described. The session is
shared with every dependency that ran before, so a rollback here would silently discard a write
one of them staged — `service.enqueue` already learned that lesson the expensive way and uses a
SAVEPOINT for it. CLAUDE.md: a rule stated in a comment must also be enforced in code.
TWO checks, because the obvious one is blind to the shapes this codebase actually writes in.
`db.new`/`deleted`/`dirty` hold only UNFLUSHED unit-of-work: the moment a helper calls
`db.flush()` — CLAUDE.md's canonical split is "the handler commits; helpers flush" — the object
is persistent with no unflushed history and all three sets are empty again, and Core DML
(`db.execute(update(...))`) never enters them at all. Measured: after `add`+`flush` the sets are
empty, the old check passed, and the rollback lost the row. So the transaction is asked
directly. Postgres assigns a transaction id on the first WRITE and never for a read, so
`pg_current_xact_id_if_assigned()` is NULL for exactly the state this function is safe in — and
it sees a raw `text("UPDATE …")` too, which no session-level bookkeeping would.
"""
if db.new or db.deleted or db.dirty:
raise RuntimeError(
"release_read_connection() with pending work: something ahead of this dependency "
"staged a write, which ending the transaction would discard. Commit it first, or do "
"it after the await."
)
if (
db.in_transaction()
and db.execute(text("SELECT pg_current_xact_id_if_assigned()")).scalar() is not None
):
raise RuntimeError(
"release_read_connection() on a transaction that has already WRITTEN: something ahead "
"of this dependency flushed a row or ran an UPDATE/INSERT/DELETE, which ending the "
"transaction would discard. Commit it first, or do it after the await."
)
db.rollback()
+485
View File
@@ -0,0 +1,485 @@
"""Debug sessions (E16): Start → reproduce → Stop, and the evidence a bug report is built from.
While a session is open its user's browser names it in `X-Debug-Session` on every request. The auth
dependency accepts the header only for the caller's OWN open session (anything else is ignored, never
an error — a stale tab must not break the app) and then logs that request at the session's level with
every line tagged by its id; the browser tags its own events the same way. The issue is later built
from exactly the rows carrying the tag, so what the review list shows is what gets sent.
Starting a session IS the consent for its level, for its duration: the start dialog carries the
consent text. It needs no change to the user's own diagnostics level, and none is made — when the
session ends the level in force is simply the user's own again.
A session is capped at `MAX_MINUTES`, set by the server at Start. An open one past its cap is over
whether or not anything has closed it yet: it reads as over at once, a new Start closes it, and the
hourly log job sweeps the rest. Sessions go with the logs they point at, after `log_retention_days`.
Core layer: imports `models`, `diagnostics`.
"""
import secrets
from datetime import datetime, timedelta, timezone
from enum import StrEnum
from typing import Literal, get_args
from uuid import uuid4
from sqlalchemy import and_, delete, func, not_, select, update
from sqlalchemy.orm import Session, defer
from app import diagnostics
from app.models import DebugScreenshot, DebugSession, LogEvent, User
HEADER = "x-debug-session"
MAX_MINUTES = diagnostics.SESSION_MINUTES
SCREENSHOT_MAX_BYTES = 10 * 1024 * 1024
SCREENSHOTS_MAX = 8
# What one user may keep in screenshots across ALL their sessions (until retention takes them): the
# per-session cap alone would let Start → 8 images → Stop repeat without end.
SCREENSHOT_USER_MAX_BYTES = 100 * 1024 * 1024
# The screenshot types kept, and the extension each is exported and sent under — one table, so an
# accepted type always has a name.
IMAGE_TYPES = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}
# What a screenshot is called in the zip and on the issue. `issues/bundle.py` builds the names from
# this and `IMAGE_TYPES`; this module and `issues/gitea.py` read them back to tell a screenshot's
# link from a log's. One definition, because three copies of a name is how the next rename breaks
# `images_pending` into silence.
SCREENSHOT_PREFIX = "screenshot-"
# Rows an issue can carry. A 60-minute session at Teljes is a few thousand rows; past this the
# bundle says how many it left out rather than growing without bound.
BUNDLE_MAX = 5_000
SessionLevel = Literal["DEBUG", "TRACE"]
SESSION_LEVELS: tuple[str, ...] = get_args(SessionLevel)
# Detail order of the diagnostics levels: a session raises the level in force, never lowers it.
_DETAIL = {"INFO": 0, "DEBUG": 1, "TRACE": 2}
_ID_LEN = 16
class EndReason(StrEnum):
STOPPED = "stopped"
EXPIRED = "expired"
def is_live(session: DebugSession, now: datetime | None = None) -> bool:
"""Open and within its cap — THE liveness rule: an open session past its cap is over."""
return session.ended_at is None and session.until > (now or datetime.now(timezone.utc))
def open_session(db: Session, user: User) -> DebugSession | None:
"""The user's live session, or None. Read-only: one past its cap is over and reads as None;
`start` and the hourly sweep are what close it."""
session = _unclosed(db, user)
if session is None or not is_live(session, datetime.now(timezone.utc)):
return None
return session
def _unclosed(db: Session, user: User) -> DebugSession | None:
return db.scalar(
select(DebugSession).where(DebugSession.user_id == user.id, DebugSession.ended_at.is_(None))
)
def start(db: Session, user: User, level: SessionLevel) -> DebugSession:
"""Open a session, or return the live one — a second tab pressing Start joins the recording
rather than ending it. One past its cap is closed first: the one-open-per-user index would
refuse the new row beside it. Flushes; the caller commits. Two concurrent Starts can still both
get here: the index refuses the second, and the caller joins the one that won."""
now = datetime.now(timezone.utc)
current = _unclosed(db, user)
if current is not None:
if is_live(current, now):
return current
end(current, EndReason.EXPIRED)
db.flush()
session = DebugSession(
id=secrets.token_hex(_ID_LEN // 2),
user_id=user.id,
level=level,
started_at=now,
until=now + timedelta(minutes=MAX_MINUTES),
)
db.add(session)
db.flush()
return session
def stop(session: DebugSession) -> None:
"""The user's Stop. Past its cap the session had already ended on its own, and a late Stop must
not rename that."""
end(session, EndReason.STOPPED if is_live(session) else EndReason.EXPIRED)
def end(session: DebugSession, reason: EndReason) -> None:
"""Close a session. An expired one is dated at its cap, not at whenever it was noticed."""
if session.ended_at is not None:
return
session.ended_at = (
session.until
if reason is EndReason.EXPIRED
else min(datetime.now(timezone.utc), session.until)
)
session.ended_reason = reason
def from_header(db: Session, user: User, value: str | None) -> DebugSession | None:
"""The session a request names, if it is the caller's own and still live; else None."""
if not value or len(value) != _ID_LEN or not all(c in "0123456789abcdef" for c in value):
return None
session = db.get(DebugSession, value)
if (
session is None
or session.user_id != user.id
or not is_live(session, datetime.now(timezone.utc))
):
return None
return session
def level_for(user: User, session: DebugSession | None) -> tuple[str, datetime | None]:
"""The diagnostics level (and its expiry) a request is logged at: the user's own, raised to the
session's while one is named — whichever records more.
A named session also caps the expiry at its own: this pair is what the process tracks under the
session (app/logs.py), and an expiry outliving the recording would hold the root logger down
after it ended."""
own = diagnostics.effective_level(user)
until = user.diagnostics_until if own != diagnostics.BASE else None
if session is None:
return own, until
if _DETAIL[session.level] <= _DETAIL[own]:
return own, min(until, session.until) if until else session.until
return session.level, session.until
def lock(db: Session, session: DebugSession) -> None:
"""Re-read the session under a row lock, held until the caller commits. Everything that changes
what an issue is made of — the draft, the screenshots, the send itself — takes it first: a send
in flight sees none of them change under it, and each of them sees a send that committed."""
db.refresh(session, with_for_update=True)
def save_draft(session: DebugSession, draft: dict) -> None:
"""Keep the composer's draft (the caller validated, locked and commits). Shape: `title`, `body`
(the rendered markdown an export or an issue carries), `excluded` (log row ids taken out of the
evidence) and `form` (the composer's own fields, to reopen it as it was). A delivery's progress
is not the composer's to replace, and is kept."""
progress = (session.draft or {}).get("delivery")
session.draft = {**draft, "delivery": progress} if progress else draft
# --- a delivery's progress -----------------------------------------------------------------
# Written by app/issues/gitea.py into the draft; read HERE, so the send's decision and what the
# composer shows are one reading of it.
def delivery(session: DebugSession) -> dict:
return dict((session.draft or {}).get("delivery") or {})
def not_uploaded(session: DebugSession) -> list[str]:
"""The files a send that stopped half-way has still to upload — by name, as the issue carries
them. What the composer names to the user."""
return list(delivery(session).get("pending") or [])
def images_pending(session: DebugSession) -> bool:
"""Whether the issue's body still has to be edited to show the screenshots it already carries
(every upload went, the edit that shows them did not)."""
state = delivery(session)
shown = state.get("images_shown")
return not shown and any(n.startswith(SCREENSHOT_PREFIX) for n in state.get("links") or {})
def unfinished(session: DebugSession) -> bool:
"""Whether a send owes the issue anything — the retry's question."""
return bool(not_uploaded(session)) or images_pending(session)
# A send claims the session while it talks to the tracker, instead of holding the row's lock across
# those calls: a Stop, an autosave or a screenshot would queue behind a lock for the whole send.
# They see the claim and are refused at once instead. A claim not renewed for this long was left by
# a request that died mid-send (the process restarted), and the next send takes it over — which is
# why a live send RENEWS it every time it reports progress (`set_delivery`). A claim is a lease, and
# a send slower than the lease must not lose it while it is still running.
SEND_CLAIM_SECONDS = 600
def _claim(session: DebugSession) -> dict:
claim = delivery(session).get("sending")
return claim if isinstance(claim, dict) else {}
def _stamp(token: str) -> dict:
return {"at": datetime.now(timezone.utc).isoformat(), "by": token}
def sending(session: DebugSession) -> bool:
"""Whether a send is under way right now (a live claim)."""
at = _claim(session).get("at")
if not at:
return False
return (datetime.now(timezone.utc) - datetime.fromisoformat(at)).total_seconds() < (
SEND_CLAIM_SECONDS
)
def claim_send(session: DebugSession) -> str:
"""Mark the session as being sent, and return the token that OWNS this claim. The caller holds
the lock and commits, which is what makes the claim visible to everyone else."""
token = uuid4().hex
_write_delivery(session, {**delivery(session), "sending": _stamp(token)})
return token
def release_send(session: DebugSession, token: str) -> None:
"""Give back our OWN claim, and only ours: one that aged out and was taken over belongs to the
send holding it now, and handing that one back would let a third send in beside it."""
if _claim(session).get("by") != token:
return
_write_delivery(session, {k: v for k, v in delivery(session).items() if k != "sending"})
class SendClaimLost(Exception):
"""This send's claim is no longer on the row: it aged out and another send took it over. The
send must stop — what it uploads from here it can no longer record, so a retry would send it
again — and it must not write, because the row now holds the other send's progress."""
def claim_owner(db: Session, session: DebugSession) -> str | None:
"""Who holds the row's claim RIGHT NOW, read from the ROW and not from the object.
`expire_on_commit` is off (app/db.py), so a checkpoint does not re-read: a send that stalled
past its lease still carries its own dead token in memory long after another send took over.
A targeted scalar, not `lock`/`db.refresh` — a refresh here would discard the `issue_url` that
`gitea.deliver` assigns one line before its first progress report, and that link is the one
thing the send must not lose."""
return db.scalar(
select(DebugSession.draft["delivery"]["sending"]["by"].as_string()).where(
DebugSession.id == session.id
)
)
def set_delivery(db: Session, session: DebugSession, state: dict, token: str) -> None:
"""Write a delivery's progress (app/issues/gitea.py keeps it; this module reads it), and renew
the claim while doing it — a send still reporting progress is still alive, so one slower than
the lease cannot expire while it is still running.
The claim is not the caller's to carry: `gitea.deliver` threads ONE snapshot of this dict
through a whole send, so a progress report that carried the claim wrote a stale one back at
every step. It is stamped from `token` here instead, and only after the ROW confirms the claim
is still ours. Without that check the write is a full-column overwrite with no WHERE: a send
that lost its lease came back and stomped the take-over send's committed links, then put its
own dead token back on an idle session (deep-review round 4). `release_send` has always made
the same check; this is the other half of it."""
if claim_owner(db, session) != token:
raise SendClaimLost(session.id)
state = {k: v for k, v in state.items() if k != "sending"}
state["sending"] = _stamp(token)
_write_delivery(session, state)
def _write_delivery(session: DebugSession, state: dict) -> None:
# A new dict, so the JSONB column sees the change.
session.draft = {**(session.draft or {}), "delivery": state}
def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None:
if session is None:
return None
return {
"id": session.id,
"level": session.level,
"started_at": session.started_at.isoformat(),
"until": session.until.isoformat(),
"ended_at": session.ended_at.isoformat() if session.ended_at else None,
"ended_reason": session.ended_reason,
"issue_url": session.issue_url,
# A send that stopped half-way: the files it has still to upload, and whether the issue's
# body has still to be edited to show the screenshots it already carries. Both are true of a
# send that is RUNNING too — every step commits its progress — so `sending` goes with them:
# without it a reload or a second tab renders a live send as a failed one, and offers a
# retry that is certain to be refused.
"pending_files": not_uploaded(session),
"images_pending": images_pending(session),
"sending": sending(session),
"screenshots": screenshots,
"draft": session.draft,
}
def view(db: Session, session: DebugSession) -> dict:
"""The session as the API serves it — one shape wherever it is served."""
return serialize(session, screenshot_count(db, session)) or {}
def screenshot_count(db: Session, session: DebugSession) -> int:
return (
db.scalar(
select(func.count())
.select_from(DebugScreenshot)
.where(DebugScreenshot.session_id == session.id)
)
or 0
)
# --- screenshots ---------------------------------------------------------------------------
# By content, not by the declared type: what is stored is served back to a browser as an image.
_SIGNATURES = (
(b"\x89PNG\r\n\x1a\n", "image/png"),
(b"\xff\xd8\xff", "image/jpeg"),
)
def sniff(data: bytes) -> str | None:
"""The image type `data` really is — one of IMAGE_TYPES — or None."""
for magic, mime in _SIGNATURES:
if data.startswith(magic):
return mime
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp"
return None
def lock_user(db: Session, user_id: int) -> None:
"""Hold this user's own lock for the rest of the transaction. A session's row lock bounds what
THAT session may keep; the byte cap below is the user's across all of them, and a lock on one
session cannot serialize a check that reads them all — two uploads to two sessions both read the
total before either commits, and both pass."""
db.execute(select(func.pg_advisory_xact_lock(user_id)))
def stored_screenshot_bytes(db: Session, user_id: int) -> int:
return (
db.scalar(
select(func.coalesce(func.sum(DebugScreenshot.size), 0))
.join(DebugSession, DebugSession.id == DebugScreenshot.session_id)
.where(DebugSession.user_id == user_id)
)
or 0
)
class ScreenshotRefused(Exception):
"""A screenshot that cannot be kept; `code` is the refusal's machine word."""
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
def add_screenshot(db: Session, session: DebugSession, data: bytes) -> DebugScreenshot:
"""Keep one screenshot. Locks the user and the session, and flushes; the caller commits.
Refused while a send is under way or once the issue was sent (it can no longer carry it), past
the per-session count or the user's storage, or when it is not an image we can show."""
if not data:
raise ScreenshotRefused("empty")
mime = sniff(data)
if mime is None:
raise ScreenshotRefused("not_an_image")
# Under both locks: two uploads at once cannot both pass the count (the session's), nor both
# pass the user's byte cap from two different sessions (the user's). Always in this order.
lock_user(db, session.user_id)
lock(db, session)
if sending(session):
raise ScreenshotRefused("sending")
if session.issue_url is not None:
raise ScreenshotRefused("already_sent")
if screenshot_count(db, session) >= SCREENSHOTS_MAX:
raise ScreenshotRefused("too_many")
if stored_screenshot_bytes(db, session.user_id) + len(data) > SCREENSHOT_USER_MAX_BYTES:
raise ScreenshotRefused("storage_full")
shot = DebugScreenshot(
session_id=session.id,
created_at=datetime.now(timezone.utc),
mime=mime,
size=len(data),
data=data,
)
db.add(shot)
db.flush()
return shot
def remove_screenshot(db: Session, session: DebugSession, shot: DebugScreenshot) -> None:
"""Delete one screenshot, under the lock. Refused while a send is under way or unfinished: a
delivery names the screenshots by their order, and removing one would renumber what it still
has to send. Once the issue has everything, this only drops OUR copy — the issue keeps its
own. The caller commits."""
lock(db, session)
if sending(session):
raise ScreenshotRefused("sending")
if unfinished(session):
raise ScreenshotRefused("already_sent")
db.delete(shot)
db.flush()
def screenshots(db: Session, session: DebugSession) -> list[DebugScreenshot]:
"""The session's screenshots, oldest first, WITHOUT their bytes loaded."""
return list(
db.scalars(
select(DebugScreenshot)
.where(DebugScreenshot.session_id == session.id)
.options(defer(DebugScreenshot.data))
.order_by(DebugScreenshot.id)
)
)
# --- the evidence --------------------------------------------------------------------------
# The browser's own log upload (`POST` here, every few seconds while recording) is how the evidence
# arrives, not part of it: its server lines would fill the bundle with the recording's plumbing. The
# browser leaves its copies out for the same reason (frontend lib/diagnostics `isOwnRequest`).
INGEST_PATH = "/api/diagnostics/events"
def _evidence(session: DebugSession):
upload_line = and_(
LogEvent.event == "http.request", LogEvent.attrs["path"].astext == INGEST_PATH
)
# `IS TRUE`, not the bare condition: for a row with no event or no attrs it is NULL, and
# `NOT NULL` would drop exactly the rows that are evidence.
return (LogEvent.debug_session_id == session.id, not_(upload_line.is_(True)))
def bundle(
db: Session, session: DebugSession, *, drop_excluded: bool = False
) -> tuple[list[LogEvent], int]:
"""The session's log rows, browser and server interleaved in time, capped at BUNDLE_MAX — and
how many rows it has in all, so a cut bundle can say so. `drop_excluded` leaves out the rows
the user took out in the composer: what an export or an issue carries."""
where = list(_evidence(session))
excluded = (session.draft or {}).get("excluded") or [] if drop_excluded else []
if excluded:
where.append(LogEvent.id.not_in(excluded))
rows = list(
db.scalars(
select(LogEvent).where(*where).order_by(LogEvent.ts, LogEvent.id).limit(BUNDLE_MAX)
)
)
total = len(rows)
if total == BUNDLE_MAX:
total = db.scalar(select(func.count()).select_from(LogEvent).where(*where)) or total
return rows, total
# --- housekeeping (the hourly log job) -----------------------------------------------------
def sweep(db: Session) -> int:
"""Stage closing every open session past its cap; returns how many."""
now = datetime.now(timezone.utc)
result = db.execute(
update(DebugSession)
.where(DebugSession.ended_at.is_(None), DebugSession.until <= now)
.values(ended_at=DebugSession.until, ended_reason=EndReason.EXPIRED.value)
.execution_options(synchronize_session=False)
)
return result.rowcount or 0
def gc(db: Session, retention_days: int) -> int:
"""Stage deleting sessions (and by cascade their screenshots) started before the log retention
window — the rows they would bundle are gone by then too. Returns how many."""
cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, retention_days))
result = db.execute(delete(DebugSession).where(DebugSession.started_at < cutoff))
return result.rowcount or 0
+65
View File
@@ -0,0 +1,65 @@
"""A user's diagnostics level (E22 S5): what they consented to, and for how long.
Three levels, named in the UI Alap / Részletes / Teljes:
* INFO — the base, for everyone, no consent. The browser keeps its log in memory; nothing extra
is sent or stored.
* DEBUG — consented. The server logs this user's requests in detail and stores every line of
them, and the browser streams its own log to `POST /api/diagnostics/events`.
* TRACE — consented. As DEBUG, plus what the user typed into a search.
A raised level always lapses after `ELEVATION_HOURS`: the expiry is computed HERE at consent time,
never taken from the client, and an expired level reads as INFO without anything having to clear it.
The shared demo account cannot raise its level — its log would mix every visitor's session.
Core layer: imports `models` and `sysconfig`.
"""
from datetime import datetime, timedelta, timezone
from typing import Literal, get_args
from sqlalchemy.orm import Session
from app import sysconfig
from app.models import User
DiagnosticsLevel = Literal["INFO", "DEBUG", "TRACE"]
LEVELS: tuple[str, ...] = get_args(DiagnosticsLevel)
BASE: DiagnosticsLevel = "INFO"
ELEVATION_HOURS = 24
# How long a debug session records at most (E16, app/debugsession.py). Here, beside the other
# consent window, because the start dialog's consent text states it and reads it from `state`.
SESSION_MINUTES = 60
def effective_level(user: User, now: datetime | None = None) -> DiagnosticsLevel:
"""The level in force for `user` right now: the consented one until it expires, else BASE."""
level, until = user.diagnostics_level, user.diagnostics_until
if level not in LEVELS or level == BASE or until is None:
return BASE
if until <= (now or datetime.now(timezone.utc)):
return BASE
return level # type: ignore[return-value] — narrowed by the LEVELS check above
def state(db: Session, user: User) -> dict:
"""The `/api/me` view of it: the level in force, when a raised one lapses, and how long stored
log lines are kept — the consent text states that number, so it must be the instance's own."""
level = effective_level(user)
return {
"level": level,
"until": user.diagnostics_until.isoformat() if level != BASE else None,
"retention_days": sysconfig.get_int(db, "log_retention_days"),
"session_minutes": SESSION_MINUTES,
}
def set_level(user: User, level: DiagnosticsLevel) -> None:
"""Record a consent (or its withdrawal). The caller commits. Raising the level again restarts
the clock — each consent is for the next ELEVATION_HOURS, not an extension of an older one."""
if level == BASE:
user.diagnostics_level = None
user.diagnostics_until = None
return
user.diagnostics_level = level
user.diagnostics_until = datetime.now(timezone.utc) + timedelta(hours=ELEVATION_HOURS)
+9 -4
View File
@@ -48,6 +48,7 @@ def pick(
shelf: list[dict] | None = None, shelf: list[dict] | None = None,
exclude: set[str] | None = None, exclude: set[str] | None = None,
start: int = 0, start: int = 0,
lang: str,
) -> dict | None: ) -> dict | None:
"""`{"tier": ..., "card": ...}`, or None when nothing at all fits. """`{"tier": ..., "card": ...}`, or None when nothing at all fits.
@@ -70,8 +71,8 @@ def pick(
skip = exclude or set() skip = exclude or set()
def _resume() -> dict | None: def _resume() -> dict | None:
for card in rails.continue_watching(db, user) if resume is None else resume: for card in rails.continue_watching(db, user, lang=lang) if resume is None else resume:
playable = rails.playable(db, user.id, card) playable = rails.playable(db, user.id, card, lang=lang)
# Excluded on the card the USER saw (the show), not only on what it resolves to, so a # Excluded on the card the USER saw (the show), not only on what it resolves to, so a
# rerolled-past show does not come straight back as its own next episode. # rerolled-past show does not come straight back as its own next episode.
if cards.key(card) in skip or (playable is not None and cards.key(playable) in skip): if cards.key(card) in skip or (playable is not None and cards.key(playable) in skip):
@@ -81,7 +82,11 @@ def pick(
return None return None
def _comfort() -> dict | None: def _comfort() -> dict | None:
for card in pins.playables(pins.shelf(db, user) if shelf is None else shelf): if shelf is None:
own = pins.shelf(db, user, include_plex=rails.plex_available(db), lang=lang)
else:
own = shelf
for card in pins.playables(own):
if cards.key(card) not in skip and fits(card["remaining_seconds"], budget): if cards.key(card) not in skip and fits(card["remaining_seconds"], budget):
return card return card
return None return None
@@ -89,7 +94,7 @@ def pick(
def _surprise() -> dict | None: def _surprise() -> dict | None:
# A wider draw than the one card we need: the shuffle is seeded, so without the headroom an # A wider draw than the one card we need: the shuffle is seeded, so without the headroom an
# excluded pick would leave the tier empty rather than move past it. # excluded pick would leave the tier empty rather than move past it.
for card in rails.surprise(db, user, budget, seed, limit=SURPRISE_DRAW): for card in rails.surprise(db, user, budget, seed, limit=SURPRISE_DRAW, lang=lang):
if cards.key(card) not in skip: if cards.key(card) not in skip:
return card return card
return None return None
+112 -18
View File
@@ -8,14 +8,16 @@ second one.
Every shelf card carries a resolved `next`: the thing that actually plays when you click the tile — Every shelf card carries a resolved `next`: the thing that actually plays when you click the tile —
a show's in-progress-or-next episode, a channel's newest unwatched video, a playlist's first a show's in-progress-or-next episode, a channel's newest unwatched video, a playlist's first
unwatched item. It costs a query per non-leaf pin, which is affordable precisely because this shelf is unwatched item. That buys the property the whole surface exists for: one click, no intermediate page,
short and hand-curated, and it buys the property the whole surface exists for: one click, no no second decision. Channels and playlists are resolved for the whole shelf at once (`_Batch`), so the
intermediate page, no second decision. cost does not grow a query or two per pin.
Domain module, so nothing here raises HTTP. `resolve_ref` returns None for "no such thing, or not Domain module, so nothing here raises HTTP. `resolve_ref` returns None for "no such thing, or not
yours" and the route turns that into its 404 — core never imports routes. yours" and the route turns that into its 404 — core never imports routes.
""" """
from dataclasses import dataclass, field
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -62,6 +64,8 @@ def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, obje
return (col, sh.id) if sh is not None else None return (col, sh.id) if sh is not None else None
try: try:
pid = int(ref) pid = int(ref)
# silent-ok: validating a reference the client supplied. "Not an integer" is answered as "no
# such pin target", which is the same answer this function gives for one that does not exist.
except ValueError: except ValueError:
return None return None
pl = db.get(Playlist, pid) pl = db.get(Playlist, pid)
@@ -71,6 +75,44 @@ def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, obje
return (col, pl.id) if pl is not None and pl.user_id == user.id else None return (col, pl.id) if pl is not None and pl.user_id == user.id else None
def reorder(db: Session, user: User, ids: list[int]) -> None:
"""Put the listed pins in the listed order, WITHIN the slots they already hold.
The client sends the order it can SEE, which is not always the whole shelf: with Plex off the Home
rail hides the Plex pins, and a stale tab may not know a pin added elsewhere. Numbering only the
listed ids 0..n-1 collided with every pin left out — two tiles on one position, and the hidden
ones jumped about when they came back. So the pins left out keep their slots, and the listed ones
are dealt into the slots the listed set occupies. Ids that are not this user's, or repeated, are
ignored rather than failing a stale replay wholesale. Flushes; the route commits."""
# Locked: the rows it leaves alone are part of the answer too. Two overlapping reorders each
# skipped the rows that already matched in their own snapshot and interleaved into two pins on
# one slot; the second now waits and deals from the first one's result. Locked in id order and
# sorted HERE: with `ORDER BY position … FOR UPDATE` Postgres sorts before it locks, so the one
# that waited got the fresh rows in its stale order and dealt the pins it left out back to their
# old slots — and one global lock order means two reorders cannot deadlock.
locked = db.execute(
select(ComfortPin)
.where(ComfortPin.user_id == user.id)
.order_by(ComfortPin.id)
.with_for_update()
.execution_options(populate_existing=True)
).scalars()
current = sorted(locked, key=lambda p: (p.position, p.id))
owned = {p.id for p in current}
listed: list[int] = []
for i in ids:
if i in owned and i not in listed:
listed.append(i)
chosen = set(listed)
queue = iter(listed)
order = [next(queue) if p.id in chosen else p.id for p in current]
by_id = {p.id: p for p in current}
for position, pin_id in enumerate(order):
if by_id[pin_id].position != position:
by_id[pin_id].position = position
db.flush()
def list_pins(db: Session, user: User) -> list[ComfortPin]: def list_pins(db: Session, user: User) -> list[ComfortPin]:
return list( return list(
db.execute( db.execute(
@@ -101,7 +143,34 @@ def next_position(db: Session, user: User) -> int:
) )
def _shelf_card(db: Session, user: User, pin: ComfortPin) -> dict | None: @dataclass
class _Batch:
"""What the shelf resolves for every pinned channel and playlist in one go."""
channels: dict[str, Channel] = field(default_factory=dict)
channel_summary: dict[str, tuple[int, dict | None]] = field(default_factory=dict)
playlists: dict[int, Playlist] = field(default_factory=dict)
playlist_summary: dict[int, tuple[int, dict | None]] = field(default_factory=dict)
@classmethod
def load(cls, db: Session, user: User, pins: list[ComfortPin]) -> "_Batch":
ch_ids = [p.channel_id for p in pins if p.channel_id is not None]
pl_ids = [p.playlist_id for p in pins if p.playlist_id is not None]
b = cls()
if ch_ids:
b.channels = {
c.id: c for c in db.scalars(select(Channel).where(Channel.id.in_(ch_ids)))
}
b.channel_summary = queries.channels_summary(db, user, ch_ids)
if pl_ids:
b.playlists = {
p.id: p for p in db.scalars(select(Playlist).where(Playlist.id.in_(pl_ids)))
}
b.playlist_summary = queries.playlists_summary(db, user, pl_ids)
return b
def _shelf_card(db: Session, user: User, pin: ComfortPin, batch: _Batch, lang: str) -> dict | None:
"""One pin as a unified card with its resolved `next`. None when the pinned row has gone (the FK """One pin as a unified card with its resolved `next`. None when the pinned row has gone (the FK
cascade removes the pin itself, so this is only the window between a delete and a refetch).""" cascade removes the pin itself, so this is only the window between a delete and a refetch)."""
card: dict | None = None card: dict | None = None
@@ -115,11 +184,11 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin) -> dict | None:
nxt = dict(card) # a video plays itself nxt = dict(card) # a video plays itself
elif pin.channel_id is not None: elif pin.channel_id is not None:
ch = db.get(Channel, pin.channel_id) ch = batch.channels.get(pin.channel_id)
if ch is None: if ch is None:
return None return None
card = cards.from_channel(ch, queries.channel_unwatched_count(db, user, ch.id)) count, row = batch.channel_summary.get(ch.id, (0, None))
row = queries.channel_next_video(db, user, ch.id) card = cards.from_channel(ch, count)
nxt = cards.from_video(row) if row else None nxt = cards.from_video(row) if row else None
elif pin.plex_item_id is not None: elif pin.plex_item_id is not None:
@@ -127,7 +196,7 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin) -> dict | None:
if it is None: if it is None:
return None return None
st = rollup.item_state_map(db, user.id, [it.id]).get(it.id) st = rollup.item_state_map(db, user.id, [it.id]).get(it.id)
card = cards.from_plex(leaf_card(it, st)) card = cards.from_plex(leaf_card(it, st, lang=lang))
nxt = dict(card) # a movie or a single episode plays itself nxt = dict(card) # a movie or a single episode plays itself
elif pin.plex_show_id is not None: elif pin.plex_show_id is not None:
@@ -135,15 +204,15 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin) -> dict | None:
if sh is None: if sh is None:
return None return None
status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new") status = rollup.state_map(db, user.id, [sh.id]).get(sh.id, "new")
card = cards.from_plex(show_card(sh, status)) card = cards.from_plex(show_card(sh, status, lang=lang))
ep = queries.next_episode(db, user.id, sh.id) ep = queries.next_episode(db, user.id, sh.id, lang=lang)
nxt = cards.from_plex(ep) if ep else None nxt = cards.from_plex(ep) if ep else None
elif pin.playlist_id is not None: elif pin.playlist_id is not None:
pl = db.get(Playlist, pin.playlist_id) pl = batch.playlists.get(pin.playlist_id)
if pl is None: if pl is None:
return None return None
count, row = queries.playlist_summary(db, user, pl.id) count, row = batch.playlist_summary.get(pl.id, (0, None))
card = cards.from_playlist(pl, count, row.get("thumbnail_url") if row else None) card = cards.from_playlist(pl, count, row.get("thumbnail_url") if row else None)
nxt = cards.from_video(row) if row else None nxt = cards.from_video(row) if row else None
@@ -157,11 +226,24 @@ def _shelf_card(db: Session, user: User, pin: ComfortPin) -> dict | None:
return card return card
def shelf(db: Session, user: User) -> list[dict]: _PLEX_COLUMNS = ("plex_item_id", "plex_show_id")
"""The whole shelf, in the user's own order."""
def shelf(db: Session, user: User, *, include_plex: bool = True, lang: str) -> list[dict]:
"""The whole shelf, in the user's own order.
`include_plex=False` is the Home surface with the deployment's Plex toggle off (the owner's call,
2026-09-19): the rails beside it go YouTube-only, and a Plex tile there would offer a Play that
lands on a gated module. The pins themselves are kept — the pin menus still read the full shelf
(`GET /pins`), and switching Plex back on brings the tiles back in their old slots
(`reorder` keeps a hidden pin's place)."""
out = [] out = []
for pin in list_pins(db, user): pins = list_pins(db, user)
card = _shelf_card(db, user, pin) if not include_plex:
pins = [p for p in pins if all(getattr(p, c) is None for c in _PLEX_COLUMNS)]
batch = _Batch.load(db, user, pins)
for pin in pins:
card = _shelf_card(db, user, pin, batch, lang)
if card is not None: if card is not None:
out.append(card) out.append(card)
return out return out
@@ -169,5 +251,17 @@ def shelf(db: Session, user: User) -> list[dict]:
def playables(shelf_cards: list[dict]) -> list[dict]: def playables(shelf_cards: list[dict]) -> list[dict]:
"""The `next` of every shelf entry that has one — what the hero may draw from. A pinned channel """The `next` of every shelf entry that has one — what the hero may draw from. A pinned channel
with nothing unwatched left, or an empty playlist, simply is not an answer right now.""" with nothing unwatched left, or an empty playlist, simply is not an answer right now.
return [c["next"] for c in shelf_cards if c.get("next")]
Each carries `pinned_from`: the pin it was resolved from. A show's next EPISODE is not itself
pinned, so without it the hero's menu looked the episode up, missed, and offered "Pin" under a
kicker that says "From your shelf" — and taking it inserted a second pin. With it, the hero's
pin row acts on the shelf entry the card came from (the owner's call, 2026-09-19: "Unpin")."""
return [
{
**c["next"],
"pinned_from": {k: c[k] for k in ("source", "kind", "id", "title")},
}
for c in shelf_cards
if c.get("next")
]
+60 -39
View File
@@ -40,11 +40,12 @@ from app.models import (
VideoState, VideoState,
) )
from app.plex import rollup from app.plex import rollup
from app.plex.cards import leaf_card, movie_card, show_card from app.plex.cards import display_title, leaf_card, movie_card, show_card
from app.plex.query import apply_show_state_filter, content_libs from app.plex.query import apply_show_state_filter, content_libs
# How many rows a single rail may carry. Matches `routes/plex/discover.py`'s RAIL_LIMIT on purpose — # How many rows a single rail may carry — Home's rails and the Plex module's Discover rails alike
# the two surfaces should feel like the same app, and a rail is a rail. # (`routes/plex/discover.py` imports it): the two surfaces should feel like the same app, and a rail
# is a rail. One definition, because a comment was all that held two copies in step.
RAIL_LIMIT = 20 RAIL_LIMIT = 20
# The feed parameters a Home rail runs with. Spelled out rather than borrowed from the route's # The feed parameters a Home rail runs with. Spelled out rather than borrowed from the route's
@@ -129,18 +130,6 @@ def yt_surprise(db: Session, user: User, budget: str, seed: int, limit: int) ->
return [r for r in out if not r["position_seconds"]][:limit] return [r for r in out if not r["position_seconds"]][:limit]
def channel_next_video(db: Session, user: User, channel_id: str) -> dict | None:
"""The newest unwatched video of a pinned channel — what "put on that channel" resolves to."""
# `library_source="all"`: a pinned channel means the whole channel, including videos that only
# ever arrived through a search. The feed's "organic" default is a feed-scoping choice, not a
# statement about what belongs to a channel.
params = dict(_FEED_BASE, channel_ids=[channel_id], scope="all", library_source="all")
query, rank_expr = filtered_query(db, user, **params)
keys = sort_keys("newest", 0, rank_expr)
row = db.execute(query.order_by(*order_by(keys)).limit(1)).first()
return serialize(row) if row is not None else None
def yt_video_row(db: Session, user: User, video_id: str) -> dict | None: def yt_video_row(db: Session, user: User, video_id: str) -> dict | None:
"""One video as a feed row, with this user's watch state. """One video as a feed row, with this user's watch state.
@@ -158,31 +147,55 @@ def yt_video_row(db: Session, user: User, video_id: str) -> dict | None:
return serialize(row) if row is not None else None return serialize(row) if row is not None else None
def channel_unwatched_count(db: Session, user: User, channel_id: str) -> int: def channels_summary(
"""How many unwatched videos a pinned channel is holding — the tile's only honest promise, since a db: Session, user: User, channel_ids: list[str]
channel has no runtime of its own.""" ) -> dict[str, tuple[int, dict | None]]:
params = dict(_FEED_BASE, channel_ids=[channel_id], scope="all", library_source="all") """For each pinned channel: (unwatched count, its newest unwatched video) — the tile's only honest
query, _ = filtered_query(db, user, **params) promise, since a channel has no runtime of its own, and what "put on that channel" resolves to.
return int(db.execute(select(func.count()).select_from(query.subquery())).scalar() or 0)
ONE query for the whole shelf, not two per channel: the count and the first row per channel are
window functions over the same filtered set. `library_source="all"`: a pinned channel means the
whole channel, including videos that only ever arrived through a search — the feed's "organic"
default is a feed-scoping choice, not a statement about what belongs to a channel."""
if not channel_ids:
return {}
params = dict(_FEED_BASE, channel_ids=list(channel_ids), scope="all", library_source="all")
query, rank_expr = filtered_query(db, user, **params)
newest = order_by(sort_keys("newest", 0, rank_expr))
per_channel = {"partition_by": Video.channel_id}
sub = query.add_columns(
func.row_number().over(**per_channel, order_by=newest).label("_rn"),
func.count().over(**per_channel).label("_total"),
).subquery()
rows = db.execute(select(sub).where(sub.c._rn == 1)).all()
return {r.channel_id: (int(r._total), serialize(r)) for r in rows}
def playlist_summary(db: Session, user: User, playlist_id: int) -> tuple[int, dict | None]: def playlists_summary(
"""(item count, the row to play next) for a pinned playlist — its first not-yet-watched item, or db: Session, user: User, playlist_ids: list[int]
its first item once the whole list is watched. The same row doubles as the tile's artwork, so a ) -> dict[int, tuple[int, dict | None]]:
shelf of playlists is scannable instead of a row of blank boxes.""" """For each pinned playlist: (item count, the row to play next) — its first not-yet-watched item,
or its first item once the whole list is watched. The same row doubles as the tile's artwork, so
a shelf of playlists is scannable instead of a row of blank boxes. One query for the whole shelf."""
if not playlist_ids:
return {}
st = aliased(VideoState) st = aliased(VideoState)
rows = db.execute( rows = db.execute(
select(*feed_columns(user, st), PlaylistItem.position) select(*feed_columns(user, st), PlaylistItem.playlist_id.label("_playlist"))
.join(PlaylistItem, PlaylistItem.video_id == Video.id) .join(PlaylistItem, PlaylistItem.video_id == Video.id)
.join(Channel, Channel.id == Video.channel_id) .join(Channel, Channel.id == Video.channel_id)
.outerjoin(st, and_(st.video_id == Video.id, st.user_id == user.id)) .outerjoin(st, and_(st.video_id == Video.id, st.user_id == user.id))
.where(PlaylistItem.playlist_id == playlist_id) .where(PlaylistItem.playlist_id.in_(playlist_ids))
.order_by(PlaylistItem.position.asc()) .order_by(PlaylistItem.playlist_id, PlaylistItem.position.asc())
).all() ).all()
if not rows: grouped: dict[int, list] = {}
return 0, None for r in rows:
nxt = next((r for r in rows if (r.status or "new") not in ("watched", "hidden")), rows[0]) grouped.setdefault(r._playlist, []).append(r)
return len(rows), serialize(nxt) out: dict[int, tuple[int, dict | None]] = {}
for pid, items in grouped.items():
nxt = next((r for r in items if (r.status or "new") not in ("watched", "hidden")), items[0])
out[pid] = (len(items), serialize(nxt))
return out
# ── Plex ────────────────────────────────────────────────────────────────────────────────────────── # ── Plex ──────────────────────────────────────────────────────────────────────────────────────────
@@ -208,7 +221,9 @@ def _unseen_movie_conds(uid: int):
return ~seen return ~seen
def plex_in_progress(db: Session, uid: int, limit: int = RAIL_LIMIT) -> list[tuple[object, dict]]: def plex_in_progress(
db: Session, uid: int, limit: int = RAIL_LIMIT, *, lang: str
) -> list[tuple[object, dict]]:
"""In-progress movies + partially-watched shows as (progressed_at, card). """In-progress movies + partially-watched shows as (progressed_at, card).
Mirrors `routes/plex/discover.py::_continue_watching` — same `PlexState` predicate for movies (a Mirrors `routes/plex/discover.py::_continue_watching` — same `PlexState` predicate for movies (a
@@ -236,7 +251,7 @@ def plex_in_progress(db: Session, uid: int, limit: int = RAIL_LIMIT) -> list[tup
.limit(limit) .limit(limit)
) )
for it, st in db.execute(mq).all(): for it, st in db.execute(mq).all():
out.append((st.progress_updated_at, movie_card(it, st))) out.append((st.progress_updated_at, movie_card(it, st, lang=lang)))
if show_libs: if show_libs:
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery() prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
@@ -250,12 +265,14 @@ def plex_in_progress(db: Session, uid: int, limit: int = RAIL_LIMIT) -> list[tup
for sh, resumed_at in db.execute( for sh, resumed_at in db.execute(
sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit) sq.order_by(prog.c.resumed_at.desc().nullslast()).limit(limit)
).all(): ).all():
out.append((resumed_at, show_card(sh, "in_progress"))) out.append((resumed_at, show_card(sh, "in_progress", lang=lang)))
return out return out
def plex_surprise(db: Session, uid: int, budget: str, seed: int, limit: int) -> list[dict]: def plex_surprise(
db: Session, uid: int, budget: str, seed: int, limit: int, *, lang: str
) -> list[dict]:
"""Unseen playable movies inside the budget, in seeded-shuffle order. """Unseen playable movies inside the budget, in seeded-shuffle order.
Movies only. A show is not a thing you can start playing, and resolving every candidate show to Movies only. A show is not a thing you can start playing, and resolving every candidate show to
@@ -287,14 +304,14 @@ def plex_surprise(db: Session, uid: int, budget: str, seed: int, limit: int) ->
q = q.where(PlexItem.duration_s <= hi) q = q.where(PlexItem.duration_s <= hi)
items = db.execute(q).scalars().all() items = db.execute(q).scalars().all()
states = rollup.item_state_map(db, uid, [it.id for it in items]) states = rollup.item_state_map(db, uid, [it.id for it in items])
return [movie_card(it, states.get(it.id)) for it in items] return [movie_card(it, states.get(it.id), lang=lang) for it in items]
# Watch states that take an episode out of the running for "what would play next". # Watch states that take an episode out of the running for "what would play next".
_DONE = ("watched", "hidden") _DONE = ("watched", "hidden")
def next_episode(db: Session, uid: int, show_id: int) -> dict | None: def next_episode(db: Session, uid: int, show_id: int, *, lang: str) -> dict | None:
"""What "put that show on" means: the episode in progress, else the first unwatched one, else the """What "put that show on" means: the episode in progress, else the first unwatched one, else the
first — over the same `(season, episode)` broadcast ordering `detail.py::_episode_nav` uses. first — over the same `(season, episode)` broadcast ordering `detail.py::_episode_nav` uses.
@@ -344,5 +361,9 @@ def next_episode(db: Session, uid: int, show_id: int) -> dict | None:
ep, st = pick ep, st = pick
show = db.get(PlexShow, show_id) show = db.get(PlexShow, show_id)
return leaf_card( return leaf_card(
ep, st, show_title=show.title if show else None, show_rk=show.rating_key if show else None ep,
st,
show_title=display_title(show, lang) if show else None,
show_rk=show.rating_key if show else None,
lang=lang,
) )
+14 -7
View File
@@ -22,7 +22,7 @@ def plex_available(db: Session) -> bool:
return sysconfig.get_bool(db, "plex_enabled") return sysconfig.get_bool(db, "plex_enabled")
def playable(db: Session, uid: int, card: dict) -> dict | None: def playable(db: Session, uid: int, card: dict, *, lang: str) -> dict | None:
"""The thing that would actually START from this card — itself for anything with a runtime, and """The thing that would actually START from this card — itself for anything with a runtime, and
for a Plex SHOW the episode that is next (in progress, else the first unwatched, else the first). for a Plex SHOW the episode that is next (in progress, else the first unwatched, else the first).
@@ -40,11 +40,11 @@ def playable(db: Session, uid: int, card: dict) -> dict | None:
sh = by_rating_key(db, PlexShow, card["id"]) sh = by_rating_key(db, PlexShow, card["id"])
if sh is None: if sh is None:
return None return None
ep = queries.next_episode(db, uid, sh.id) ep = queries.next_episode(db, uid, sh.id, lang=lang)
return cards.from_plex(ep) if ep else None return cards.from_plex(ep) if ep else None
def continue_watching(db: Session, user: User, limit: int = RAIL_LIMIT) -> list[dict]: def continue_watching(db: Session, user: User, limit: int = RAIL_LIMIT, *, lang: str) -> list[dict]:
"""One resume rail across both sources, most recently progressed first. """One resume rail across both sources, most recently progressed first.
A Plex SHOW enters as a show CARD — that is what the Plex rail shows, and it is the artwork the A Plex SHOW enters as a show CARD — that is what the Plex rail shows, and it is the artwork the
@@ -57,17 +57,22 @@ def continue_watching(db: Session, user: User, limit: int = RAIL_LIMIT) -> list[
(ts, cards.from_video(r)) for ts, r in queries.yt_in_progress(db, user, limit) (ts, cards.from_video(r)) for ts, r in queries.yt_in_progress(db, user, limit)
] ]
if plex_available(db): if plex_available(db):
rows += [(ts, cards.from_plex(c)) for ts, c in queries.plex_in_progress(db, user.id, limit)] rows += [
(ts, cards.from_plex(c))
for ts, c in queries.plex_in_progress(db, user.id, limit, lang=lang)
]
rows.sort(key=lambda r: r[0] or NO_TS, reverse=True) rows.sort(key=lambda r: r[0] or NO_TS, reverse=True)
out = [card for _, card in rows[:limit]] out = [card for _, card in rows[:limit]]
# Resolve AFTER the slice: only the rows that survive it are worth a query. # Resolve AFTER the slice: only the rows that survive it are worth a query.
for card in out: for card in out:
if card["kind"] == "show": if card["kind"] == "show":
card["next"] = playable(db, user.id, card) card["next"] = playable(db, user.id, card, lang=lang)
return out return out
def surprise(db: Session, user: User, budget: str, seed: int, limit: int = 3) -> list[dict]: def surprise(
db: Session, user: User, budget: str, seed: int, limit: int = 3, *, lang: str
) -> list[dict]:
"""The hero's draw pool: unseen things that fit the budget, from both sources under ONE seed. """The hero's draw pool: unseen things that fit the budget, from both sources under ONE seed.
Interleaved rather than concatenated — the same alternation `routes/plex/discover.py` uses for its Interleaved rather than concatenated — the same alternation `routes/plex/discover.py` uses for its
@@ -83,7 +88,9 @@ def surprise(db: Session, user: User, budget: str, seed: int, limit: int = 3) ->
(cards.from_video, queries.yt_surprise(db, user, budget, seed, limit)), (cards.from_video, queries.yt_surprise(db, user, budget, seed, limit)),
( (
cards.from_plex, cards.from_plex,
queries.plex_surprise(db, user.id, budget, seed, limit) if plex_available(db) else [], queries.plex_surprise(db, user.id, budget, seed, limit, lang=lang)
if plex_available(db)
else [],
), ),
] ]
if seed % 2: if seed % 2:
+40 -5
View File
@@ -18,6 +18,7 @@ import yt_dlp
from yt_dlp.networking.impersonate import ImpersonateTarget from yt_dlp.networking.impersonate import ImpersonateTarget
from app.config import settings from app.config import settings
from app.downloads.stated import StatedFailure
# Facebook's "Cannot parse data": the page yt-dlp got was a wall, not the content. Both the Chrome retry # Facebook's "Cannot parse data": the page yt-dlp got was a wall, not the content. Both the Chrome retry
# below and the failure catalog (errors.py) decide on it, so the wording exists once. # below and the failure catalog (errors.py) decide on it, so the wording exists once.
@@ -93,12 +94,46 @@ class YouTubeAdapter(SourceAdapter):
"Requested format is not available", "Requested format is not available",
"Only images are available", "Only images are available",
"Sign in to confirm", "Sign in to confirm",
# Measured 2026-09-16: a gated video whose EXTRACTION succeeds and whose media fetch is then
# refused by the CDN ("unable to download video data: HTTP Error 403"). The wall moves
# between the player response and the stream, so this is the same per-client flakiness one
# step later — and it used to end the job on attempt 1, because the text classifies as
# `network` and nothing here matched it. The format URLs belong to the client that minted
# them, so the next client set is a real second chance.
"unable to download video data",
) )
# Measured in the worker against a REAL account cookie (2026-09-16, yt-dlp 2026.07.04): with
# `player_client=android_vr` yt-dlp logs `Skipping client "android_vr" since it does not support
# cookies`, drops it, and the request that is left with no client fails with "Requested format is
# not available" — which is already a retriable marker above, so the cost is a wasted attempt
# rather than a hard failure. Our primary set is android_vr ALONE, so without this filter every
# signed-in download would burn its first attempt that way before reaching the POT-backed web
# fallback the cookie exists for. The list is yt-dlp's own INNERTUBE_CLIENTS[...]
# ["SUPPORTS_COOKIES"], pinned by test_adapters against the installed yt-dlp, so a version bump
# that moves the table turns the gate red instead of silently un-authing every YouTube download.
_COOKIE_CAPABLE = frozenset(
{
"mweb",
"tv",
"tv_downgraded",
"web",
"web_creator",
"web_embedded",
"web_music",
"web_safari",
}
)
# Where a signed-in download lands when the admin's knobs hold no cookie-capable client at all.
# Both clients in ONE set: yt-dlp queries each and merges their formats, which is the cover for
# the per-client flakiness that the two-tier ladder gives a signed-out download.
_AUTHED_DEFAULT = ("web_safari", "web")
def client_sets(self, authed: bool = False) -> list: def client_sets(self, authed: bool = False) -> list:
return [ sets = [c for c in (settings.player_client_list, settings.player_client_fallback_list) if c]
c for c in (settings.player_client_list, settings.player_client_fallback_list) if c if authed:
] or [None] kept = [[c for c in s if c in self._COOKIE_CAPABLE] for s in sets]
return [s for s in kept if s] or [list(self._AUTHED_DEFAULT)]
return sets or [None]
def ydl_opts_extra(self, clients) -> dict: def ydl_opts_extra(self, clients) -> dict:
# == --extractor-args "youtube:player_client=…;youtubepot-bgutilhttp:base_url=…" (list values). # == --extractor-args "youtube:player_client=…;youtubepot-bgutilhttp:base_url=…" (list values).
@@ -144,11 +179,11 @@ class FacebookAdapter(SourceAdapter):
# Measured (E11.5 review): a refused or expired cookie gets the login page (~450 KB, a login # Measured (E11.5 review): a refused or expired cookie gets the login page (~450 KB, a login
# form, no post id at all), which is not a post without a video — the user's cookie is the cause. # form, no post id at all), which is not a post without a video — the user's cookie is the cause.
if _quoted(post_id) not in page: if _quoted(post_id) not in page:
raise RuntimeError(POST_NOT_SHOWN_MESSAGE) raise StatedFailure(POST_NOT_SHOWN_MESSAGE)
video_id = facebook_post_video_id(page, post_id) video_id = facebook_post_video_id(page, post_id)
if video_id is None: if video_id is None:
# Never fall back to the post link: that is exactly where the wrong video comes from. # Never fall back to the post link: that is exactly where the wrong video comes from.
raise RuntimeError(POST_WITHOUT_VIDEO_MESSAGE) raise StatedFailure(POST_WITHOUT_VIDEO_MESSAGE)
return f"https://www.facebook.com/watch/?v={video_id}" return f"https://www.facebook.com/watch/?v={video_id}"
def client_sets(self, authed: bool = False) -> list: def client_sets(self, authed: bool = False) -> list:
+27 -7
View File
@@ -11,11 +11,12 @@ Run once per environment:
docker exec siftlode-worker-1 python -c "from app.downloads import backfill; print(backfill.run())" docker exec siftlode-worker-1 python -c "from app.downloads import backfill; print(backfill.run())"
""" """
import logging from urllib.parse import urlsplit
import yt_dlp import yt_dlp
from sqlalchemy import select from sqlalchemy import select
from app import logs
from app.config import settings from app.config import settings
from app.db import SessionLocal from app.db import SessionLocal
from app.downloads import adapters from app.downloads import adapters
@@ -23,12 +24,17 @@ from app.downloads import edit as editmod
from app.downloads import formats, service from app.downloads import formats, service
from app.models import MediaAsset from app.models import MediaAsset
log = logging.getLogger("siftlode.backfill") log = logs.get_logger(__name__)
def _extract_meta(url: str, adapter: adapters.SourceAdapter) -> dict | None: def _extract_meta(url: str, adapter: adapters.SourceAdapter, asset_id: int) -> dict | None:
"""Metadata-only yt-dlp extraction, using the source adapter's client sets + options (YouTube """Metadata-only yt-dlp extraction, using the source adapter's client sets + options (YouTube
needs the POT/player-client strategy even just to extract).""" needs the POT/player-client strategy even just to extract)."""
with formats.hiding(url):
return _extract_meta_scoped(url, adapter, asset_id)
def _extract_meta_scoped(url: str, adapter: adapters.SourceAdapter, asset_id: int) -> dict | None:
for clients in adapter.client_sets(): for clients in adapter.client_sets():
opts = formats.build_ydl_opts( opts = formats.build_ydl_opts(
{}, "%(id)s.%(ext)s", lambda d: None, extra=adapter.ydl_opts_extra(clients) {}, "%(id)s.%(ext)s", lambda d: None, extra=adapter.ydl_opts_extra(clients)
@@ -40,7 +46,19 @@ def _extract_meta(url: str, adapter: adapters.SourceAdapter) -> dict | None:
if info: if info:
return info return info
except Exception as exc: # noqa: BLE001 — best-effort backfill, keep going except Exception as exc: # noqa: BLE001 — best-effort backfill, keep going
log.info("meta extract failed for %s (%s): %s", url, clients, str(exc)[:100]) # The asset id, not the URL. A source URL is a row of this user's download
# history, and `redact_text` only takes credential params out of it — the rest
# of it goes in whole. The id names the same asset to anyone holding the
# database and nothing at all to anyone who is not; the host is kept because it
# is what decides which client set was tried. yt-dlp's message goes through
# `log_safe` for the same reason: it opens with the URL or the video id.
log.info(
"meta extract failed for asset %s on %s (%s): %s",
asset_id,
urlsplit(url).hostname or "?", # log-pii-ok: the host, never the path or query
clients,
formats.log_safe(exc)[:100],
)
return None return None
@@ -60,12 +78,14 @@ def run() -> dict:
adapter = adapters.resolve_adapter(a.source_kind, a.source_ref) adapter = adapters.resolve_adapter(a.source_kind, a.source_ref)
if a.uploader_url is None and adapter.wants_metadata_backfill(): if a.uploader_url is None and adapter.wants_metadata_backfill():
url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref) url = a.source_webpage_url or service.source_url(a.source_kind, a.source_ref)
info = _extract_meta(url, adapter) info = _extract_meta(url, adapter, a.id)
uu = adapter.uploader_url(info) if info else None uu = adapter.uploader_url(info) if info else None
if uu: if uu:
a.uploader_url = uu a.uploader_url = uu
filled_url += 1 filled_url += 1
log.info("asset %s uploader_url <- %s", a.id, uu) log.info(
"asset %s uploader_url filled", a.id
) # the URL names what they downloaded
if a.poster_path is None: if a.poster_path is None:
# Record a self-hosted poster for every download (the reliable og:image source): # Record a self-hosted poster for every download (the reliable og:image source):
# ensure_poster returns the existing <base>.jpg thumbnail sidecar, or cuts a frame. # ensure_poster returns the existing <base>.jpg thumbnail sidecar, or cuts a frame.
@@ -73,7 +93,7 @@ def run() -> dict:
if rel: if rel:
a.poster_path = rel a.poster_path = rel
filled_poster += 1 filled_poster += 1
log.info("asset %s poster <- %s", a.id, rel) log.info("asset %s poster filled", a.id) # the path carries the title
db.commit() db.commit()
result = { result = {
"assets": len(assets), "assets": len(assets),
+7 -2
View File
@@ -23,19 +23,19 @@ Security controls enforced here:
logged, or written to a job's error/snapshot. logged, or written to a job's error/snapshot.
""" """
import logging
import os import os
from pathlib import Path from pathlib import Path
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import logs
from app.downloads import formats from app.downloads import formats
from app.downloads.adapters import _host_is from app.downloads.adapters import _host_is
from app.models import DownloadCredential, MediaAsset from app.models import DownloadCredential, MediaAsset
from app.security import decrypt_optional, encrypt from app.security import decrypt_optional, encrypt
log = logging.getLogger("siftlode.downloads.credentials") log = logs.get_logger(__name__)
class HostSpec: class HostSpec:
@@ -55,6 +55,11 @@ class HostSpec:
# all derive from this, so there is nothing else to wire up. # all derive from this, so there is nothing else to wire up.
SUPPORTED_HOSTS: tuple[HostSpec, ...] = ( SUPPORTED_HOSTS: tuple[HostSpec, ...] = (
HostSpec("facebook", "Facebook", ("facebook.com", "fb.watch")), HostSpec("facebook", "Facebook", ("facebook.com", "fb.watch")),
# YouTube gates INDIVIDUAL videos behind "Sign in to confirm you're not a bot" (measured on prod
# job 273: every player client fails on that one video while its neighbours download fine, and
# the newest yt-dlp fails the same way — a signed-in session is the only way through). Without a
# row here the `login_required` copy sent the user to a dropdown that could not help them.
HostSpec("youtube", "YouTube", ("youtube.com", "youtu.be", "youtube-nocookie.com")),
HostSpec("instagram", "Instagram", ("instagram.com",)), HostSpec("instagram", "Instagram", ("instagram.com",)),
HostSpec("twitter", "X (Twitter)", ("twitter.com", "x.com")), HostSpec("twitter", "X (Twitter)", ("twitter.com", "x.com")),
HostSpec("tiktok", "TikTok", ("tiktok.com",)), HostSpec("tiktok", "TikTok", ("tiktok.com",)),
+78 -15
View File
@@ -23,7 +23,6 @@ for a player hover-preview later.
import hashlib import hashlib
import json import json
import logging
import math import math
import os import os
import queue import queue
@@ -33,9 +32,11 @@ import threading
import time import time
from pathlib import Path from pathlib import Path
from app import logs
from app.config import settings from app.config import settings
from app.downloads.stated import StatedFailure
log = logging.getLogger("siftlode.edit") log = logs.get_logger(__name__)
def encode_thread_cap() -> int: def encode_thread_cap() -> int:
@@ -71,7 +72,13 @@ def normalize_edit_spec(spec: dict | None) -> dict:
if crop: if crop:
try: try:
c = {k: int(round(float(crop[k]))) for k in ("x", "y", "w", "h")} c = {k: int(round(float(crop[k]))) for k in ("x", "y", "w", "h")}
except (KeyError, TypeError, ValueError): except (KeyError, TypeError, ValueError) as exc:
# The clip still renders — uncropped. The user asked for a crop, got a whole frame,
# and nothing anywhere called it a failure; this line is the only trace there is.
log.warning(
"the crop in an edit spec could not be read and was dropped",
extra=logs.event("edit.spec.crop_dropped", reason=type(exc).__name__),
)
c = None c = None
if c and c["w"] > 0 and c["h"] > 0 and c["x"] >= 0 and c["y"] >= 0: if c and c["w"] > 0 and c["h"] > 0 and c["x"] >= 0 and c["y"] >= 0:
out["crop"] = c out["crop"] = c
@@ -80,6 +87,7 @@ def normalize_edit_spec(spec: dict | None) -> dict:
segs = spec.get("segments") segs = spec.get("segments")
if isinstance(segs, list) and segs: if isinstance(segs, list) and segs:
norm: list[dict] = [] norm: list[dict] = []
unreadable = 0
for s in segs: for s in segs:
try: try:
st = max(0.0, float((s or {}).get("start_s") or 0.0)) st = max(0.0, float((s or {}).get("start_s") or 0.0))
@@ -88,9 +96,18 @@ def normalize_edit_spec(spec: dict | None) -> dict:
continue continue
en = float(end_raw) en = float(end_raw)
except (TypeError, ValueError): except (TypeError, ValueError):
# silent-ok: counted, not logged here — one cut-list can drop several, and the
# join that comes out is simply shorter than the one the user built. Reported
# once after the loop.
unreadable += 1
continue continue
if en > st: if en > st:
norm.append({"start_s": round(st, 3), "end_s": round(en, 3)}) norm.append({"start_s": round(st, 3), "end_s": round(en, 3)})
if unreadable:
log.warning(
"some segments of a cut list could not be read and were dropped",
extra=logs.event("edit.spec.segments_dropped", dropped=unreadable, kept=len(norm)),
)
norm.sort(key=lambda x: x["start_s"]) norm.sort(key=lambda x: x["start_s"])
# SEC-13: an accurate join builds one ffmpeg `filter_complex` with two filters per segment, # SEC-13: an accurate join builds one ffmpeg `filter_complex` with two filters per segment,
# so an unbounded list becomes an unbounded command line and an ffmpeg that never finishes. # so an unbounded list becomes an unbounded command line and an ffmpeg that never finishes.
@@ -114,7 +131,13 @@ def normalize_edit_spec(spec: dict | None) -> dict:
start = max(0.0, float(trim.get("start_s") or 0.0)) start = max(0.0, float(trim.get("start_s") or 0.0))
end_raw = trim.get("end_s") end_raw = trim.get("end_s")
end = float(end_raw) if end_raw is not None else None end = float(end_raw) if end_raw is not None else None
except (TypeError, ValueError): except (TypeError, ValueError) as exc:
# Either a 400 the user cannot act on, or — beside a crop — a full-length clip where
# they asked for a cut. Both are worth one line saying the spec was the problem.
log.warning(
"the trim in an edit spec could not be read and was dropped",
extra=logs.event("edit.spec.trim_dropped", reason=type(exc).__name__),
)
start, end = 0.0, None start, end = 0.0, None
t: dict = {"start_s": round(start, 3)} t: dict = {"start_s": round(start, 3)}
if end is not None and end > start: if end is not None and end > start:
@@ -306,11 +329,14 @@ def _parse_out_time(line: str) -> float | None:
if line.startswith("out_time_us="): if line.startswith("out_time_us="):
try: try:
return int(line.split("=", 1)[1]) / 1_000_000 return int(line.split("=", 1)[1]) / 1_000_000
# silent-ok: one line of ffmpeg's `-progress` stream, several per second. None means "this
# line carried no usable time", which the caller already treats as "no progress yet".
except ValueError: except ValueError:
return None return None
if line.startswith("out_time_ms="): # (some builds mislabel this as microseconds) if line.startswith("out_time_ms="): # (some builds mislabel this as microseconds)
try: try:
return int(line.split("=", 1)[1]) / 1_000_000 return int(line.split("=", 1)[1]) / 1_000_000
# silent-ok: as above, the other spelling of the same line.
except ValueError: except ValueError:
return None return None
return None return None
@@ -329,7 +355,9 @@ def _pump(stream, sink: "queue.Queue[str | None]") -> None:
try: try:
for line in stream: for line in stream:
sink.put(line) sink.put(line)
except (ValueError, OSError): # pipe closed under us by the kill path # silent-ok: the pipe was closed under us by our OWN kill path — the expected end of a
# cancelled or timed-out run, and the code that did the killing is the code that reports it.
except (ValueError, OSError):
pass pass
finally: finally:
sink.put(None) sink.put(None)
@@ -347,6 +375,8 @@ def _drain_tail(stream, sink: list[str]) -> None:
sink.append(line) sink.append(line)
if len(sink) > 200: if len(sink) > 200:
del sink[:100] del sink[:100]
# silent-ok: same closed pipe as `_pump` above, and the tail collected so far is still handed
# to whoever reports the failure.
except (ValueError, OSError): except (ValueError, OSError):
pass pass
@@ -362,13 +392,17 @@ def escalate_kill(proc: subprocess.Popen, terminate, kill, grace_s: float, label
try: try:
proc.wait(timeout=grace_s) proc.wait(timeout=grace_s)
return return
# silent-ok: the escalation below IS the handling of this timeout, and surviving THAT is what
# the line under the second wait reports.
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
pass pass
kill() kill()
try: try:
proc.wait(timeout=grace_s) proc.wait(timeout=grace_s)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
log.error("%s pid %s survived SIGKILL", label, proc.pid) # TRY400: the exception IS the message here. A TimeoutExpired's traceback is this
# `wait` call and nothing else — it would add three frames and no fact the line lacks.
log.error("%s pid %s survived SIGKILL", label, proc.pid) # noqa: TRY400
def _stop(proc: subprocess.Popen) -> None: def _stop(proc: subprocess.Popen) -> None:
@@ -404,6 +438,8 @@ def run_ffmpeg(
while True: while True:
try: try:
line = lines.get(timeout=1.0) line = lines.get(timeout=1.0)
# silent-ok: not a failure — the one-second timeout is how this loop gets a turn to
# poll for cancellation while ffmpeg is quiet.
except queue.Empty: except queue.Empty:
line = "" line = ""
if line is None: # stdout hit EOF — ffmpeg is done (or dying) if line is None: # stdout hit EOF — ffmpeg is done (or dying)
@@ -416,11 +452,11 @@ def run_ffmpeg(
if should_cancel(): if should_cancel():
raise EditAborted raise EditAborted
if time.monotonic() - last_output > stall_timeout_s: if time.monotonic() - last_output > stall_timeout_s:
raise RuntimeError(f"ffmpeg stalled: no progress for {int(stall_timeout_s)}s") raise StatedFailure(f"ffmpeg stalled: no progress for {int(stall_timeout_s)}s")
try: try:
ret = proc.wait(timeout=_FFMPEG_EXIT_GRACE_S) ret = proc.wait(timeout=_FFMPEG_EXIT_GRACE_S)
except subprocess.TimeoutExpired: # pipes closed but the process lingers except subprocess.TimeoutExpired: # pipes closed but the process lingers
raise RuntimeError("ffmpeg did not exit after closing its output") raise StatedFailure("ffmpeg did not exit after closing its output") from None
except BaseException: except BaseException:
# ONE exit path stops the process, so the invariant is unconditional: nothing leaves this # ONE exit path stops the process, so the invariant is unconditional: nothing leaves this
# function with ffmpeg still running. Not just our own EditAborted/stall — `on_progress` # function with ffmpeg still running. Not just our own EditAborted/stall — `on_progress`
@@ -435,7 +471,7 @@ def run_ffmpeg(
if pipe: if pipe:
pipe.close() pipe.close()
if ret != 0: if ret != 0:
raise RuntimeError(f"ffmpeg failed ({ret}): {''.join(err_tail)[-300:]}") raise StatedFailure(f"ffmpeg failed ({ret}): {''.join(err_tail)[-300:]}")
# --- ffmpeg: universal browser playability -------------------------------------------------- # --- ffmpeg: universal browser playability --------------------------------------------------
@@ -471,7 +507,13 @@ def run_ffprobe_json(
try: try:
out = subprocess.run(args, capture_output=True, text=True, timeout=30, check=True).stdout out = subprocess.run(args, capture_output=True, text=True, timeout=30, check=True).stdout
return json.loads(out) return json.loads(out)
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError): except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError) as exc:
# None makes every field it would have filled read as absent, and a file with no streams
# and no duration is indistinguishable here from one that is not media at all.
log.warning(
"ffprobe could not read a file",
extra=logs.event("media.ffprobe_failed", reason=type(exc).__name__),
)
return None return None
@@ -525,7 +567,13 @@ def probe_duration(path: Path) -> int:
check=True, check=True,
).stdout.strip() ).stdout.strip()
return int(float(out)) if out else 0 return int(float(out)) if out else 0
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError): except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError, ValueError) as exc:
# Zero is the denominator of the re-encode progress bar, so a failed probe leaves the user
# watching a bar that never moves rather than a job that says it went wrong.
log.info(
"the duration probe failed; progress for this job has no total",
extra=logs.event("media.duration_probe_failed", reason=type(exc).__name__),
)
return 0 return 0
@@ -670,15 +718,28 @@ def _run_ffmpeg_best_effort(cmd: list[str], timeout_s: int) -> Exception | None:
try: try:
subprocess.run(cmd, capture_output=True, timeout=timeout_s, check=True) subprocess.run(cmd, capture_output=True, timeout=timeout_s, check=True)
return None return None
# silent-ok: the exception is the RETURN VALUE. This helper exists so the three callers share
# one run/timeout policy, and each logs the failure with the context only it has.
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError) as exc: except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError) as exc:
return exc return exc
def _step_failure(err: BaseException) -> str:
"""What a best-effort image step's failure line says: the kind and, for ffmpeg, its exit
status. Never `str(err)`: a CalledProcessError or TimeoutExpired quotes ffmpeg's argv, and an
OSError its file name — the source path either way, which is built from the channel and the
title."""
code = getattr(err, "returncode", None)
return f"{type(err).__name__} {code}" if code is not None else type(err).__name__
def _is_jpeg(path: Path) -> bool: def _is_jpeg(path: Path) -> bool:
"""True if the file starts with the JPEG SOI marker (FF D8 FF).""" """True if the file starts with the JPEG SOI marker (FF D8 FF)."""
try: try:
with path.open("rb") as f: with path.open("rb") as f:
return f.read(3) == b"\xff\xd8\xff" return f.read(3) == b"\xff\xd8\xff"
# silent-ok: False only means "do not copy this straight through" — the caller then goes the
# ffmpeg route, which opens the same file and reports it properly if it really is unreadable.
except OSError: except OSError:
return False return False
@@ -699,13 +760,15 @@ def thumbnail_to_jpg(src: Path | None, dest: Path, timeout_s: int = 30) -> Path
shutil.copyfile(src, dest) shutil.copyfile(src, dest)
return dest return dest
except OSError as exc: except OSError as exc:
log.info("thumbnail copy failed (%s); poster falls back to a cut frame", str(exc)[:120]) log.info(
"thumbnail copy failed (%s); poster falls back to a cut frame", _step_failure(exc)
)
return None return None
err = _run_ffmpeg_best_effort(build_thumbnail_jpg_cmd(src, dest), timeout_s) err = _run_ffmpeg_best_effort(build_thumbnail_jpg_cmd(src, dest), timeout_s)
if err is not None: if err is not None:
log.info( log.info(
"thumbnail->jpg conversion failed (%s); poster falls back to a cut frame", "thumbnail->jpg conversion failed (%s); poster falls back to a cut frame",
str(err)[:120], _step_failure(err),
) )
return None return None
return dest if dest.exists() else None return dest if dest.exists() else None
@@ -730,7 +793,7 @@ def ensure_poster(asset, download_root: str, timeout_s: int = 30) -> str | None:
at = min(3.0, (asset.duration_s or 10) * 0.1) at = min(3.0, (asset.duration_s or 10) * 0.1)
err = _run_ffmpeg_best_effort(build_poster_cmd(src, dest, at), timeout_s) err = _run_ffmpeg_best_effort(build_poster_cmd(src, dest, at), timeout_s)
if err is not None: if err is not None:
log.info("poster gen skipped for asset %s: %s", asset.id, str(err)[:120]) log.info("poster gen skipped for asset %s: %s", asset.id, _step_failure(err))
return None return None
return rel if dest.exists() else None return rel if dest.exists() else None
@@ -761,7 +824,7 @@ def ensure_storyboard(asset, download_root: str, timeout_s: int = 90) -> dict |
cmd = build_storyboard_cmd(src, dest, asset.duration_s, geom) cmd = build_storyboard_cmd(src, dest, asset.duration_s, geom)
err = _run_ffmpeg_best_effort(cmd, timeout_s) err = _run_ffmpeg_best_effort(cmd, timeout_s)
if err is not None: if err is not None:
log.info("storyboard gen skipped for asset %s: %s", asset.id, str(err)[:120]) log.info("storyboard gen skipped for asset %s: %s", asset.id, _step_failure(err))
return None return None
if not dest.exists(): if not dest.exists():
return None return None
+37
View File
@@ -212,6 +212,43 @@ _CATALOG: tuple[_Entry, ...] = (
cookie=False, cookie=False,
link=facebook_group_post_id, link=facebook_group_post_id,
), ),
# YouTube gates INDIVIDUAL videos behind "Sign in to confirm you're not a bot" (measured on prod
# job 273, a festival set: all 12 of yt-dlp's player clients fail on that one video while its
# neighbours download fine, the POT sidecar is live and answering, and the newest yt-dlp fails
# identically — so this is the video's own signed-out gate, not a broken stack or a stale yt-dlp).
# The marker is quote-agnostic on purpose: YouTube writes the apostrophe as U+2019, so
# "you're not a bot" with a straight quote never matches the real text.
_Entry(
host="youtube",
markers=("[youtube]", "not a bot"),
code="login_required",
detail="youtube.bot_wall",
cookie=False,
),
# The same wall WITH a cookie. Not yet reproduced with a real YouTube cookie (this user's first
# one is being added now) — but "add cookies" is provably the wrong advice for someone who has,
# and yt-dlp names the likeliest cause itself: "The provided YouTube account cookies are no
# longer valid. They have likely been rotated in the browser as a security measure."
_Entry(
host="youtube",
markers=("[youtube]", "not a bot"),
code="login_required",
detail="youtube.cookie_rejected",
cookie=True,
),
# The same gate one step later: the player response comes back, and then the CDN refuses the
# stream itself ("unable to download video data: HTTP Error 403"). Measured 2026-09-16 on the
# very same video, ~90 minutes after it was answering with the bot message — signed out it
# 403'd on every attempt; with the owner's cookie attached it downloaded in full (519 MB). So
# the category this text carries generically, `network`, is a lie here: nothing was wrong with
# the network. Scoped to a cookie-less YouTube download, where that is what we measured.
_Entry(
host="youtube",
markers=("unable to download video data", "403"),
code="login_required",
detail="youtube.media_forbidden",
cookie=False,
),
) )
+139 -1
View File
@@ -17,8 +17,134 @@ spec shape (all keys optional; defaults + browser-playability clamp applied by `
`container`/`vcodec` keys survive only so an old stored profile deserializes without error. `container`/`vcodec` keys survive only so an old stored profile deserializes without error.
""" """
import contextlib
import contextvars
import hashlib import hashlib
import json import json
import logging
import os
import re
import urllib.parse
from collections.abc import Iterator
from app import logs
log = logs.get_logger(__name__)
# yt-dlp names what it is working on in these shapes, and each one is a row of someone's download
# history: the source URL, the `[extractor] <id>:` prefix on nearly every warning and error, the
# file path it writes to (`storage.rel_path` builds it from the channel and the title), a
# playlist's title, and an id quoted mid-sentence. The job id is already on every line
# (`logs.set_job` in the download child), so none of them adds anything an operator needs. What
# stays: the host (it says which extractor is failing) and the extractor tag. Every pattern below
# is a shape measured in yt-dlp's own source (2026.07.04), pinned in test_ytdlp_logging.py.
_URL_RE = re.compile(r"https?://([^/\s]+)\S*")
# `Destination:` is yt-dlp's own word in `[download] Destination: <path>`, not an id — the path
# after it is what `_PATH_RE` takes out.
_ID_PREFIX_RE = re.compile(r"(\[[\w:.-]+\] )(?!Destination:)[^\s:\[\]]+:")
# The generic extractor's "id" is the URL's basename, percent-DECODED: `My%20Holiday.mp4` is
# `My Holiday` — spaces, brackets, colons and all, which no pattern can bound (`Our Trip: Day 1`
# ends at which `: `?). The job knows its own URL, so the exact name is taken out as a literal
# (`hiding`). This pattern is the fallback for a line logged outside that scope: up to the first
# `: `. Only this extractor: every other one names a site id, and letting the prefix run to `: `
# everywhere would take the words out of lines like `[VideoConvertor] Converting …; Destination:`.
_GENERIC_ID_RE = re.compile(r"(\[generic\] )(?!Destination:)[^\[\]\n]+?:(?= |$)", re.M)
# `[youtube:tab] Playlist <title>: Downloading 5 items` — the title is not an id, and has spaces.
_PLAYLIST_TITLE_RE = re.compile(r"(\] Playlist ).+?(?=: Downloading )")
# `[download] Finished downloading playlist: <title>`, `[download] Downloading playlist: <title>`,
# `… Skipping already downloaded playlist: <title>` — the title runs to the end of the line.
_TITLE_TAIL_RE = re.compile(r"(playlist: |\[download\] Downloading \w+: ).*", re.I)
# An id quoted mid-sentence, not as a prefix: `… not available for <id>`, and `--no-playlist`'s
# `Downloading just the video <id>` (`_yes_playlist`) / `Downloading just video <id>` (multifeed).
_BARE_ID_RE = re.compile(
r"((?:subtitles not available for|Downloading just (?:the )?\w+) )[^\s,;]+"
)
# Two segments or more, and not glued to a word: `www.youtube.com/…` is what the URL pattern
# leaves behind, and it must survive this one.
_PATH_RE = re.compile(r"(?<![\w.…])/(?:[^/\s\"']+/)+[^/\s\"']*")
_HIDDEN: contextvars.ContextVar[tuple[str, ...]] = contextvars.ContextVar(
"formats_hidden", default=()
)
def generic_id(url: str) -> str:
"""The name yt-dlp's generic extractor gives a URL: its basename, extension off, percent-
decoded — `InfoExtractor._generic_id` (2026.07.04), pinned against it in the tests."""
return urllib.parse.unquote(os.path.splitext(url.rstrip("/").split("/")[-1])[0])
@contextlib.contextmanager
def hiding(url: str | None) -> Iterator[None]:
"""For the work on one URL: `log_safe` also takes out the name yt-dlp's generic extractor
gives it, exactly, where yt-dlp puts it (`[generic] <name>:`). A context variable, so it
holds for yt-dlp's logger calls too — they run on the same thread as the extraction."""
name = generic_id(url) if url else ""
token = _HIDDEN.set((*_HIDDEN.get(), name) if name else _HIDDEN.get())
try:
yield
finally:
_HIDDEN.reset(token)
def log_safe(text: object) -> str:
"""yt-dlp's text (its own lines, or a DownloadError's message) with the parts that name a
person's download taken out. Every log line that carries yt-dlp's words goes through here,
not just the adapter's: yt-dlp raises the same text it has just logged, so a line that skipped
it would put back one line later exactly what the adapter took out."""
text = str(text)
for name in _HIDDEN.get():
text = text.replace(f"[generic] {name}:", "[generic] …:")
text = _URL_RE.sub(lambda m: f"{m.group(1)}/…", text)
text = _ID_PREFIX_RE.sub(r"\1…:", text)
text = _GENERIC_ID_RE.sub(r"\1…:", text)
text = _PLAYLIST_TITLE_RE.sub(r"\1…", text)
text = _TITLE_TAIL_RE.sub(r"\1…", text)
text = _BARE_ID_RE.sub(r"\1…", text)
return _PATH_RE.sub("…", text)
class _YdlLog:
"""yt-dlp's logger protocol, pointed at ours.
Without it yt-dlp explains itself to nobody: `quiet` sends its output to a stdout we do not
read, so "Requested format is not available", a signature-extraction failure or a POT refusal
reached us only as whatever text ended up in the raised exception. Measured against yt-dlp
2026.07.04: with `logger` set it calls `debug` for extraction and progress lines and
`warning`/`error` for the rest, and `quiet`/`no_warnings` do NOT gate those calls.
`debug` goes to TRACE: it is several lines per second during a download, so it shows only
when the whole process runs at TRACE. (Not under a user's own Teljes consent — that lives in
the API process's request scope, and the download child that runs yt-dlp has none.)
`error` goes to WARNING, not ERROR. yt-dlp calls it from `trouble()` just before it raises,
on EVERY failed attempt — including the ones the pipeline then recovers from with its fallback
client set. Whether the job failed is the pipeline's verdict (`download.job.failed`), and that
is the one line whose level should say so."""
def _emit(self, level: int, msg: str) -> None:
# The line goes as an ARGUMENT, not as the format string. yt-dlp writes progress as
# `[download] 53.2% of 12.00MiB`, and while stdlib logging only applies `%` formatting to
# a record that has args, this way the question never arises — and it is the shape ruff's
# `G` rules ask for. (The sink's flood limiter groups a bare `%s` by the text itself, so
# distinct yt-dlp warnings do not share one bucket — `logsink._template`.)
log.log(level, "%s", log_safe(msg), extra=logs.event("download.ytdlp"))
def debug(self, msg):
self._emit(logs.TRACE, msg)
def info(self, msg):
self._emit(logging.DEBUG, msg)
def warning(self, msg):
self._emit(logging.WARNING, msg)
def error(self, msg):
self._emit(logging.WARNING, msg)
_YDL_LOG = _YdlLog()
# Fields that affect the produced file — and therefore the cache identity. # Fields that affect the produced file — and therefore the cache identity.
_SIG_KEYS = ( _SIG_KEYS = (
@@ -78,7 +204,13 @@ def normalize(spec: dict) -> dict:
# rather than raising an unhandled 500 at enqueue. # rather than raising an unhandled 500 at enqueue.
try: try:
s["max_height"] = int(s["max_height"]) s["max_height"] = int(s["max_height"])
except (TypeError, ValueError): except (TypeError, ValueError) as exc:
# "best" is the opposite of what a height cap asks for: the user set a ceiling to save
# bandwidth or disk, and the download quietly takes the largest rendition there is.
log.warning(
"the height cap on a download could not be read and was dropped",
extra=logs.event("download.format.max_height_dropped", reason=type(exc).__name__),
)
s["max_height"] = None s["max_height"] = None
for b in ("embed_subs", "embed_chapters", "embed_thumbnail", "sponsorblock"): for b in ("embed_subs", "embed_chapters", "embed_thumbnail", "sponsorblock"):
s[b] = bool(s[b]) s[b] = bool(s[b])
@@ -181,6 +313,12 @@ def build_ydl_opts(
if extra: if extra:
opts.update(extra) opts.update(extra)
# With a logger attached, `quiet` and `no_warnings` stop deciding whether yt-dlp explains itself
# and only decide where — so both stay on above and the explanation comes here instead of to a
# stdout nothing reads. AFTER the adapter merge, like `cookiefile` and the size cap: an adapter
# must not be able to silence the one account of what went wrong that a failed job leaves.
opts["logger"] = _YDL_LOG
# SEC-13: hard ceiling on a single download. AFTER the adapter merge, for the same reason # SEC-13: hard ceiling on a single download. AFTER the adapter merge, for the same reason
# `cookiefile` is — a source adapter must not be able to raise a limit the quota layer set. # `cookiefile` is — a source adapter must not be able to raise a limit the quota layer set.
# #
+14 -5
View File
@@ -16,7 +16,6 @@ Five passes each run:
Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work — no YouTube quota. Runs in the API process (which mounts DOWNLOAD_ROOT). Pure disk/DB work — no YouTube quota.
""" """
import logging
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
@@ -24,14 +23,13 @@ from pathlib import Path
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import sysconfig from app import logs, sysconfig
from app.config import settings from app.config import settings
from app.downloads import storage from app.downloads import storage
from app.models import DownloadJob, MediaAsset from app.models import DownloadJob, MediaAsset
from app.notifications import create_notification from app.notifications import create_notification
log = logging.getLogger("siftlode.downloads.gc") log = logs.get_logger(__name__)
# Dot-prefixed top-level trees that are NOT part of the Plex/flat asset layout — the orphan sweep # Dot-prefixed top-level trees that are NOT part of the Plex/flat asset layout — the orphan sweep
# never touches them (user clips, Plex HLS/img caches, in-progress staging, editor storyboards). # never touches them (user clips, Plex HLS/img caches, in-progress staging, editor storyboards).
_SYSTEM_TREE = {".edits", ".plex-hls", ".plex-img-cache", ".staging", ".storyboards"} _SYSTEM_TREE = {".edits", ".plex-hls", ".plex-img-cache", ".staging", ".storyboards"}
@@ -99,6 +97,7 @@ def _sweep_orphan_files(db: Session, download_root: str) -> int:
if pause_s and touched % batch == 0: if pause_s and touched % batch == 0:
time.sleep(pause_s) time.sleep(pause_s)
unreclaimed = 0
for entry in root.iterdir(): for entry in root.iterdir():
if entry.name.startswith("."): # .edits / .plex-* / .staging / .storyboards + any dotfile if entry.name.startswith("."): # .edits / .plex-* / .staging / .storyboards + any dotfile
continue continue
@@ -111,7 +110,9 @@ def _sweep_orphan_files(db: Session, download_root: str) -> int:
f.unlink() f.unlink()
removed += 1 removed += 1
except OSError: except OSError:
pass # silent-ok: counted, not logged per file — a broken mount would put one line
# under every orphan. The total is reported once at the end of the sweep.
unreclaimed += 1
if entry.is_dir(): # prune emptied dirs (deepest first), then the channel dir itself if entry.is_dir(): # prune emptied dirs (deepest first), then the channel dir itself
for d in sorted( for d in sorted(
(p for p in entry.rglob("*") if p.is_dir()), (p for p in entry.rglob("*") if p.is_dir()),
@@ -120,12 +121,20 @@ def _sweep_orphan_files(db: Session, download_root: str) -> int:
): ):
try: try:
d.rmdir() d.rmdir()
# silent-ok: `rmdir` refusing means the directory still holds files, which is
# the ordinary answer here — this prune only takes the ones that emptied.
except OSError: except OSError:
pass pass
try: try:
entry.rmdir() entry.rmdir()
# silent-ok: the same prune one level up, at the channel directory.
except OSError: except OSError:
pass pass
if unreclaimed:
log.warning(
"some orphaned download files could not be reclaimed",
extra=logs.event("download.gc.unreclaimed", files=unreclaimed, removed=removed),
)
return removed return removed
+2
View File
@@ -49,6 +49,8 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool:
exp_s, sig = grant.split(".", 1) exp_s, sig = grant.split(".", 1)
try: try:
exp = int(exp_s) exp = int(exp_s)
# silent-ok: an untrusted grant that does not parse is simply not a valid grant, and the
# honest answer to it — a password prompt — is the same as for one that expired.
except ValueError: except ValueError:
return False return False
if exp < datetime.now(timezone.utc).timestamp(): if exp < datetime.now(timezone.utc).timestamp():
+2
View File
@@ -66,6 +66,8 @@ def _jpeg_size(path: Path) -> tuple[int, int] | None:
width = (data[3] << 8) + data[4] width = (data[3] << 8) + data[4]
return width, height return width, height
f.seek(length - 2, 1) # skip this segment's payload f.seek(length - 2, 1) # skip this segment's payload
# silent-ok: None omits width/height from the unfurl card, which is what the card does for
# every poster whose dimensions were never asked for. Nothing is claimed that is not true.
except OSError: except OSError:
return None return None
+129 -23
View File
@@ -1,6 +1,5 @@
"""Child-side download/edit pipeline — the yt-dlp download + ffmpeg edit that runs in the spawned subprocess.""" """Child-side download/edit pipeline — the yt-dlp download + ffmpeg edit that runs in the spawned subprocess."""
import logging
import re import re
import shutil import shutil
import time import time
@@ -9,7 +8,7 @@ from pathlib import Path
import yt_dlp import yt_dlp
from app import sysconfig from app import logs, sysconfig
from app.config import settings from app.config import settings
from app.db import SessionLocal from app.db import SessionLocal
from app.downloads import edit as editmod from app.downloads import edit as editmod
@@ -24,12 +23,13 @@ from app.downloads.jobdb import (
_staging_dir, _staging_dir,
_touch_job, _touch_job,
) )
from app.downloads.stated import StatedFailure
from app.downloads.status import JobStatus from app.downloads.status import JobStatus
from app.models import DownloadJob, MediaAsset from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
from app.notifications import create_notification from app.notifications import create_notification
from app.titles import normalize_title from app.titles import normalize_title
log = logging.getLogger("siftlode.worker") log = logs.get_logger(__name__)
class _Aborted(Exception): class _Aborted(Exception):
@@ -98,6 +98,8 @@ def _parse_upload_date(raw) -> date | None:
return None return None
try: try:
return datetime.strptime(str(raw), "%Y%m%d").date() return datetime.strptime(str(raw), "%Y%m%d").date()
# silent-ok: a date we cannot read becomes a date the card does not show, which is what the
# card shows for every source that never supplies one. Absent is the honest rendering.
except ValueError: except ValueError:
return None return None
@@ -138,7 +140,14 @@ def _make_progress_hook(job_id: int, asset_id: int):
try: try:
_fill_asset_meta_early(asset_id, d["info_dict"]) _fill_asset_meta_early(asset_id, d["info_dict"])
except Exception: # noqa: BLE001 — metadata is best-effort, never fail a download except Exception: # noqa: BLE001 — metadata is best-effort, never fail a download
pass # Best-effort is about the DOWNLOAD, not about knowing. What this swallows is
# anything from a shape change in yt-dlp's info dict to the database being down,
# and all the user sees is a card with an id on it until the job finishes.
log.info(
"early metadata could not be filled in for this download",
extra=logs.event("download.meta.early_fill_failed", asset_id=asset_id),
exc_info=True,
)
if d.get("status") != "downloading": if d.get("status") != "downloading":
return return
if not allow_write(): if not allow_write():
@@ -299,7 +308,7 @@ def _produced_file(info: dict, staging: Path) -> Path:
and not _YTDLP_TEMP_RE.search(p.name) and not _YTDLP_TEMP_RE.search(p.name)
] ]
if not cands: if not cands:
raise RuntimeError("yt-dlp produced no output file") raise StatedFailure("yt-dlp produced no output file")
return max(cands, key=lambda p: p.stat().st_size) return max(cands, key=lambda p: p.stat().st_size)
@@ -375,13 +384,22 @@ def _extract_with_fallback(
"job %s: clients %s failed (%s); retrying with fallback", "job %s: clients %s failed (%s); retrying with fallback",
job_id, job_id,
clients, clients,
str(exc)[:70], formats.log_safe(exc)[:70],
) )
for p in list(staging.iterdir()): # drop partials before the retry for p in list(staging.iterdir()): # drop partials before the retry
try: try:
p.unlink() p.unlink()
except OSError: except OSError as exc:
pass # A partial left behind is not inert: the next client set resumes into it,
# so the retry this loop exists to make clean starts dirty instead.
log.info(
"a partial file could not be removed before the retry",
extra=logs.event(
"download.retry.partial_unlink_failed",
job_id=job_id,
reason=type(exc).__name__,
),
)
_set_job(job_id, progress=0, phase="downloading") _set_job(job_id, progress=0, phase="downloading")
continue continue
raise raise
@@ -464,7 +482,7 @@ def _download(
try: try:
urlguard.check_url(url) urlguard.check_url(url)
except urlguard.BlockedUrl as e: except urlguard.BlockedUrl as e:
raise RuntimeError(f"Refusing to download from this address ({e.reason}).") from e raise StatedFailure(f"Refusing to download from this address ({e.reason}).") from e
# S6: attach the acting user's per-host cookie ONLY when it's host-scoped to this URL AND # S6: attach the acting user's per-host cookie ONLY when it's host-scoped to this URL AND
# this asset is that user's private, salted identity (the double gate lives in credentials). # this asset is that user's private, salted identity (the double gate lives in credentials).
remaining_bytes: int | None = None remaining_bytes: int | None = None
@@ -579,20 +597,30 @@ def _download(
} }
if job and not job.display_name and meta.title: if job and not job.display_name and meta.title:
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early). # display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
fields["display_name"] = meta.title[:255] fields["display_name"] = meta.title[:DISPLAY_TEXT_MAX]
# Atomic terminal write: finalizes ONLY while the row is still running, so a child that # Atomic terminal write: finalizes ONLY while the row is still running, so a child that
# finishes just after a route cancel/pause can't resurrect it (see finalize_if_running). # finishes just after a route cancel/pause can't resurrect it (see finalize_if_running).
finalized = service.finalize_if_running(db, job_id, **fields) finalized = service.finalize_if_running(db, job_id, **fields)
db.commit() db.commit()
if finalized: if finalized:
_notify_download_done(db, owner_id, meta.title, job_id, kind="download") _notify_download_done(db, owner_id, meta.title, job_id, kind="download")
log.info("job %s done: %s", job_id, rel) # The asset id, not `rel`: the path is built from the channel and the title.
log.info(
"job %s done: asset %s",
job_id,
asset_id,
extra=logs.event("download.job.done", job_id=job_id, asset_id=asset_id),
)
except (_Aborted, editmod.EditAborted): except (_Aborted, editmod.EditAborted):
# Paused/canceled mid-download or mid-transcode: free the asset for a later retry; keep the # Paused/canceled mid-download or mid-transcode: free the asset for a later retry; keep the
# job's status (the route set it to paused/canceled). ref_count is adjusted by the route. # job's status (the route set it to paused/canceled). ref_count is adjusted by the route.
_reset_asset_pending(asset_id) _reset_asset_pending(asset_id)
log.info("job %s aborted (pause/cancel)", job_id) log.info(
"job %s aborted (pause/cancel)",
job_id,
extra=logs.event("download.job.aborted", job_id=job_id),
)
except _OwnerRefused as exc: except _OwnerRefused as exc:
# Fail this job only. The asset is never marked `error`; it goes back to `pending` (below), # Fail this job only. The asset is never marked `error`; it goes back to `pending` (below),
# so another user's job for the same video is claimed and downloaded normally instead of # so another user's job for the same video is claimed and downloaded normally instead of
@@ -613,7 +641,12 @@ def _download(
# it, and this one must already be out of the way. (The staging tree is still torn down in # it, and this one must already be out of the way. (The staging tree is still torn down in
# `finally` below — the same narrow window the pause/cancel path above has always had.) # `finally` below — the same narrow window the pause/cancel path above has always had.)
_reset_asset_pending(asset_id) _reset_asset_pending(asset_id)
log.info("job %s refused for its owner: %s", job_id, msg) log.info(
"job %s refused for its owner: %s",
job_id,
formats.log_safe(msg),
extra=logs.event("download.job.refused", job_id=job_id),
)
except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive except Exception as exc: # noqa: BLE001 — surface any failure to the user, keep worker alive
msg = str(exc)[:500] msg = str(exc)[:500]
# The catalog needs the link and whether the user's cookie went with it: the same yt-dlp # The catalog needs the link and whether the user's cookie went with it: the same yt-dlp
@@ -626,8 +659,9 @@ def _download(
if asset: if asset:
asset.status = "error" asset.status = "error"
asset.error = msg asset.error = msg
# Atomic fail: only while still running, so a route-set cancel/pause is never overwritten # Atomic fail: only while still running, so a route-set cancel/pause is never
# with `error` (error also holds the asset → would re-open the double-release class). # overwritten with `error` (error also holds the asset → would re-open the
# double-release class).
service.finalize_if_running( service.finalize_if_running(
db, db,
job_id, job_id,
@@ -638,7 +672,10 @@ def _download(
phase=None, phase=None,
) )
db.commit() db.commit()
log.warning("job %s failed: %s", job_id, msg) # After the write: a job the write could not fail is not a failed job. When it raises,
# the runner reports this failure with it (`runner._run`) and the worker fails the job.
# silent-ok: not silent — `_log_download_failed` is the report.
_log_download_failed(job_id, msg, diagnosis.code, exc)
finally: finally:
# Unlink the decrypted cookie file explicitly (the rmtree below removes the whole staging # Unlink the decrypted cookie file explicitly (the rmtree below removes the whole staging
# tree too, but never leave a plaintext session cookie on disk a moment longer than needed). # tree too, but never leave a plaintext session cookie on disk a moment longer than needed).
@@ -647,7 +684,50 @@ def _download(
shutil.rmtree(staging, ignore_errors=True) shutil.rmtree(staging, ignore_errors=True)
def _fail_edit(job_id: int, asset_id: int, msg: str) -> None: def _failure_stack(exc: BaseException) -> dict[str, str]:
"""The `extra=` fragment that puts a failure's traceback on its line — or nothing.
Nothing for yt-dlp's DownloadError and for our own `StatedFailure` (`ffmpeg failed (1): …`):
their message is the whole story, and their stack is only the `except` that caught them.
Everything else keeps it — a TypeError on an odd info dict, a plain RuntimeError, an OSError,
a database error: that is our code or our platform failing, and the stack is the lead.
Scrubbed, link by link (`logs.scrubbed_stack`): a stack's messages are data — an OSError names
its file, and a database error raised inside `except DownloadError` carries yt-dlp's
unscrubbed text as its context."""
if isinstance(exc, (yt_dlp.utils.DownloadError, StatedFailure)):
return {}
return logs.scrubbed_stack(exc, formats.log_safe)
def _log_download_failed(job_id: int, msg: str, code: str | None, exc: BaseException) -> None:
"""The child's `download.job.failed` line. `msg` is the user's copy and keeps their URL; the
log's copy goes through `log_safe`, or it would put back one line later the URL and id the
yt-dlp adapter took out of the line before it. The stack only when the classifier could not
place the failure: a recognised one is fully described by its code."""
unplaced = code in (None, "", "unknown")
log.warning(
"job %s failed: %s",
job_id,
formats.log_safe(msg),
extra={
**logs.event(
"download.job.failed",
job_id=job_id,
error_code=code,
error_type=type(exc).__name__,
),
**(_failure_stack(exc) if unplaced else {}),
},
)
def _fail_edit(job_id: int, asset_id: int, msg: str, exc: BaseException | None = None) -> None:
"""Fail an edit job, and say so ONCE — every way an edit fails comes through here, so this is
where its line and its event code live. `exc` is the exception when there was one.
Its traceback goes with the line unless the message already tells it all (`_failure_stack`).
Logged after the write, as on the download error path."""
with SessionLocal() as db: with SessionLocal() as db:
asset = db.get(MediaAsset, asset_id) asset = db.get(MediaAsset, asset_id)
if asset: if asset:
@@ -663,7 +743,22 @@ def _fail_edit(job_id: int, asset_id: int, msg: str) -> None:
phase=None, phase=None,
) )
db.commit() db.commit()
log.warning("edit job %s failed: %s", job_id, msg) # `msg` is the user's copy; ffmpeg's stderr tail in it can name the source file, whose path is
# built from the channel and the title.
log.warning(
"edit job %s failed: %s",
job_id,
formats.log_safe(msg),
extra={
**logs.event(
"edit.job.failed",
job_id=job_id,
asset_id=asset_id,
**({"error_type": type(exc).__name__} if exc is not None else {}),
),
**(_failure_stack(exc) if exc is not None else {}),
},
)
def _process_edit(job_id: int, asset_id: int) -> None: def _process_edit(job_id: int, asset_id: int) -> None:
@@ -715,7 +810,7 @@ def _process_edit(job_id: int, asset_id: int) -> None:
should_cancel=_cancel_poll(job_id), should_cancel=_cancel_poll(job_id),
) )
if not dest_staging.exists(): if not dest_staging.exists():
raise RuntimeError("ffmpeg produced no output file") raise StatedFailure("ffmpeg produced no output file")
rel = storage.edit_rel_path(user_id, asset_id, out_ext) rel = storage.edit_rel_path(user_id, asset_id, out_ext)
storage.place_file(dest_staging, settings.download_root, rel) storage.place_file(dest_staging, settings.download_root, rel)
@@ -751,13 +846,24 @@ def _process_edit(job_id: int, asset_id: int) -> None:
db.commit() db.commit()
if finalized: if finalized:
_notify_download_done(db, owner_id, dl_title, job_id, kind="edit") _notify_download_done(db, owner_id, dl_title, job_id, kind="edit")
log.info("edit job %s done: %s", job_id, rel) # The asset id, not `rel`: the path is built from the channel and the title.
log.info(
"edit job %s done: asset %s",
job_id,
asset_id,
extra=logs.event("edit.job.done", job_id=job_id, asset_id=asset_id),
)
except editmod.EditAborted: except editmod.EditAborted:
# Paused/canceled mid-encode: free the asset for a later retry (ref_count is the route's job). # Paused/canceled mid-encode: free the asset for a later retry (ref_count is the route's job).
_reset_asset_pending(asset_id) _reset_asset_pending(asset_id)
log.info("edit job %s aborted (pause/cancel)", job_id) log.info(
"edit job %s aborted (pause/cancel)",
job_id,
extra=logs.event("edit.job.aborted", job_id=job_id),
)
except Exception as exc: # noqa: BLE001 — surface the failure, keep the worker alive except Exception as exc: # noqa: BLE001 — surface the failure, keep the worker alive
_fail_edit(job_id, asset_id, str(exc)[:500]) # silent-ok: not silent — `_fail_edit` is the report, and it takes `exc` for its stack.
_fail_edit(job_id, asset_id, str(exc)[:500], exc)
finally: finally:
shutil.rmtree(staging, ignore_errors=True) shutil.rmtree(staging, ignore_errors=True)
+2
View File
@@ -64,6 +64,8 @@ def _facts_from_probe(
raw = fmt.get("duration") raw = fmt.get("duration")
if raw is not None: if raw is not None:
duration = int(float(raw)) duration = int(float(raw))
# silent-ok: an unusable duration is stored as no duration, which the UI renders as unknown
# rather than as zero — the honest state, and the one a re-probe can still fill in.
except (TypeError, ValueError): except (TypeError, ValueError):
duration = None duration = None
return vcodec, acodec, width, height, duration return vcodec, acodec, width, height, duration
+45 -8
View File
@@ -11,18 +11,20 @@ writes the terminal job/asset state itself (done / error, or a cooperative pause
progress hook), then exits 0. An unexpected crash exits non-zero and the parent fails the job. progress hook), then exits 0. An unexpected crash exits non-zero and the parent fails the job.
""" """
import logging
import os import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
from app import logs
from app import logsink
from app.config import settings from app.config import settings
from app.db import SessionLocal from app.db import SessionLocal
from app.downloads import edit as editmod from app.downloads import edit as editmod
from app.downloads import formats
from app.models import DownloadJob from app.models import DownloadJob
log = logging.getLogger("siftlode.worker.runner") log = logs.get_logger(__name__)
def _deprioritize() -> None: def _deprioritize() -> None:
@@ -52,12 +54,21 @@ def _deprioritize() -> None:
def main() -> None: def main() -> None:
logging.basicConfig(level=logging.INFO) logs.configure_logging("runner")
_deprioritize() _deprioritize()
if len(sys.argv) != 2: if len(sys.argv) != 2:
print("usage: python -m app.downloads.runner <job_id>", file=sys.stderr) # T201: a usage line is for the person who typed the command. Logging is already
# configured here, and in a deployment it writes JSON records — the one reader of this
# line wants the bare sentence, not a record with a request id they do not have.
print( # noqa: T201
"usage: python -m app.downloads.runner <job_id>", file=sys.stderr
)
raise SystemExit(2) raise SystemExit(2)
job_id = int(sys.argv[1]) 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)
logsink.start() # flushed at exit (atexit) — this process has no shutdown hook of its own
with SessionLocal() as db: with SessionLocal() as db:
job = db.get(DownloadJob, job_id) job = db.get(DownloadJob, job_id)
@@ -71,14 +82,40 @@ def main() -> None:
if asset_id is None: if asset_id is None:
return return
_run(job_id, asset_id, job_kind, source_kind, source_ref, spec, user_id)
def _run(job_id, asset_id, job_kind, source_kind, source_ref, spec, user_id) -> None:
"""Run the job, and be the last stop before Python's own exception hook.
What gets past the pipeline is a failure it could not even record (the database refused the
write that fails the job). Python's hook would print it to stderr raw — and its context is the
download's own failure, whose text is the URL or ffmpeg's stderr naming the file. Here it is
one line with the whole chain scrubbed; the non-zero exit then has the worker fail the job
(`cause=child_exit`)."""
# Imported here (not at module top) so the child pays the yt-dlp import cost only when it has # Imported here (not at module top) so the child pays the yt-dlp import cost only when it has
# real work, and to keep the import graph obvious: runner is a thin entry over the pipeline. # real work, and to keep the import graph obvious: runner is a thin entry over the pipeline.
from app.downloads.pipeline import _download, _process_edit from app.downloads.pipeline import _download, _process_edit
if job_kind == "edit": with formats.hiding(source_ref):
_process_edit(job_id, asset_id) try:
else: if job_kind == "edit":
_download(job_id, asset_id, source_kind, source_ref, spec, user_id) _process_edit(job_id, asset_id)
else:
_download(job_id, asset_id, source_kind, source_ref, spec, user_id)
except Exception as exc: # noqa: BLE001 — whatever it is, it is reported here or raw
# TRY400: the stack goes with it scrubbed (`scrubbed_stack`); `exc_info` is raw.
log.error( # noqa: TRY400
"the download child for job %s failed",
job_id,
extra={
**logs.event(
"download.child.failed", job_id=job_id, error_type=type(exc).__name__
),
**logs.scrubbed_stack(exc, formats.log_safe),
},
)
raise SystemExit(1) from None
if __name__ == "__main__": if __name__ == "__main__":
+10 -5
View File
@@ -20,7 +20,7 @@ from app.downloads import credentials
from app.downloads import edit as editmod from app.downloads import edit as editmod
from app.downloads import formats, mediakind, quota, storage from app.downloads import formats, mediakind, quota, storage
from app.downloads.status import HOLDING, JobStatus from app.downloads.status import HOLDING, JobStatus
from app.models import Channel, DownloadJob, MediaAsset, Video from app.models import DISPLAY_TEXT_MAX, Channel, DownloadJob, MediaAsset, Video
class EditError(Exception): class EditError(Exception):
@@ -87,6 +87,8 @@ def get_or_create_asset(
with db.begin_nested(): with db.begin_nested():
db.add(asset) db.add(asset)
db.flush() db.flush()
# silent-ok: the expected outcome of the race this savepoint exists for — two jobs wanting the
# same asset. Losing it means the other insert won, and re-selecting is the whole handling.
except IntegrityError: except IntegrityError:
asset = db.execute(stmt).scalar_one() asset = db.execute(stmt).scalar_one()
return asset return asset
@@ -177,7 +179,7 @@ def enqueue(
profile_id=profile_id, profile_id=profile_id,
profile_snapshot=spec, profile_snapshot=spec,
display_name=display_name display_name=display_name
or (asset.title[:255] if asset.status == "ready" and asset.title else None), or (asset.title[:DISPLAY_TEXT_MAX] if asset.status == "ready" and asset.title else None),
status="queued", status="queued",
queue_pos=next_queue_pos(db), queue_pos=next_queue_pos(db),
) )
@@ -196,10 +198,13 @@ def enqueue(
def _clip_title(source_title: str | None, display_name: str | None) -> str: def _clip_title(source_title: str | None, display_name: str | None) -> str:
# Clipped to DISPLAY_TEXT_MAX, not to a literal: this feeds `DownloadJob.display_name` through
# `enqueue_edit`, the same column `display_text` guards. A machine-made title, so it is clipped
# rather than run through `display_text` — blank here means "no title", not None.
if display_name: if display_name:
return display_name[:255] return display_name[:DISPLAY_TEXT_MAX]
base = source_title or "Clip" base = source_title or "Clip"
return f"{base} (clip)"[:255] return f"{base} (clip)"[:DISPLAY_TEXT_MAX]
def enqueue_edit( def enqueue_edit(
@@ -253,7 +258,7 @@ def enqueue_edit(
source_asset_id=src_asset.id, source_asset_id=src_asset.id,
edit_spec=spec, edit_spec=spec,
profile_snapshot={}, profile_snapshot={},
display_name=display_name or asset.title, display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
status="queued", status="queued",
queue_pos=next_queue_pos(db), queue_pos=next_queue_pos(db),
) )
+13
View File
@@ -0,0 +1,13 @@
"""The failures the download pipeline raises itself, with the whole story in the message.
`ffmpeg failed (1): …`, `yt-dlp produced no output file`, a post that shows no video: the message
says what happened, and the stack is only the `except` that caught it. A failure line leaves that
stack off (`pipeline._failure_stack`) — but ONLY for these. A plain `RuntimeError` is also what
Python raises for a bug of ours (a dict changed size during iteration, a generator's
StopIteration, `RecursionError`, `NotImplementedError`), and that one's stack is the only lead.
"""
class StatedFailure(RuntimeError):
"""A failure we raised on purpose, whose message is its full explanation. A `RuntimeError` so
every `except RuntimeError` that caught these before still does."""
+21 -3
View File
@@ -19,8 +19,11 @@ from datetime import date
from pathlib import Path from pathlib import Path
from xml.sax.saxutils import escape from xml.sax.saxutils import escape
from app import logs
from app.downloads import mediakind from app.downloads import mediakind
log = logs.get_logger(__name__)
# Reserve room for the "_id.ext" suffix + parent dirs within the 255-char component limit. # Reserve room for the "_id.ext" suffix + parent dirs within the 255-char component limit.
_MAX_TITLE = 120 _MAX_TITLE = 120
# User-facing (Content-Disposition) names can be longer than an on-disk component. # User-facing (Content-Disposition) names can be longer than an on-disk component.
@@ -294,7 +297,13 @@ def write_sidecars(download_root: str, rel: str, meta: MediaMeta, thumb_src: Pat
if thumb_src and thumb_src.exists(): if thumb_src and thumb_src.exists():
shutil.copyfile(str(thumb_src), str(_sidecar(media, ".jpg"))) shutil.copyfile(str(thumb_src), str(_sidecar(media, ".jpg")))
return True return True
except OSError: except OSError as exc:
# The download itself still completes, so the job reads as done while the library it was
# downloaded FOR has no .nfo to read and no poster to show.
log.warning(
"the sidecars beside a download could not be written",
extra=logs.event("download.sidecars.write_failed", reason=type(exc).__name__),
)
return False return False
@@ -304,14 +313,23 @@ def delete_asset_files(download_root: str, rel: str) -> None:
for p in (media, _sidecar(media, ".nfo"), _sidecar(media, ".jpg")): for p in (media, _sidecar(media, ".nfo"), _sidecar(media, ".jpg")):
try: try:
p.unlink() p.unlink()
except OSError: except FileNotFoundError:
pass pass # silent-ok: already gone is the outcome this function wanted
except OSError as exc:
# The row goes and the bytes stay. Nothing else ever revisits this path, so the disk
# quietly keeps a file no account has any way to see, let alone delete.
log.info(
"a deleted download left a file behind",
extra=logs.event("download.asset.file_unlink_failed", reason=type(exc).__name__),
)
# Prune empty Season/Channel dirs up to (but not including) the root. # Prune empty Season/Channel dirs up to (but not including) the root.
root = Path(download_root).resolve() root = Path(download_root).resolve()
parent = media.parent parent = media.parent
while parent != root and root in parent.parents: while parent != root and root in parent.parents:
try: try:
parent.rmdir() # only succeeds if empty parent.rmdir() # only succeeds if empty
# silent-ok: this `rmdir` failing is the loop's stop condition — the parent still
# holds another download's files, so the prune has gone as far up as it may.
except OSError: except OSError:
break break
parent = parent.parent parent = parent.parent
+2 -2
View File
@@ -25,7 +25,7 @@ from sqlalchemy.orm import Session
from app.config import settings from app.config import settings
from app.downloads import edit as editmod from app.downloads import edit as editmod
from app.downloads import mediakind, probe, quota, service, storage from app.downloads import mediakind, probe, quota, service, storage
from app.models import DownloadJob, MediaAsset from app.models import DownloadJob, MediaAsset, display_text
def _title_from_filename(filename: str) -> str: def _title_from_filename(filename: str) -> str:
@@ -99,7 +99,7 @@ def ingest_upload(
source_kind="upload", source_kind="upload",
source_ref=ref, source_ref=ref,
profile_snapshot={}, profile_snapshot={},
display_name=(display_name or "").strip() or None, display_name=display_text(display_name),
status="done", status="done",
progress=100, progress=100,
queue_pos=service.next_queue_pos(db), queue_pos=service.next_queue_pos(db),
+15 -7
View File
@@ -11,7 +11,6 @@ to be a person telling them nothing arrived.
""" """
import html import html
import logging
import re import re
import smtplib import smtplib
import ssl import ssl
@@ -21,15 +20,14 @@ from email.utils import formatdate
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import mailtext, sysconfig from app import logs, mailtext, sysconfig
from app.config import settings from app.config import settings
from app.db import SessionLocal from app.db import SessionLocal
from app.models import User from app.models import User
from app.notifications import create_notification from app.notifications import create_notification
from app.ratelimit import RateLimiter from app.ratelimit import RateLimiter
log = logging.getLogger("siftlode.email") log = logs.get_logger(__name__)
# One admin notice an hour per reason. A relay that is down fails every send, and the inbox must # One admin notice an hour per reason. A relay that is down fails every send, and the inbox must
# stay readable; the log keeps the full record either way. # stay readable; the log keeps the full record either way.
_alert_throttle = RateLimiter(max_events=1, window_seconds=3600) _alert_throttle = RateLimiter(max_events=1, window_seconds=3600)
@@ -155,7 +153,15 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
return False return False
c = _smtp() c = _smtp()
if not (c["host"] and c["user"] and c["password"]): if not (c["host"] and c["user"] and c["password"]):
log.info("SMTP not configured; skipping email %r to %s", subject, recipients) # The count, not the addresses: `redact_text` rewrites every one of them to `[email]`
# before either sink sees the line, so the list only ever printed as noise. The subject
# stays — the templates are static, and the one that interpolates (`admin_new_request`)
# interpolates an address, which the same redactor covers.
log.info(
"SMTP not configured; skipping email %r to %d recipient(s)",
subject,
len(recipients), # log-pii-ok: how many, not who
)
_alert_admins("not_configured", subject, recipients) _alert_admins("not_configured", subject, recipients)
return False return False
msg = EmailMessage() msg = EmailMessage()
@@ -176,10 +182,12 @@ def _send(to: list[str], subject: str, body: str, reply_to: str | None = None) -
s.starttls(context=ssl.create_default_context()) s.starttls(context=ssl.create_default_context())
s.login(c["user"], c["password"]) s.login(c["user"], c["password"])
s.send_message(msg) s.send_message(msg)
log.info("Sent email %r to %s", subject, recipients) # log-pii-ok: how many, not who
log.info("Sent email %r to %d recipient(s)", subject, len(recipients))
return True return True
except Exception: except Exception:
log.exception("SMTP send failed (%r to %s)", subject, recipients) # log-pii-ok: how many, not who
log.exception("SMTP send failed (%r to %d recipient(s))", subject, len(recipients))
_alert_admins("send_failed", subject, recipients) _alert_admins("send_failed", subject, recipients)
return False return False
+110 -25
View File
@@ -12,9 +12,10 @@ import binascii
import json import json
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from sqlalchemy import BigInteger, Select, and_, cast, false, func, or_, select from sqlalchemy import BigInteger, Select, and_, case, cast, false, func, or_, select
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
from app.collation import collated
from app.models import ( from app.models import (
LIVE_OR_UPCOMING, LIVE_OR_UPCOMING,
BlockedChannel, BlockedChannel,
@@ -30,7 +31,7 @@ from app.models import (
Video, Video,
VideoState, VideoState,
) )
from app.utils import iso, to_tsquery_str from app.utils import iso, like_escape, to_tsquery_str
def _channel_url(channel_id: str, handle: str | None) -> str: def _channel_url(channel_id: str, handle: str | None) -> str:
@@ -105,15 +106,30 @@ def _tag_visible(user: User):
return or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id) return or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
def tags_by_category(db: Session, tag_ids: list[int]) -> dict[str, list[int]]: # The pseudo-category of a selected tag id no Tag row answers (a saved view or the feed state outlives
# a deleted tag). It filters like a category whose tags no channel carries — to nothing, in the SQL and
# in feed_summary's mirror alike — instead of the id being dropped, which showed the WHOLE feed under
# the view's old name (E32 S2). No real category is called this (language/topic/other).
MISSING_TAG_CATEGORY = "missing"
def tags_by_category(db: Session, user: User, tag_ids: list[int]) -> dict[str, list[int]]:
"""Group tag ids by their category (language/topic/other). The single source for the category split """Group tag ids by their category (language/topic/other). The single source for the category split
shared by the feed's tag WHERE (filtered_query) and the facet/count summary (feed_summary), so the shared by the feed's tag WHERE (filtered_query) and the facet/count summary (feed_summary), so the
two can't disagree on which selected tag belongs to which category.""" two can't disagree on which selected tag belongs to which category. An unknown id lands in
MISSING_TAG_CATEGORY — and so does another user's private tag: resolving it to its real category
told the caller which foreign ids exist (E32 review)."""
if not tag_ids: if not tag_ids:
return {} # skip the pointless `WHERE id IN ()` round-trip on the no-tag-filter hot path return {} # skip the pointless `WHERE id IN ()` round-trip on the no-tag-filter hot path
out: dict[str, list[int]] = {} out: dict[str, list[int]] = {}
for tid, cat in db.execute(select(Tag.id, Tag.category).where(Tag.id.in_(tag_ids))).all(): visible = or_(Tag.user_id.is_(None), Tag.user_id == user.id)
for tid, cat in db.execute(
select(Tag.id, Tag.category).where(Tag.id.in_(tag_ids), visible)
).all():
out.setdefault(cat, []).append(tid) out.setdefault(cat, []).append(tid)
known = {tid for ids in out.values() for tid in ids}
if missing := [tid for tid in tag_ids if tid not in known]:
out[MISSING_TAG_CATEGORY] = missing
return out return out
@@ -264,7 +280,8 @@ def filtered_query(
# the queries that surfaced the video > description; see Video.search_vector). YouTube-like # the queries that surfaced the video > description; see Video.search_vector). YouTube-like
# "fuzzy" matching: word-order-independent, multi-word AND, prefix on the word being typed, # "fuzzy" matching: word-order-independent, multi-word AND, prefix on the word being typed,
# accent-insensitive (unaccent_simple). The channel name still matches as a substring so # accent-insensitive (unaccent_simple). The channel name still matches as a substring so
# channel searches work. `rank_expr` (weight-aware ts_rank) drives the "relevance" sort. # channel searches work. `rank_expr` (weight-aware ts_rank) drives the "relevance" sort, over the
# newest RANK_WINDOW matches only (see there).
rank_expr = None rank_expr = None
if q: if q:
# Resolve the channels whose NAME matches up front (there are only a few hundred channels, so # Resolve the channels whose NAME matches up front (there are only a few hundred channels, so
@@ -272,8 +289,9 @@ def filtered_query(
# `unaccent(channels.title) ILIKE '%q%'` straight into the per-video predicate defeated the # `unaccent(channels.title) ILIKE '%q%'` straight into the per-video predicate defeated the
# search_vector GIN — the planner detoasted every non-matching tsvector to test the `@@` # search_vector GIN — the planner detoasted every non-matching tsvector to test the `@@`
# (~2.6s/search on the prod-sized set); channel_id.in_(...) keeps the GIN as a BitmapOr branch # (~2.6s/search on the prod-sized set); channel_id.in_(...) keeps the GIN as a BitmapOr branch
# (PERF-12). Empty match -> false() so the OR collapses to the text match alone. # (PERF-12). Empty match -> false() so the OR collapses to the text match alone. The query
like = func.unaccent(f"%{q}%") # is a literal inside the pattern: `_` in `my_channel` is not a wildcard.
like = func.unaccent(f"%{like_escape(q)}%")
matching_channel_ids = ( matching_channel_ids = (
db.execute(select(Channel.id).where(func.unaccent(Channel.title).ilike(like))) db.execute(select(Channel.id).where(func.unaccent(Channel.title).ilike(like)))
.scalars() .scalars()
@@ -301,7 +319,7 @@ def filtered_query(
# ALL its selected tags. ⚠️ feed_summary.passes() reimplements this exact rule in Python for the # ALL its selected tags. ⚠️ feed_summary.passes() reimplements this exact rule in Python for the
# sidebar count/facets — keep the two in lockstep (equivalence-tested by # sidebar count/facets — keep the two in lockstep (equivalence-tested by
# test_feed_summary_matches_count_plus_facets). # test_feed_summary_matches_count_plus_facets).
by_category = tags_by_category(db, tags) by_category = tags_by_category(db, user, tags)
# Facet counting drops the category being counted so its own chips don't zero each # Facet counting drops the category being counted so its own chips don't zero each
# other out (standard drill-down faceting: a category's count ignores its own # other out (standard drill-down faceting: a category's count ignores its own
# selections but still honours the other categories' filters). # selections but still honours the other categories' filters).
@@ -392,7 +410,7 @@ def feed_summary(db: Session, user: User, **params) -> dict:
return {"count": 0, "facets": {}} return {"count": 0, "facets": {}}
# channel -> its visible tag ids, plus each tag's category (for grouping the facet output). # channel -> its visible tag ids, plus each tag's category (for grouping the facet output).
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id) visible = _tag_visible(user)
channel_tags: dict[str, set[int]] = {} channel_tags: dict[str, set[int]] = {}
tag_category: dict[int, str] = {} tag_category: dict[int, str] = {}
for cid, tid, cat in db.execute( for cid, tid, cat in db.execute(
@@ -407,7 +425,7 @@ def feed_summary(db: Session, user: User, **params) -> dict:
# base channel carries still filters everything out for its category — matching the SQL `IN (empty)` # base channel carries still filters everything out for its category — matching the SQL `IN (empty)`
# behaviour). # behaviour).
selected_by_cat: dict[str, set[int]] = { selected_by_cat: dict[str, set[int]] = {
cat: set(ids) for cat, ids in tags_by_category(db, params.get("tags") or []).items() cat: set(ids) for cat, ids in tags_by_category(db, user, params.get("tags") or []).items()
} }
def passes(ch_tags: set[int], exclude_cat: str | None) -> bool: def passes(ch_tags: set[int], exclude_cat: str | None) -> bool:
@@ -435,7 +453,7 @@ def feed_summary(db: Session, user: User, **params) -> dict:
# Drop this category's own selections so its chips keep independent counts; a conjunctive # Drop this category's own selections so its chips keep independent counts; a conjunctive
# topic-AND keeps them (each remaining chip narrows to channels that ALSO have the selected). # topic-AND keeps them (each remaining chip narrows to channels that ALSO have the selected).
exclude = None if tag_modes.get(category, "or") == "and" else category exclude = None if tag_modes.get(category, "or") == "and" else category
for cid, ch_tags in channel_tags.items(): for ch_tags in channel_tags.values():
if not passes(ch_tags, exclude): if not passes(ch_tags, exclude):
continue continue
for tid in ch_tags: for tid in ch_tags:
@@ -477,8 +495,10 @@ def feed_years(db: Session, user: User, **params) -> list[dict]:
# replaces OFFSET/LIMIT: deep scrolling no longer scans-and-discards skipped rows, # replaces OFFSET/LIMIT: deep scrolling no longer scans-and-discards skipped rows,
# and the page boundary is stable even as the scheduler ingests new videos mid-scroll # and the page boundary is stable even as the scheduler ingests new videos mid-scroll
# (OFFSET would shift and duplicate/skip rows). # (OFFSET would shift and duplicate/skip rows).
def sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]: def sort_keys(sort: str, seed: int, rank_expr=None, lang: str | None = None) -> list[dict]:
"""`lang` orders the title sorts in the reader's alphabet (`collation`); the other keys ignore it."""
pub = {"expr": Video.published_at, "asc": False, "nullable": False, "kind": "dt"} pub = {"expr": Video.published_at, "asc": False, "nullable": False, "kind": "dt"}
title = collated(Video.title, lang)
table: dict[str, list[dict]] = { table: dict[str, list[dict]] = {
"newest": [pub], "newest": [pub],
"oldest": [{**pub, "asc": True}], "oldest": [{**pub, "asc": True}],
@@ -490,10 +510,8 @@ def sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]:
"duration_asc": [ "duration_asc": [
{"expr": Video.duration_seconds, "asc": True, "nullable": True, "kind": "num"} {"expr": Video.duration_seconds, "asc": True, "nullable": True, "kind": "num"}
], ],
"title": [{"expr": func.lower(Video.title), "asc": True, "nullable": True, "kind": "str"}], "title": [{"expr": title, "asc": True, "nullable": True, "kind": "str"}],
"title_desc": [ "title_desc": [{"expr": title, "asc": False, "nullable": True, "kind": "str"}],
{"expr": func.lower(Video.title), "asc": False, "nullable": True, "kind": "str"}
],
"subscribers": [ "subscribers": [
{"expr": Channel.subscriber_count, "asc": False, "nullable": True, "kind": "num"} {"expr": Channel.subscriber_count, "asc": False, "nullable": True, "kind": "num"}
], ],
@@ -541,6 +559,54 @@ def sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]:
return table.get(sort) or table["newest"] return table.get(sort) or table["newest"]
# Relevance ranks only the newest RANK_WINDOW matches (E31): ts_rank reads every match's tsvector, and
# a broad word matches most of the library ("a" ranked 193k rows in 6.8 s on prod, against 1.1 s to just
# find them). The rows past the window keep rank 0, so they follow the ranked ones newest first; a word
# that broad has no meaningful relevance order anyway, and a specific one fits the window whole.
RANK_WINDOW = 2000
def rank_window_start(db: Session, query: Select) -> datetime | None:
"""The published_at the ranked window starts at: the RANK_WINDOW-th newest match, None when fewer
match (rank them all). The newest-first walk stops after RANK_WINDOW matches (40 ms for "a")."""
return db.scalar(
query.with_only_columns(Video.published_at)
.order_by(Video.published_at.desc())
.offset(RANK_WINDOW - 1)
.limit(1)
)
def windowed_rank(rank_expr, since: datetime | None):
"""`rank_expr` inside the window, 0 past it. CASE evaluates ts_rank only for the rows inside."""
if since is None:
return rank_expr
return case((Video.published_at >= since, rank_expr), else_=0)
def windowed_relevance_rows(
db: Session, query: Select, keys: list[dict], window: datetime, cursor_id: str | None, n: int
) -> list:
"""Up to `n` rows of the windowed relevance order, read as its two halves: the window ranked, then
the rest newest first (every window row sorts before every row past it). As one ORDER BY the
planner computes the CASE for every match and sorts them all (2.7 s for "a" on prod); split, each
half rides an index (80 ms + 5 ms). With a cursor, `keys` carry its values (keyset_predicate)."""
pub_key = keys[1] # relevance: [rank, published_at]
rows: list = []
if cursor_id is None or pub_key["value"] >= window:
inside = query.where(Video.published_at >= window).order_by(*order_by(keys))
if cursor_id is not None:
inside = inside.where(keyset_predicate(keys, cursor_id))
rows = list(db.execute(inside.limit(n)).all())
if len(rows) < n:
newest = [pub_key] # past the window every rank is 0: the order is newest first
rest = query.where(Video.published_at < window).order_by(*order_by(newest))
if cursor_id is not None:
rest = rest.where(keyset_predicate(newest, cursor_id))
rows += db.execute(rest.limit(n - len(rows))).all()
return rows
def order_by(keys: list[dict]): def order_by(keys: list[dict]):
cols = [] cols = []
for k in keys: for k in keys:
@@ -587,26 +653,45 @@ def keyset_predicate(keys: list[dict], cursor_id: str):
return rec(0) return rec(0)
def encode_cursor(keys: list[dict], row) -> str: def encode_cursor(keys: list[dict], row, window: datetime | None = None) -> str:
"""`window` is the relevance window's start: the cursor carries it, so every page ranks against the
window page one did. Recomputed per page, a video ingested mid-scroll would push the window's oldest
row to rank 0, behind the cursor, and the reader would see it twice."""
vals = [] vals = []
for i, k in enumerate(keys): for i, k in enumerate(keys):
v = getattr(row, f"_k{i}") v = getattr(row, f"_k{i}")
vals.append(v.isoformat() if (v is not None and k["kind"] == "dt") else v) vals.append(v.isoformat() if (v is not None and k["kind"] == "dt") else v)
payload = json.dumps({"k": vals, "id": row.id}) body: dict = {"k": vals, "id": row.id}
if window is not None:
body["w"] = window.isoformat()
payload = json.dumps(body)
return base64.urlsafe_b64encode(payload.encode()).decode() return base64.urlsafe_b64encode(payload.encode()).decode()
def decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str]: def _cursor_dt(v):
"""A cursor's timestamp: aware or refused. The server writes only aware ones (timestamptz), and
windowed_relevance_rows compares them in Python, where a naive one beside an aware one raises — a
500 for a hand-made cursor instead of the 400 (E31 review 2)."""
if v is None:
return None
dt = datetime.fromisoformat(v)
if dt.tzinfo is None:
raise ValueError("naive cursor timestamp")
return dt
def decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str, datetime | None]:
try: try:
payload = json.loads(base64.urlsafe_b64decode(cursor.encode())) payload = json.loads(base64.urlsafe_b64decode(cursor.encode()))
raw = payload["k"] raw = payload["k"]
if len(raw) != len(keys): if len(raw) != len(keys):
raise ValueError("key arity mismatch") raise ValueError("key arity mismatch")
vals = [ vals = [_cursor_dt(v) if k["kind"] == "dt" else v for k, v in zip(keys, raw, strict=True)]
datetime.fromisoformat(v) if (v is not None and k["kind"] == "dt") else v for k, v in zip(keys, vals, strict=True):
for k, v in zip(keys, raw) if v is None and not k["nullable"]:
] raise ValueError("null value for a non-nullable key")
return vals, payload["id"] window = payload.get("w")
return vals, payload["id"], _cursor_dt(window) if window else None
except (binascii.Error, json.JSONDecodeError, KeyError, ValueError, TypeError) as exc: except (binascii.Error, json.JSONDecodeError, KeyError, ValueError, TypeError) as exc:
# Framework-free: the route handler translates this into an HTTP 400 so the # Framework-free: the route handler translates this into an HTTP 400 so the
# read-model stays web-layer-agnostic (see routes/feed.get_feed). # read-model stays web-layer-agnostic (see routes/feed.get_feed).
+3
View File
@@ -0,0 +1,3 @@
"""Bug reports built from debug sessions (E16): the files an issue carries, and where they go —
downloaded as a zip, or (S4) sent to a Gitea issue tracker. The session itself — what is recorded
and tagged — is the core module `app/debugsession.py`."""
+79
View File
@@ -0,0 +1,79 @@
"""What an issue from a debug session carries, as files — one definition for the zip export and the
Gitea upload, so the two cannot drift apart.
* `issue.md` — the title and the body the user wrote and reviewed in the composer (rendered there,
in their language; the server does not rewrite it);
* `client.log` / `server.log` — the session's evidence (`debugsession.bundle`) minus the rows the
user took out, one JSON object per line, browser rows and server rows apart. `.log`, not
`.jsonl`: Gitea refuses that extension (measured, plans/e16 S0);
* the screenshots, in the order they were taken.
Every row is already redacted twice (at capture, and again when stored), and nothing here adds a
field the review list did not show.
"""
import io
import json
import zipfile
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import debugsession, logsink
from app.models import DebugScreenshot, DebugSession
@dataclass(frozen=True)
class IssueFile:
name: str
data: bytes
mime: str
def title_of(session: DebugSession) -> str:
# Not a sentence of ours: the composer always saves a title in the user's language, and a
# session exported before it ever saved is named by its date alone, in no language at all.
title = ((session.draft or {}).get("title") or "").strip()
return title or f"{session.started_at:%Y-%m-%d %H:%M} · {session.id}"
def body_of(session: DebugSession) -> str:
return ((session.draft or {}).get("body") or "").strip()
def _lines(rows) -> bytes:
return "".join(
json.dumps(logsink.serialize(r), ensure_ascii=False, default=str) + "\n" for r in rows
).encode()
def files(db: Session, session: DebugSession) -> list[IssueFile]:
"""The session's issue as files: the text, the two logs, the screenshots."""
rows, _ = debugsession.bundle(db, session, drop_excluded=True)
text = f"# {title_of(session)}\n\n{body_of(session)}\n"
out = [
IssueFile("issue.md", text.encode(), "text/markdown"),
IssueFile("client.log", _lines(r for r in rows if r.source == "web"), "text/plain"),
IssueFile("server.log", _lines(r for r in rows if r.source != "web"), "text/plain"),
]
shots = db.scalars(
select(DebugScreenshot)
.where(DebugScreenshot.session_id == session.id)
.order_by(DebugScreenshot.id)
)
for n, shot in enumerate(shots, start=1):
ext = debugsession.IMAGE_TYPES[shot.mime]
name = f"{debugsession.SCREENSHOT_PREFIX}{n}.{ext}"
out.append(IssueFile(name, shot.data, shot.mime))
return out
def zipped(items: list[IssueFile]) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for item in items:
# The images are compressed already: deflating them again costs time and wins nothing.
method = zipfile.ZIP_STORED if item.mime.startswith("image/") else zipfile.ZIP_DEFLATED
z.writestr(item.name, item.data, compress_type=method)
return buf.getvalue()
+288
View File
@@ -0,0 +1,288 @@
"""The Gitea issue tracker a debug session's issue goes to (E16 S4) — measured against Gitea 1.27.1
before a line of this was written (plans/e16-debug-issue.md, S0).
Configured by an admin (sysconfig `issues_gitea_url` / `_repo` / `_token`, the token encrypted at
rest). The repository must be PRIVATE: an issue carries its reporter's log — page titles, filter
values, at Teljes (TRACE) what they typed into search — and a public repository would publish it. That is
checked on every send, not only in the admin's "test connection", because a repository can be made
public after the fact.
What an issue is made of comes from `bundle.files` (the same files the zip export carries). Sending
is three kinds of call, measured:
* `POST /repos/{repo}/issues` — the title and the body the user wrote;
* `POST /repos/{repo}/issues/{n}/assets?name=…` — ONE file per request (field `attachment`);
* `PATCH /repos/{repo}/issues/{n}` — the body again, with the screenshots shown inline once their
links exist.
A private repository's attachment answers 404 to a browser not signed in to Gitea, which is why the
composer's own preview shows our copies, never these links.
Delivery keeps its progress in the session's draft (`delivery`: the issue number, the files still to
upload, the links of those uploaded, whether the body shows the screenshots yet), so a failure
half-way keeps the issue that exists and a retry does only what is missing — never a second issue.
Every step is checkpointed (the caller's `db.commit`), so what did happen is kept whatever fails
next — and the first checkpoint comes before the first call, with the tracker's settings read ABOVE
it, so neither a row lock nor a read snapshot nor a pooled connection is held across the calls. The
caller claims the session for the send first (`debugsession.claim_send`) and passes the claim's
token in, which is what keeps a second send, an autosave or a screenshot out of the way; the claim
is renewed by every progress report, and a report whose claim is gone raises rather than writing
over the send that took it. `debugsession.not_uploaded` / `images_pending` read that progress.
"""
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from typing import Any, Literal
import httpx
from sqlalchemy.orm import Session
from app import debugsession, logs, sysconfig
from app.issues import bundle
from app.models import DebugSession
log = logs.get_logger(__name__)
TIMEOUT_SECONDS = 20.0
Reason = Literal[
"not_configured", # url, repository or token missing
"unreachable", # no answer, or a 5xx
"bad_token", # 401/403: the token is wrong or lacks write:issue / read:repository
"repo_not_found", # 404 on the repository
"public_repo", # the repository is public — an issue would publish the reporter's log
"issues_disabled", # the repository has its issue tracker switched off
"rejected", # any other 4xx (a file type the instance does not allow, …), or an answer that is
# not the API's (a redirect — an http:// address behind https —, a page that is not JSON)
]
class GiteaError(Exception):
def __init__(self, reason: Reason) -> None:
super().__init__(reason)
self.reason: Reason = reason
def _settings(db: Session) -> tuple[str, str, str]:
return (
sysconfig.get_str(db, "issues_gitea_url").strip().rstrip("/"),
sysconfig.get_str(db, "issues_gitea_repo").strip().strip("/"),
sysconfig.get_str(db, "issues_gitea_token").strip(),
)
def target(db: Session) -> dict | None:
"""Where a report goes, for the composer to say so — the repository — or None when no tracker is
configured (the report can then only be downloaded)."""
url, repo, token = _settings(db)
return {"repo": repo} if url and repo and token else None
@contextmanager
def _client(settings: tuple[str, str, str]) -> Iterator[tuple[httpx.Client, str]]:
# Takes the settings already read, not the session: reading them HERE opened a transaction one
# line after `deliver` had ended one, and held it across the calls below (deep-review round 4).
url, repo, token = settings
if not (url and repo and token):
raise GiteaError("not_configured")
with httpx.Client(
base_url=f"{url}/api/v1",
headers={"Authorization": f"token {token}", "Accept": "application/json"},
timeout=TIMEOUT_SECONDS,
) as client:
yield client, repo
def _call(client: httpx.Client, method: str, path: str, **kwargs) -> httpx.Response:
try:
r = client.request(method, path, **kwargs)
except httpx.HTTPError as exc:
log.warning(
"the issue tracker did not answer",
extra=logs.event("issues.gitea.unreachable", path=path, error=type(exc).__name__),
)
raise GiteaError("unreachable") from None
if r.status_code < 300:
return r
# The status only: Gitea's message can quote back what was sent — the user's own title.
log.warning(
"the issue tracker refused a call",
extra=logs.event("issues.gitea.refused", path=path, status=r.status_code),
)
if r.status_code in (401, 403):
raise GiteaError("bad_token")
if r.status_code == 404:
raise GiteaError("repo_not_found")
if r.status_code >= 500:
raise GiteaError("unreachable")
raise GiteaError("rejected")
def _json(r: httpx.Response, *keys: str) -> dict[str, Any]:
"""The answer as the API's JSON, holding `keys` — anything else is refused, not a crash, so a
step after the issue was created cannot take down the request that holds its record.
The CREATE call is the one place this cannot keep that record: its answer IS the record. An
answer it cannot read there leaves the session unsent, and a retry opens another issue — the
price of not being able to tell a refused create from an unreadable one."""
try:
data = r.json()
# silent-ok: not JSON is one of the answers refused (and logged) just below.
except ValueError:
data = None
if not isinstance(data, dict) or any(k not in data for k in keys):
log.warning(
"the issue tracker answered something that is not its API",
extra=logs.event("issues.gitea.unexpected", status=r.status_code),
)
raise GiteaError("rejected")
return data
def _private_repo(client: httpx.Client, repo: str) -> dict:
data = _json(_call(client, "GET", f"/repos/{repo}"))
if not data.get("private"):
raise GiteaError("public_repo")
if not data.get("has_issues"):
raise GiteaError("issues_disabled")
return data
def check(db: Session) -> dict:
"""The admin's "test connection": the repository answers, is private and takes issues."""
with _client(_settings(db)) as (client, repo):
data = _private_repo(client, repo)
return {"repo": data.get("full_name") or repo, "url": data.get("html_url")}
def preview(db: Session, text: str) -> str:
"""The body as Gitea itself will render it (its markdown flavour, its sanitiser).
**No `Context`, deliberately — a repository context here does not fail the call, it kills the
tracker.** With one, Gitea's post-processor turns every hex-shaped word into a commit link, and
on the API's render path the commit checker it asks is nil: the whole Gitea process segfaults
and restarts, taking the git server (and, here, the container registry) down with it. Every
report carries hex — a tab id, a request id — so this was not an edge case but the only case.
Measured on throwaway instances (2026-09-20): `{"Mode": "gfm", "Context": "<repo>"}` with
`5645b1c0ae01` in the text crashes 1.26.4 and 1.27.1 and is fixed in 1.27.3 — but a self-hoster
runs whatever version they run, so the payload stays safe here rather than relying on theirs.
Dropping `Context` is not a downgrade of the preview: measured against the same body, the HTML
keeps its tables, its task lists and its highlighted code blocks. It only stops resolving
repo-relative links (a hash, a `#123`), which the issue page itself still resolves.
"""
with _client(_settings(db)) as (client, _repo):
r = _call(client, "POST", "/markdown", json={"Text": text, "Mode": "gfm"})
return r.text
def deliver(
db: Session,
session: DebugSession,
*,
token: str,
checkpoint: Callable[[], None],
before_create: Callable[[], None] | None = None,
on_created: Callable[[], None] | None = None,
) -> str:
"""Send the session's issue, or finish sending it; returns its URL.
`token` is the caller's claim on this session (`debugsession.claim_send`). Every progress report
renews it and checks the row still holds it, so a send that outlived its lease stops here rather
than writing over the one that took over (`debugsession.SendClaimLost`).
`checkpoint` persists what has happened so far and is called after every step (the caller's
commit — this module does not decide when a transaction ends). `before_create` runs right before
the tracker is asked to open a NEW issue, after the checks that can refuse it without asking,
and may raise to stop it; `on_created` runs right after one was opened, in time to be committed
by the SAME checkpoint that first writes `issue_url` — anything the caller must not be able to
lose separately from the link belongs there.
"""
state = debugsession.delivery(session)
files = bundle.files(db, session)
attachments = [f for f in files if f.name != "issue.md"]
settings = _settings(db)
# Reading the bundle and the tracker's settings opened a transaction; end it before the first
# call, so nothing holds a snapshot (and an idle connection) across minutes of tracker I/O.
# The settings are read HERE and handed to `_client`, not read inside it: reading them there
# was a SELECT one line after this commit, which reopened the transaction and held it — idle,
# with its snapshot pinned — across the repository check and the create (deep-review round 4
# measured it in pg_stat_activity). A comment saying otherwise sat right here.
checkpoint()
with _client(settings) as (client, repo):
_private_repo(client, repo)
if "number" not in state:
if before_create is not None:
before_create()
created = _json(
_call(
client,
"POST",
f"/repos/{repo}/issues",
json={"title": bundle.title_of(session), "body": bundle.body_of(session)},
),
"number",
"html_url",
)
# Built ON the state read at the start so nothing already in it is dropped. The claim
# is NOT among those things, whatever this comment used to say: `set_delivery` strips
# any `sending` passed to it and stamps the row's own from `token` (round 4 — removing
# the spread left the whole suite green, because the claim never travelled here).
state = {
**state,
"number": created["number"],
"pending": [f.name for f in attachments],
"links": {},
}
session.issue_url = created["html_url"]
try:
debugsession.set_delivery(db, session, state, token)
except debugsession.SendClaimLost:
# The one step where losing the claim costs something irreversible: the issue is
# already open on the tracker and this is what would have recorded its link. We
# still must not write — another send owns the row and is opening its own issue —
# so say which issue was left behind, because nothing else will.
# TRY400: the traceback is one SendClaimLost carrying the session id and
# nothing else; the issue number below is the whole news.
log.error( # noqa: TRY400
"an issue was created by a send that had already lost its claim",
extra=logs.event(
"issues.gitea.orphaned",
number=created["number"],
session_id=session.id,
),
)
raise
if on_created is not None:
on_created()
checkpoint()
log.info(
"issue created",
extra=logs.event("issues.gitea.created", number=created["number"]),
)
number = state["number"]
for item in attachments:
if item.name not in state["pending"]:
continue
asset = _json(
_call(
client,
"POST",
f"/repos/{repo}/issues/{number}/assets",
params={"name": item.name},
files={"attachment": (item.name, item.data, item.mime)},
),
"browser_download_url",
)
state = {
**state,
"pending": [n for n in state["pending"] if n != item.name],
"links": {**state["links"], item.name: asset["browser_download_url"]},
}
debugsession.set_delivery(db, session, state, token)
checkpoint()
shots = [n for n in state["links"] if n.startswith(debugsession.SCREENSHOT_PREFIX)]
if shots and not state.get("images_shown"):
images = "\n".join(f"![{n}]({state['links'][n]})" for n in sorted(shots))
body = f"{bundle.body_of(session)}\n\n---\n\n{images}"
_call(client, "PATCH", f"/repos/{repo}/issues/{number}", json={"body": body})
debugsession.set_delivery(db, session, {**state, "images_shown": True}, token)
checkpoint()
return session.issue_url or ""
+838
View File
@@ -0,0 +1,838 @@
"""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 = logs.get_logger(__name__) # app/downloads/pipeline.py -> siftlode.downloads.pipeline
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 threading
import time
import traceback
import uuid
from collections.abc import Callable
from datetime import datetime, timezone
from typing import Any
from urllib.parse import parse_qsl
from app.config import settings
from app.ratelimit import RateLimiter, client_key
# 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,
}
class _RequestScope:
"""What the auth dependency learns about a request, for every line logged after it.
A MUTABLE object behind a contextvar, not plain contextvars, because of how a request actually
runs: FastAPI resolves a sync dependency (`auth.current_user`) in a threadpool on a COPY of the
context, so a `ContextVar.set()` there is gone by the time the handler runs — measured: that is
how the account never reached a handler's own lines. `log_requests` installs one scope per
request; every copied context shares the same object, so what the dependency writes is seen by
the handler and by the request line itself."""
__slots__ = ("user_id", "level", "debug_session_id")
def __init__(self) -> None:
self.user_id: int | None = None
# The user's consented diagnostics level as a logging number; None at the base level.
self.level: int | None = None
# The user's own open debug session this request named (E16), else None.
self.debug_session_id: str | None = None
_scope: contextvars.ContextVar[_RequestScope | None] = contextvars.ContextVar(
"log_request_scope", default=None
)
def set_user(
user_id: int | None,
level: str = "INFO",
until: datetime | None = None,
debug_session: str | None = None,
) -> None:
"""Attribute the rest of this request's log lines to `user_id`, and log them at the user's
consented diagnostics `level` until `until` (E22 S5; "INFO" = no elevation). Called from the
auth dependency — the one place the session is actually resolved into an account — rather than
from the request middleware, which runs OUTSIDE the session middleware.
`debug_session` tags every line with the user's open debug session (E16). The caller has
already checked it is theirs and open; a session's level arrives here as `level`/`until`.
The root logger's floor is tracked per elevation: a request without the session's header (an
`<img>`, a `<video>` — the browser sends those itself) carries only the user's own level, and
must not lower the floor a concurrent request of the session still needs."""
_user_id.set(user_id)
scope = _scope.get()
if scope is None: # outside log_requests (a script, a unit test): this context only
scope = _RequestScope()
_scope.set(scope)
elevated = level != "INFO" and until is not None and until > datetime.now(timezone.utc)
scope.user_id = user_id
scope.debug_session_id = debug_session
scope.level = resolve_level(level) if elevated else None
if user_id is not None:
key = user_id if debug_session is None else (user_id, debug_session)
_track_elevation(key, scope.level, until)
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, strict=True):
_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).
`code` is positional-only so that no attribute name is reserved: `event("x", code=...)` means
the attribute, not this parameter. Without the `/` it was a TypeError, and `code` is exactly
the word a caller reaches for when the thing that failed has one of its own."""
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_NAMES = (
"access_token",
"refresh_token",
"id_token",
"token",
"code",
"state",
"key",
"secret",
"password",
"pwd",
"sig",
"signature",
"verify",
"reset",
"setup",
)
_SECRET_PARAM_RE = re.compile(
r"\b((?:" + "|".join(_SECRET_PARAM_NAMES) + r")=)([^&\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]"
# `extra={PERSIST: False}` keeps a line out of the `log_events` table while stdout still gets it —
# how `log_requests` caps anonymous traffic per client (app/logsink.py reads it).
PERSIST = "persist"
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_query(query: str) -> str:
"""Scrub a raw query string. DECODED first: the text rules match what was typed, and a browser
sends it percent-encoded — `someone%40example.com` is an address `_EMAIL_RE` cannot see. Each
parameter also goes through the key rules, so `?new_password=` goes as `redact` would drop it."""
pairs = parse_qsl(query, keep_blank_values=True)
return "&".join(f"{key}={_redact_param(key, value)}" for key, value in pairs)
def _redact_param(key: str, value: str) -> str:
"""One decoded `key=value` pair. The parameter-NAME rule is applied to the key here rather than
left to `_SECRET_PARAM_RE` over the rebuilt pair: that pattern was written for the raw,
percent-encoded query, where a value holds no literal space, `&` or quote, and its value class
stops at the first one. On decoded text `?state=a%20b` would keep its tail and `?code=%20abc`
would not match at all — the whole credential straight into the line."""
if key.lower() in _SECRET_PARAM_NAMES:
return REDACTED
return str(redact(value, key=key))
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 ----------------------------------------------------------------------------
_SCRUBBED_STACK = "scrubbed_stack"
def scrubbed_stack(exc: BaseException, scrub: Callable[[BaseException], str]) -> dict[str, str]:
"""`extra=` fragment: `exc`'s traceback, rendered where an `exc_info` stack goes, with the
message of every exception in it put through `scrub`.
For a failure whose message can name someone's data (a download's URL, a file path, a video
id). The frames name code and are kept whole; the text at the foot of each link is the data.
A plain `exc_info` renders the chain too — the `__cause__`/`__context__` a DB error raised
inside `except DownloadError` carries — so scrubbing only the line's own message is not
enough. Merge it into the call's `extra`: `{**event(...), **scrubbed_stack(...)}`."""
links: list[BaseException] = []
link: BaseException | None = exc
while link is not None and all(link is not seen for seen in links):
links.append(link)
link = link.__cause__ or (None if link.__suppress_context__ else link.__context__)
parts: list[str] = []
for i in range(len(links) - 1, -1, -1): # the oldest first, as Python prints a chain
link = links[i]
kind = type(link)
name = kind.__qualname__
if kind.__module__ not in ("builtins", "__main__"):
name = f"{kind.__module__}.{name}"
try:
message = scrub(link)
# silent-ok: the message is what could not be rendered, and the placeholder says so — the
# caller is in the middle of reporting one failure and must not get a second from here.
except Exception: # noqa: BLE001 — a `__str__` that raises, of any shape
message = "<unprintable>"
frames = "".join(traceback.format_tb(link.__traceback__))
head = f"Traceback (most recent call last):\n{frames}" if frames else ""
parts.append(f"{head}{name}: {message}" if message else f"{head}{name}")
if i:
parts.append(
"\nThe above exception was the direct cause of the following exception:\n"
if links[i - 1].__cause__ is link
else "\nDuring handling of the above exception, another exception occurred:\n"
)
return {_SCRUBBED_STACK: "\n".join(parts)}
# 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", PERSIST, "elevated", _SCRUBBED_STACK}
)
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)
scope = _scope.get()
if scope is not None and scope.user_id is not None and not hasattr(record, "user_id"):
record.user_id = scope.user_id
if (
scope is not None
and scope.debug_session_id is not None
and not hasattr(record, "debug_session_id")
):
record.debug_session_id = scope.debug_session_id
return True
class LevelGate(logging.Filter):
"""The configured level, enforced at the HANDLER, with one exception: the lines of a request
whose user raised their diagnostics level (E22 S5).
The root logger's level cannot do this alone: it is one number for the whole process, and the
point is to log ONE user's requests in detail while everyone else's stay at the base level. So
the root is lowered to the lowest level any active elevation needs (`_track_elevation`), and
this gate drops what only an elevation let through unless the line belongs to that elevated
request. It also marks such a line `elevated`, which is what makes the `log_events` sink keep
every line of that request, not just its warnings."""
def filter(self, record: logging.LogRecord) -> bool:
scope = _scope.get()
if scope is not None and scope.level is not None:
record.elevated = True
return record.levelno >= min(scope.level, _base_level)
return record.levelno >= _base_level
def _record_attrs(record: logging.LogRecord) -> dict[str, Any]:
extra = {k: v for k, v in vars(record).items() if k not in _RECORD_STANDARD}
attrs = dict(getattr(record, "attrs", None) or {})
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()
# silent-ok: the failure is reported IN the returned line. Logging it would re-enter this same
# formatter with a record built out of the one that just failed.
except Exception as exc: # noqa: BLE001 — any formatting error, reported not raised
return f"{record.msg!r} (log formatting failed: {exc})"
def record_fields(record: logging.LogRecord) -> dict[str, Any]:
"""The event as structured fields, REDACTED — the one definition both the stdout line and the
`log_events` row are built from, so the two sinks can never disagree about what is safe to keep.
Absent optional fields are left out; `ts` is an aware datetime."""
fields: dict[str, Any] = {
"ts": datetime.fromtimestamp(record.created, timezone.utc),
"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:
fields["event"] = code
for name in _CONTEXT_VARS:
value = getattr(record, name, None)
if value is not None:
fields[name] = value
attrs = _record_attrs(record)
if attrs:
fields["attrs"] = redact(attrs)
if record.exc_info:
fields["exc"] = redact_text(logging.Formatter().formatException(record.exc_info))
elif getattr(record, _SCRUBBED_STACK, None):
fields["exc"] = redact_text(getattr(record, _SCRUBBED_STACK))
elif record.exc_text:
# A QueueHandler-style hand-off formats the traceback into exc_text and drops exc_info.
fields["exc"] = redact_text(record.exc_text)
fields["version"] = settings.app_version
return fields
class JsonFormatter(logging.Formatter):
"""One JSON object per line — the shape Loki and the `log_events` table both ingest."""
def format(self, record: logging.LogRecord) -> str:
payload = record_fields(record)
payload["ts"] = payload["ts"].isoformat(timespec="milliseconds")
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))
elif getattr(record, _SCRUBBED_STACK, None):
line += "\n" + redact_text(getattr(record, _SCRUBBED_STACK))
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",
"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 get_logger(name: str) -> logging.Logger:
"""The logger for one module: `logs.get_logger(__name__)` in `app/plex/sync.py` is
`siftlode.plex.sync`.
The names were literals before, and 25 of them covered 34 modules — six different Plex modules
all called themselves `siftlode.plex`, so neither a level pinned per logger nor E22.6's console
filter could tell a sync line from a stream line. Derived from `__name__` there is exactly one
name per module, it cannot drift from the file it sits in, and `test_logger_names.py` fails if a
module hand-rolls one again.
`__main__` is resolved to the module it really is. The worker runs as `python -m app.worker`
and spawns `python -m app.downloads.runner`, so in the two long-running processes `__name__`
is `__main__` — taken at its word, both would log as the bare root. `-m` records the real name
on the module's spec; a plain script has none and stays the root."""
if name == "__main__":
spec = getattr(sys.modules.get("__main__"), "__spec__", None)
name = getattr(spec, "name", None) or name
stem = name.removeprefix("app.") if name.startswith("app.") else ""
# logger-name-ok: this call IS the derivation
return logging.getLogger(f"siftlode.{stem}" if stem else "siftlode")
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) -> 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. 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
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 = settings.log_format
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"},
"gate": {"()": f"{__name__}.LevelGate"},
},
"handlers": {
"stdout": {
"class": "logging.StreamHandler",
"stream": sys.stdout,
"formatter": formatter,
"filters": ["context", "gate"],
}
},
"root": {"level": resolve_level(settings.log_level), "handlers": ["stdout"]},
"loggers": {name: {"level": lvl} for name, lvl in _THIRD_PARTY_LEVELS.items()},
}
)
set_level(settings.log_level)
def set_level(name: str) -> None:
"""Change the running process's level (the `log_level` admin knob). Third-party loggers keep
their pinned levels, so turning on DEBUG shows OUR detail, not SQLAlchemy's."""
global _base_level
_base_level = resolve_level(name)
_apply_root_level(force=True)
def current_level() -> str:
"""The configured level — not the root logger's, which an active elevation may have lowered."""
return logging.getLevelName(_base_level or logging.getLogger().getEffectiveLevel())
# --- per-user elevation (E22 S5) -----------------------------------------------------------
# The base level (`LOG_LEVEL`, then the admin knob). NOTSET until configure_logging runs — a unit
# test, a script — so the gate lets everything through there, as it did before the gate existed.
_base_level = logging.NOTSET
# → (level number, expiry) for every elevation this process has seen and not yet expired: a user's
# own, by user id, and a debug session's (E16), by (user id, session id) — its requests alone carry
# its level, so it is tracked apart from the user's own requests.
_ElevationKey = int | tuple[int, str]
_elevations: dict[_ElevationKey, tuple[int, datetime]] = {}
_elevations_lock = threading.Lock()
_root_applied: int | None = None
def _track_elevation(key: _ElevationKey, level: int | None, until: datetime | None) -> None:
with _elevations_lock:
if level is None or until is None:
_elevations.pop(key, None)
else:
_elevations[key] = (level, until)
active = bool(_elevations)
if active or _root_applied != _base_level:
_apply_root_level()
def _apply_root_level(force: bool = False) -> None:
"""Root level = the lowest of the base and every unexpired elevation (expired ones are pruned
here). `setLevel` clears every logger's level cache, so it runs only when the number changes."""
global _root_applied
if _base_level == logging.NOTSET:
return # not configured: the root keeps whatever the process set up
now = datetime.now(timezone.utc)
# The level is SET under the lock too, not just computed: two threads that each computed a
# different answer would otherwise apply them in either order, and the older one could win —
# the root back at the base while an elevation is still active.
with _elevations_lock:
for key in [k for k, (_, until) in _elevations.items() if until <= now]:
del _elevations[key]
lowest = min([_base_level, *(lvl for lvl, _ in _elevations.values())])
if force or lowest != _root_applied:
logging.getLogger().setLevel(lowest)
_root_applied = lowest
# --- erased accounts ------------------------------------------------------------------------
# `auth.purge_user` deletes an account's `log_events` rows, but a line about that account can still
# be on its way: the erasing request's own access line is logged AFTER the handler committed, a queued
# row waits for the sink's writer, a background task logs later. Those rows are stored without the
# account (`logsink` asks here). Remembered for an hour in this process — a line later than that has
# no request left to come from, and the retention job is the backstop for any other process.
ERASED_MEMORY_SECONDS = 3600.0
_erased: dict[int, float] = {}
_erased_lock = threading.Lock()
# Held by whoever checks rows against `is_erased` AND writes them (`erasure_guard`). `forget_user`
# takes it too, so an erasure waits for a write that already passed the check to commit — and the
# purge's DELETE, which runs after `forget_user`, then removes those rows as well. Without it a row
# checked a moment before the erasure could be inserted a moment after the DELETE.
_erasure_guard = threading.Lock()
def erasure_guard() -> threading.Lock:
"""Hold this around "drop erased accounts from these rows, insert them, commit"."""
return _erasure_guard
def forget_user(user_id: int) -> None:
"""Mark `user_id` erased: no line logged about it from now on is stored under its id. Call BEFORE
deleting its rows (see `_erasure_guard`)."""
now = time.monotonic()
with _erasure_guard, _erased_lock:
for stale in [u for u, at in _erased.items() if now - at > ERASED_MEMORY_SECONDS]:
del _erased[stale]
_erased[user_id] = now
with _elevations_lock:
for key in [
k for k in _elevations if k == user_id or (isinstance(k, tuple) and k[0] == user_id)
]:
del _elevations[key]
# That pop can have removed the lowest elevation, and the root level is the lowest of the base
# and every unexpired one. Recomputed here rather than left to whichever later request happens
# to call `_track_elevation` — the invariant `_apply_root_level` documents holds at every pop.
_apply_root_level()
def end_session_elevation(session_id: str) -> None:
"""A debug session was stopped: its elevation ends now, not at its cap."""
with _elevations_lock:
for key in [k for k in _elevations if isinstance(k, tuple) and k[1] == session_id]:
del _elevations[key]
_apply_root_level()
def is_erased(user_id: int | None) -> bool:
if user_id is None:
return False
with _erased_lock:
at = _erased.get(user_id)
return at is not None and time.monotonic() - at <= ERASED_MEMORY_SECONDS
def current_source() -> str:
"""The process name `configure_logging` was given (api, worker, runner, alembic)."""
return _source
# --- 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), hashed static assets are served by the hundred per page load, and
# the admin log console reads the stored log — its live tail every few seconds, so at INFO the log
# would fill with the console watching itself. A FAILURE on any of them still logs at WARNING/ERROR
# — this lowers the floor, it does not hide.
_QUIET_PREFIXES = ("/healthz", "/assets/", "/static/", "/api/admin/logs")
# The reads the frontend POLLS (a `useLiveQuery`, the `me` heartbeat, the sync status, the subtitle
# progress), by route template, GET only — logged at DEBUG like the prefixes above. One open tab asked
# for the two unread counts, `/api/me` and the download index every 30 s, and at INFO those were ~95%
# of the stored log (measured on prod 2026-09-19): 11k lines a day per tab burying every line someone
# caused. The same route answered with a failure still logs at WARNING/ERROR.
_POLLED_ROUTES = frozenset(
{
"/api/me",
"/api/me/notifications/unread_count",
"/api/messages/unread_count",
"/api/downloads",
"/api/downloads/index",
"/api/downloads/usage",
"/api/sync/my-status",
"/api/admin/scheduler",
"/api/admin/downloads",
"/api/admin/downloads/storage",
"/api/plex/subtitle/{rating_key}/{sub_ord}/status",
}
)
# Not `siftlode.logs`: this is the replacement for uvicorn's ACCESS log, and `siftlode.http` is
# what an operator pins a level on when they want request lines and nothing else. The module it
# happens to live in is not what the name is about.
_request_log = logging.getLogger("siftlode.http") # logger-name-ok: named for the traffic
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` (with the template in `X-Route`), 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)
scope = _RequestScope()
scope_token = _scope.set(scope)
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),
),
**_request_owner(request, logging.ERROR),
},
)
_scope.reset(scope_token)
_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) or (
request.method == "GET" and _route_template(request) in _POLLED_ROUTES
)
if status >= 500:
level = logging.ERROR
elif status >= 400:
level = logging.WARNING
else:
level = logging.DEBUG if quiet else logging.INFO
attrs: dict[str, Any] = {
"method": request.method,
"path": _route_template(request),
"status": status,
"duration_ms": duration_ms,
}
# Teljes (TRACE) the user consented to: the query string too — redacted, and in attrs only, never
# in the message.
if request.url.query and _traced(scope):
attrs["query"] = redact_query(request.url.query)
_request_log.log(
level,
"%s %s %s",
request.method,
_route_template(request),
status,
extra={**event("http.request", **attrs), **_request_owner(request, level)},
)
response.headers["X-Request-Id"] = rid
# The browser logs its own copy of this line (frontend lib/http.ts) and must pick the same level:
# it learns "this one is quiet" here instead of keeping a second copy of the polled-route list.
if level == logging.DEBUG:
response.headers["X-Log-Quiet"] = "1"
# The template, so the browser's copy of this line groups the same way (frontend lib/http.ts).
# Only a MATCHED route's: a template never carries a path parameter's value (a share token stays
# `{token}`), while the raw-path fallback of an unmatched request could — the browser then
# templates the path itself.
matched = _matched_route(request)
if matched:
response.headers["X-Route"] = matched
_scope.reset(scope_token)
_request_id.reset(token)
return response
def consented_trace() -> bool:
"""Whether the current request's OWN user turned Teljes (TRACE) on — the gate for a line that
carries what they typed or what was quoted back to them. The level alone is not enough: an
admin running the whole process at TRACE would pass it for every user, and a scheduler thread,
which has no request, for none of them in particular. Guard such a line with this, then log it
at TRACE."""
scope = _scope.get()
return scope is not None and _traced(scope)
def _traced(scope: _RequestScope) -> bool:
"""Whether this request's query string is logged: only under its OWN user's Teljes consent. An
admin setting the whole process to TRACE gets more detail, not every user's search terms — the
privacy policy promises those are kept only for a user who agreed to it."""
return scope.level is not None and scope.level <= TRACE
# Anonymous request lines kept per client per minute. Signed-in traffic is not capped: it is a known
# account and exactly what an admin needs to see. Anonymous traffic is where the volume can come from
# nobody in particular — a scanner walking `/wp-login.php`, a bot on a share link — and each of its
# 404s is a WARNING that would otherwise land in `log_events` without limit. The client key is an
# in-memory counter key only; no address is ever written to a log line or a row.
ANONYMOUS_LINES_PER_MINUTE = 60
_anonymous_lines = RateLimiter(ANONYMOUS_LINES_PER_MINUTE, 60.0)
def _request_owner(request, level: int) -> dict[str, Any]:
"""`extra=` fields naming who the request belongs to, and whether its line may be stored. The
account comes from `request.state` (filled by the auth dependency), so it lands in the line's
top-level `user_id` — the correlation field — rather than in `attrs`."""
user_id = getattr(request.state, "user_id", None)
if user_id is not None:
return {"user_id": user_id}
if level < logging.INFO:
return {} # a quiet line is never stored anyway — don't spend the client's allowance on it
return {} if _anonymous_lines.allow(client_key(request)) else {PERSIST: False}
def _matched_route(request) -> str | None:
"""The matched route's template (`/api/videos/{video_id}`), or None when nothing matched."""
return getattr(request.scope.get("route"), "path", None)
def _route_template(request) -> str:
"""The matched route's template, else the raw path for a 404 or a static file. Never the query
string."""
return _matched_route(request) or request.url.path
+367
View File
@@ -0,0 +1,367 @@
"""The `log_events` sink (E22 S3): the stored copy of the log the diagnostics console reads.
stdout stays the complete log (docker keeps 3×50 MB of it); this table keeps the part worth querying
for `log_retention_days`:
* every WARNING and above,
* the `http.request` line of every request at INFO or above (not the DEBUG-level health checks and
static files),
* every line of a request whose user raised their diagnostics level (E22 S5 — `logs.LevelGate`
marks them), which is the detail they consented to have kept,
* nothing a caller marked `extra={logs.PERSIST: False}` — how the request log caps anonymous
traffic per client.
What the table must never do is slow down or break the code that logs. So a line is redacted and
turned into a row on the CALLER's thread (the same `logs.record_fields` the stdout line is built
from), put on a bounded queue without blocking, and written in batches by one background thread
on its own connection — never inside a request's transaction. A full queue drops the row and
counts it; a failed write drops the batch and says so on stderr. Both are reported rather than
retried: a log that retries into a sick database is how a small outage becomes a big one.
Flood control is the second half. On 2026-09-16 a YouTube RSS outage made prod log 11k near-identical
warnings in four hours. stdout keeps them all; the table keeps `REPEATS_PER_MINUTE` of each message
template per minute and one `log.repeats_suppressed` row saying how many more there were.
Core layer: imports `logs`, `db`, `models`, `config` — nothing above them.
"""
import atexit
import json
import logging
import queue
import re
import sys
import threading
import time
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import delete, insert
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from app import logs
from app.config import settings
from app.db import engine
from app.models import LogEvent
from app.utils import iso
QUEUE_MAX = 10_000
BATCH_MAX = 500
FLUSH_SECONDS = 1.0
REPEATS_PER_MINUTE = 30
REPEAT_WINDOW_SECONDS = 60.0
# Distinct templates tracked per window. Past this many, a new template is stored without counting
# — the limiter must not become the unbounded thing it exists to prevent.
_REPEAT_KEYS_MAX = 2_000
# Column widths from models.LogEvent. A value past its column would fail the WHOLE batch it rides in.
_LIMITS = {
"level": 10,
"source": 16,
"logger": 128,
"event": 64,
"request_id": 64,
"debug_session_id": 64,
"version": 64,
"msg": 8_000,
"exc": 32_000,
}
_log = logs.get_logger(__name__)
def should_store(record: logging.LogRecord) -> bool:
"""Whether this record belongs in `log_events` (see the module docstring)."""
if getattr(record, logs.PERSIST, True) is False:
return False
if record.levelno >= logging.WARNING or getattr(record, "elevated", False):
return True
return getattr(record, "event", None) == "http.request" and record.levelno >= logging.INFO
def _without_nul(value: Any) -> Any:
"""`value` with every NUL character replaced, in keys and nested values alike. Postgres refuses
NUL in text and `\\u0000` in JSONB, and a refused row fails the WHOLE batch it rides in — every
user's rows with it. Anything logged can carry one (`GET /assets/%00` logs the decoded path)."""
if isinstance(value, str):
return value.replace("\x00", "�")
if isinstance(value, dict):
return {_without_nul(k): _without_nul(v) for k, v in value.items()}
if isinstance(value, list):
return [_without_nul(v) for v in value]
return value
def to_row(fields: dict[str, Any]) -> dict[str, Any]:
"""`logs.record_fields` output → a `log_events` row: known columns only, text clipped to its
column and free of NUL, attrs made JSON-safe (anything exotic becomes its `str`, as on stdout)."""
row = {
column: fields.get(column) for column in LogEvent.__table__.columns.keys() if column != "id"
}
for column, limit in _LIMITS.items():
value = row.get(column)
if isinstance(value, str):
value = _without_nul(value)
row[column] = value[: limit - 1] + "…" if len(value) > limit else value
if row["attrs"] is not None:
row["attrs"] = _without_nul(
json.loads(json.dumps(row["attrs"], ensure_ascii=False, default=str))
)
return row
def without_erased(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""The rows, with the account taken off any whose account was erased meanwhile
(`logs.forget_user`). Checked right before the INSERT, under `logs.erasure_guard()` held until
the commit: a row built before the erasure can still be waiting for its batch."""
for row in rows:
if logs.is_erased(row.get("user_id")):
row["user_id"] = None
return rows
def _notice(code: str, msg: str, **attrs: Any) -> dict[str, Any]:
"""A row the sink writes about ITSELF. Built directly, not logged: logging it would route it back
through this sink (and the writer thread's own records are deliberately ignored)."""
return to_row(
{
"ts": datetime.now(timezone.utc),
"level": "WARNING",
"sev": logs.SEVERITY["WARNING"],
"source": logs.current_source(),
"logger": _log.name,
"event": code,
"msg": msg,
"attrs": attrs,
"version": settings.app_version,
}
)
_DIGITS = re.compile(r"\d+")
def _template(record: logging.LogRecord) -> str:
"""The flood limiter's grouping key: the message template. Except when the template is a bare
`%s` — a pass-through for someone else's text (yt-dlp's lines, `formats._YdlLog`), where the
template says nothing and every distinct line would share one bucket. Then the text itself
groups, with its numbers folded, so `Retrying fragment 5 (1/10)` and `(2/10)` are still one
repeat and a different warning is not crowded out by them."""
msg = str(record.msg)
if msg != "%s" or not record.args:
return msg
return _DIGITS.sub("#", record.getMessage())[:200]
class _Repeats:
"""Per-template admission for one minute at a time. Used by the writer thread only (unlocked)."""
def __init__(self, per_window: int, window_seconds: float):
self.per_window = per_window
self.window = window_seconds
self.started = time.monotonic()
self.seen: dict[tuple[str, str, int], int] = {}
def admit(self, key: tuple[str, str, int] | None) -> bool:
if key is None:
return True
count = self.seen.get(key)
if count is None and len(self.seen) >= _REPEAT_KEYS_MAX:
return True
self.seen[key] = (count or 0) + 1
return self.seen[key] <= self.per_window
def roll(self, now: float, *, final: bool = False) -> list[dict[str, Any]]:
"""Close the window if it is over — or, `final`, because the process is: one notice per
template that went past the limit. Without `final` a process that lives under a minute
(every download child) would drop its repeats and never say so."""
if not final and now - self.started < self.window:
return []
notices = [
_notice(
"log.repeats_suppressed",
f"{count - self.per_window} more like this were not stored",
logger=logger,
template=logs.redact_text(template),
suppressed=count - self.per_window,
window_seconds=int(self.window),
)
for (logger, template, _level), count in self.seen.items()
if count > self.per_window
]
self.seen.clear()
self.started = now
return notices
class LogSink(logging.Handler):
"""The handler plus its writer. `start()` runs the writer thread; a test drives `write_pending()`
directly instead, on its own thread, with no timing involved."""
def __init__(self, bind: Engine = engine):
super().__init__(level=logging.NOTSET)
# Correlation fields are contextvars: capture them now, on the thread that logged.
self.addFilter(logs.ContextFilter())
self._bind = bind
self._queue: queue.Queue[tuple[tuple[str, str, int] | None, dict[str, Any]]] = queue.Queue(
QUEUE_MAX
)
self._dropped = 0
self._dropped_lock = threading.Lock()
self._repeats = _Repeats(REPEATS_PER_MINUTE, REPEAT_WINDOW_SECONDS)
self._stopping = threading.Event()
self._writer: threading.Thread | None = None
# --- caller side ---------------------------------------------------------------------------
def emit(self, record: logging.LogRecord) -> None:
if threading.current_thread() is self._writer:
return # the writer's own trouble (a driver warning mid-insert) must not feed itself
if not should_store(record):
return
try:
row = to_row(logs.record_fields(record))
# silent-ok: `handleError` is the report — logging's own out-of-band channel to stderr.
# A log call here would be handed to this handler, which is the one that just failed.
except Exception: # noqa: BLE001 — a record we cannot turn into a row is reported, not raised
self.handleError(record)
return
# Request lines are one per request by nature; only the repeated SAME message is a flood.
key = (
None
if row["event"] == "http.request"
else (record.name, _template(record), record.levelno)
)
try:
self._queue.put_nowait((key, row))
# silent-ok: counted, not logged — the count goes out as the `log.sink.dropped` notice row
# the writer emits, which is the one report that does not feed this queue again.
except queue.Full:
with self._dropped_lock:
self._dropped += 1
# --- writer side ---------------------------------------------------------------------------
def write_pending(self, wait: float = 0.0, *, final: bool = False) -> int:
"""Write one batch: whatever is queued (up to BATCH_MAX), waiting up to `wait` seconds for the
first item. Returns the number of rows written, notices included. `final` closes the
repeat window early — the last batch before the process exits."""
rows: list[dict[str, Any]] = []
try:
item = self._queue.get(timeout=wait) if wait > 0 else self._queue.get_nowait()
# silent-ok: an idle sink. Nothing failed; there was simply nothing waiting.
except queue.Empty:
item = None
while item is not None:
key, row = item
if self._repeats.admit(key):
rows.append(row)
if len(rows) >= BATCH_MAX:
break
try:
item = self._queue.get_nowait()
# silent-ok: the batch ends here because the queue ran dry, which is the normal end.
except queue.Empty:
item = None
rows.extend(self._repeats.roll(time.monotonic(), final=final))
with self._dropped_lock:
dropped, self._dropped = self._dropped, 0
if dropped:
rows.append(
_notice("log.sink.dropped", f"{dropped} log events were dropped", dropped=dropped)
)
if not rows:
return 0
try:
# The connection is checked out BEFORE the guard, and every request path takes them in
# that order too (its session already holds a connection when it reaches the guard). The
# other way round, this thread would hold a process-global lock while waiting up to
# `pool_timeout` for a connection, and the requests that could return one are the ones
# blocked on the lock. The guard still spans the check, the insert and the commit.
with self._bind.connect() as conn, logs.erasure_guard(), conn.begin():
conn.execute(insert(LogEvent), without_erased(rows))
except Exception as exc: # noqa: BLE001 — the database is down or refused the batch
# silent-ok / noqa T201: the sink's OWN failure. A log call here would be handed to
# this sink, which is the thing that just failed — straight into a loop. stderr is
# the one way out that does not re-enter.
print( # noqa: T201
f"log sink: could not store {len(rows)} log events: "
f"{logs.redact_text(str(exc))[:300]}",
file=sys.stderr,
)
return 0
return len(rows)
def _run(self) -> None:
while not self._stopping.is_set():
self.write_pending(wait=FLUSH_SECONDS)
while self.write_pending(): # drain what arrived before the stop
pass
self.write_pending(final=True) # and the repeat notices of a window cut short
def start(self) -> None:
self._writer = threading.Thread(target=self._run, name="log-sink", daemon=True)
self._writer.start()
def stop(self, timeout: float = 5.0) -> None:
self._stopping.set()
if self._writer is not None:
self._writer.join(timeout)
_sink: LogSink | None = None
def start() -> None:
"""Attach the sink to the root logger and start its writer. Call after `logs.configure_logging`
(which replaces the root handlers) and once the schema exists. A no-op when `LOG_DB_SINK` is off
or it is already running."""
global _sink
if _sink is not None or not settings.log_db_sink:
return
_sink = LogSink()
# The same level gate as stdout: the root may be lowered for one user's elevated requests, and
# everyone else's detail must not reach the table through that gap either.
_sink.addFilter(logs.LevelGate())
logging.getLogger().addHandler(_sink)
_sink.start()
# A short-lived process (the download child) exits without a lifespan: flush on the way out.
atexit.register(stop)
def stop() -> None:
"""Detach the sink and write what is still queued."""
global _sink
if _sink is None:
return
logging.getLogger().removeHandler(_sink)
_sink.stop()
_sink = None
def gc(db: Session, retention_days: int) -> int:
"""Stage the deletion of rows older than `retention_days` (at least one day — stored logs always
expire). The caller commits. Returns the number of rows removed."""
cutoff = datetime.now(timezone.utc) - timedelta(days=max(1, retention_days))
result = db.execute(delete(LogEvent).where(LogEvent.ts < cutoff))
return result.rowcount or 0
def serialize(row: LogEvent) -> dict:
"""A stored row as the API sends it — one shape for every reader: the admin console (E22.6) and
a debug session's evidence list (E16)."""
return {
"id": row.id,
"ts": iso(row.ts),
"level": row.level,
"source": row.source,
"logger": row.logger,
"event": row.event,
"msg": row.msg,
"attrs": row.attrs,
"exc": row.exc,
"request_id": row.request_id,
"user_id": row.user_id,
"job_id": row.job_id,
"debug_session_id": row.debug_session_id,
"version": row.version,
}
+93 -35
View File
@@ -1,43 +1,44 @@
import asyncio import asyncio
import logging
import math import math
import sys import mimetypes
from contextlib import asynccontextmanager, suppress from contextlib import asynccontextmanager, suppress
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError 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
log = logging.getLogger("siftlode.app")
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy.exc import DataError from sqlalchemy.exc import DataError
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from app import state # Logging is configured HERE, before the app's own modules are imported, so a line one of them logs
from app.config import settings # WHILE importing already has the format (a logger is configured whenever it is created —
from app.db import SessionLocal # app/logs.py — but a line emitted before `configure_logging` goes out raw). ONE format for the api,
from app.plex import stream as plex_stream # the worker, the download child and alembic alike — this replaces the old uvicorn --log-config +
from app.plex import subtitles as plex_subtitles # the hand-rolled fallback that used to live here, and uvicorn's access log is replaced by the
from app.routes import ( # `http.request` middleware below. The imports after it are E402 on purpose, each one marked.
from app import logs
from app.logs import configure_logging, current_level, log_requests, set_level
configure_logging("api")
log = logs.get_logger(__name__)
from app import bodylimit, collation, logsink, state, sysconfig # noqa: E402 — logging is configured first
from app.config import settings # noqa: E402 — logging is configured first
from app.db import SessionLocal, engine # noqa: E402 — logging is configured first
from app.plex import stream as plex_stream # noqa: E402 — logging is configured first
from app.plex import subtitles as plex_subtitles # noqa: E402 — logging is configured first
from app.routes import ( # noqa: E402 — logging is configured first
admin, admin,
admin_logs as admin_logs_routes,
audit as audit_routes, audit as audit_routes,
auth as auth_routes, auth as auth_routes,
channels, channels,
config as config_routes, config as config_routes,
debug_sessions as debug_sessions_routes,
diagnostics as diagnostics_routes,
discover as discover_routes, discover as discover_routes,
downloads, downloads,
feed, feed,
@@ -57,22 +58,34 @@ from app.routes import (
tags, tags,
version, version,
) )
from app.scheduler import shutdown_scheduler, start_scheduler from app.scheduler import shutdown_scheduler, start_scheduler # noqa: E402 — logging is configured first
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
log.info("Siftlode starting up") log.info("Siftlode starting up")
# The stored copy of the log (log_events). The entrypoint has already migrated the schema.
logsink.start()
# First-run: if the instance isn't configured yet, mint a fresh one-time setup token and print # 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). # the wizard URL so the operator can open it (the app runs in setup mode until they finish).
with SessionLocal() as db: 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: # any DB/config failure keeps the bootstrap level
# With the traceback: this catch spans a connection, a query and a coercion at boot,
# and `%s` of the exception does not say which of them raised.
log.exception("could not read the stored log level; staying at %s", current_level())
if not state.is_configured(db): if not state.is_configured(db):
token = state.rotate_setup_token(db) token = state.rotate_setup_token(db)
url = f"{settings.app_base}/setup?token={token}" url = f"{settings.app_base}/setup?token={token}"
log.warning( log.warning(
"FIRST-RUN SETUP REQUIRED — this instance isn't configured yet.\n" "FIRST-RUN SETUP REQUIRED — this instance isn't configured yet.\n"
" Open the install wizard (internal access only):\n %s", " Open the install wizard (internal access only):\n %s",
url, url, # log-pii-ok: this instance's OWN setup URL, and `?token=` is redacted
) )
# No HLS session survives a restart (they live in this process), so anything still on disk is # No HLS session survives a restart (they live in this process), so anything still on disk is
# a crashed run's segments — sweep them or they accumulate forever. FIRE AND FORGET, not # a crashed run's segments — sweep them or they accumulate forever. FIRE AND FORGET, not
@@ -83,6 +96,9 @@ async def lifespan(app: FastAPI):
# owns, so racing it against early playback is safe. # owns, so racing it against early playback is safe.
sweep = asyncio.create_task(_sweep_hls_orphans()) sweep = asyncio.create_task(_sweep_hls_orphans())
reaper = asyncio.create_task(_reap_hls_sessions()) reaper = asyncio.create_task(_reap_hls_sessions())
# Fire and forget as well: a rebuild after an ICU upgrade takes seconds, and the title sorts serve
# meanwhile (CONCURRENTLY) in the old index's order, as they did before the restart.
heal = asyncio.create_task(_heal_collation_drift())
start_scheduler() start_scheduler()
try: try:
yield yield
@@ -92,7 +108,7 @@ async def lifespan(app: FastAPI):
# pending ("Task was destroyed but it is pending!" — noise that reads like a bug during # pending ("Task was destroyed but it is pending!" — noise that reads like a bug during
# triage). Cancelling does NOT interrupt the rmtree thread itself; this only makes our own # triage). Cancelling does NOT interrupt the rmtree thread itself; this only makes our own
# bookkeeping honest. # bookkeeping honest.
for task in (sweep, reaper): for task in (sweep, reaper, heal):
task.cancel() task.cancel()
with suppress(asyncio.CancelledError): with suppress(asyncio.CancelledError):
await task await task
@@ -100,6 +116,7 @@ async def lifespan(app: FastAPI):
# Kills a running subtitle extraction: its worker would otherwise hold interpreter exit for # Kills a running subtitle extraction: its worker would otherwise hold interpreter exit for
# the rest of the read (see plex_subtitles.shutdown). # the rest of the read (see plex_subtitles.shutdown).
plex_subtitles.shutdown() plex_subtitles.shutdown()
logsink.stop() # last: write what the shutdown itself just logged
# How often idle HLS sessions are reaped (see plex/stream.py `reap_idle`). # How often idle HLS sessions are reaped (see plex/stream.py `reap_idle`).
@@ -124,6 +141,18 @@ async def _reap_hls_sessions() -> None:
log.exception("HLS session reap failed") log.exception("HLS session reap failed")
async def _heal_collation_drift() -> None:
"""Background startup check of the ICU collation the title-sort indexes are built in (see
`collation.heal_drift`). A failure is logged, never fatal: the sorts still answer, only possibly in
the old order."""
try:
await asyncio.to_thread(collation.heal_drift, engine)
except asyncio.CancelledError:
raise
except Exception:
log.exception("ICU collation drift check failed")
async def _sweep_hls_orphans() -> None: async def _sweep_hls_orphans() -> None:
"""Background half of the startup HLS sweep (see lifespan). Owns its own errors: a failure here """Background half of the startup HLS sweep (see lifespan). Owns its own errors: a failure here
must never take the app down, and a cancelled shutdown is not one.""" must never take the app down, and a cancelled shutdown is not one."""
@@ -175,15 +204,6 @@ app.add_middleware(
https_only=settings.session_https_only, # Secure flag when served over HTTPS (prod) https_only=settings.session_https_only, # Secure flag when served over HTTPS (prod)
) )
if settings.frontend_origin:
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_origin],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Paths reachable while the instance is unconfigured (setup mode). Everything else under /api or # Paths reachable while the instance is unconfigured (setup mode). Everything else under /api or
# /auth is locked until the wizard finishes; static/SPA always loads so the wizard page can render. # /auth is locked until the wizard finishes; static/SPA always loads so the wizard page can render.
_SETUP_OPEN_PREFIXES = ("/api/setup", "/api/version") _SETUP_OPEN_PREFIXES = ("/api/setup", "/api/version")
@@ -203,6 +223,34 @@ async def setup_gate(request, call_next):
return await call_next(request) return await call_next(request)
# SEC-20 — the global request-body cap (app/bodylimit.py). FastAPI reads a declared body IN FULL
# before the first dependency, so without this the anonymous login route is a free memory/time sink.
#
# WHERE it sits is the whole design, and it is pinned by `test_body_limit.py` rather than by this
# comment, because `add_middleware` inverts the reading order (last registered = OUTERMOST) and a
# stack described in prose drifts. Registered here it lands directly INSIDE CORS and directly
# OUTSIDE the setup gate, which is the narrowest position that satisfies both constraints:
#
# * inside CORS and the security headers, so the 413 is decorated like every other response. A
# refusal the browser drops for a missing Access-Control-Allow-Origin is no better than a hang,
# and `capped_body`'s 413 one layer down IS decorated — two shapes for one fact is exactly what
# bodylimit.py exists to avoid.
# * outside the setup gate, the session and every auth dependency under the router — none of which
# run for a body that is already too large. The setup gate matters specifically: on an instance
# that is not yet configured it opens a DB session per request, which is precisely the anonymous
# pre-auth work SEC-20 exists to remove.
app.add_middleware(bodylimit.BodyLimitMiddleware)
if settings.frontend_origin:
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_origin],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.middleware("http") @app.middleware("http")
async def static_cache_headers(request, call_next): async def static_cache_headers(request, call_next):
"""Long-cache the content-hashed SPA bundles. Vite hashes their filename, so a given """Long-cache the content-hashed SPA bundles. Vite hashes their filename, so a given
@@ -243,6 +291,15 @@ async def security_headers(request, call_next):
return response 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 every layer above does — the body cap included, so a 413 is still logged
# with its request id and duration. 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(health.router)
app.include_router(auth_routes.router) app.include_router(auth_routes.router)
app.include_router(sync.router) app.include_router(sync.router)
@@ -251,6 +308,8 @@ app.include_router(feed.router)
app.include_router(discover_routes.router) app.include_router(discover_routes.router)
app.include_router(search_routes.router) app.include_router(search_routes.router)
app.include_router(me.router) app.include_router(me.router)
app.include_router(diagnostics_routes.router)
app.include_router(debug_sessions_routes.router)
app.include_router(notifications.router) app.include_router(notifications.router)
app.include_router(messages.router) app.include_router(messages.router)
app.include_router(channels.router) app.include_router(channels.router)
@@ -264,14 +323,13 @@ app.include_router(admin.router)
app.include_router(config_routes.router) app.include_router(config_routes.router)
app.include_router(scheduler_routes.router) app.include_router(scheduler_routes.router)
app.include_router(audit_routes.router) app.include_router(audit_routes.router)
app.include_router(admin_logs_routes.router)
app.include_router(quota.router) app.include_router(quota.router)
app.include_router(version.router) app.include_router(version.router)
app.include_router(setup_routes.router) app.include_router(setup_routes.router)
# Ensure modern image types resolve to the right Content-Type when FileResponse guesses from the # Ensure modern image types resolve to the right Content-Type when FileResponse guesses from the
# filename (the runtime's mimetypes db doesn't always know .webp → it'd fall back to octet-stream). # filename (the runtime's mimetypes db doesn't always know .webp → it'd fall back to octet-stream).
import mimetypes
mimetypes.add_type("image/webp", ".webp") mimetypes.add_type("image/webp", ".webp")
mimetypes.add_type("image/avif", ".avif") mimetypes.add_type("image/avif", ".avif")
+148 -10
View File
@@ -12,6 +12,8 @@ from sqlalchemy import (
Index, Index,
Integer, Integer,
JSON, JSON,
LargeBinary,
SmallInteger,
String, String,
Text, Text,
UniqueConstraint, UniqueConstraint,
@@ -23,6 +25,32 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from app.db import Base from app.db import Base
from app.utils import canonical_email from app.utils import canonical_email
# Every user-supplied display string in this schema shares one width, and it lives HERE so the
# columns below are declared with it rather than with a literal: a name a person typed goes into
# `User.display_name` (their Google profile name), `DownloadJob.display_name` and
# `DownloadJob.display_uploader`. A value wider than the column is a psycopg DataError, which
# `main.py` re-raises for everything but the NUL case — i.e. a bare 500 for a name somebody pasted.
DISPLAY_TEXT_MAX = 255
def display_text(raw: str | None) -> str | None:
"""A user-supplied display string on its way into one of those columns: stripped, truncated,
blank -> None.
One definition because there are SIX writers and they had drifted: `POST /api/downloads`,
`POST /api/downloads/edit`, `POST /api/downloads/upload`, the metadata PATCH's two fields, and
the Google sign-in callback. Only the PATCH truncated. Truncating is the right answer rather
than refusing: the name is a label on work the user has already done — a file already
transferred, an account already authenticated — and losing its tail is not worth failing the
request over.
`test_upload.py` holds both halves: that every route assigning one of these fields goes through
this helper (parsed, not grepped), and that each of those routes truncates rather than 500s. A
sweep is exactly the kind of thing that gets done five-sixths of the way, which is how this
ended up being written twice.
"""
return (raw or "").strip()[:DISPLAY_TEXT_MAX] or None
# The resume-position columns (VideoState / PlexState.position_seconds) are int4. The checkpoint # The resume-position columns (VideoState / PlexState.position_seconds) are int4. The checkpoint
# routes bound their bodies by this, so an out-of-range position is a 422 rather than a 500. # routes bound their bodies by this, so an out-of-range position is a 422 rather than a 500.
@@ -102,7 +130,17 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
# current_user rejects any cookie whose epoch is stale, so a stolen/copied cookie dies on those # 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). # events (client-side signed cookies otherwise stay valid until they expire).
session_epoch: Mapped[int] = mapped_column(Integer, default=0, server_default="0") session_epoch: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
display_name: Mapped[str | None] = mapped_column(String(255)) # 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))
# The diagnostics level this user consented to (E22 S5) and when it lapses back to the base
# level. Both NULL = base. Written only by `PUT /api/me/diagnostics`, which computes the expiry
# itself; read through `app.diagnostics.effective_level`, never directly (an expired level is
# not cleared eagerly — it simply stops counting).
diagnostics_level: Mapped[str | None] = mapped_column(String(8))
diagnostics_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
display_name: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
avatar_url: Mapped[str | None] = mapped_column(String(1024)) avatar_url: Mapped[str | None] = mapped_column(String(1024))
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user") role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
# The user's own YouTube channel id, resolved lazily (channels.list?mine=true) and # The user's own YouTube channel id, resolved lazily (channels.list?mine=true) and
@@ -230,6 +268,9 @@ class Channel(Base, TimestampMixin):
details_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) details_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
recent_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) recent_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_rss_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_rss_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# When the newest uploads were last read through the Data API because the RSS feed would not
# answer (`sync/runner.py` `run_feed_fallback`): the once-per-`rss_fallback_hours` clock.
feed_fallback_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
backfill_cursor: Mapped[str | None] = mapped_column(String(128)) backfill_cursor: Mapped[str | None] = mapped_column(String(128))
backfill_done: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") backfill_done: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
# True when this channel only entered the catalog because a live YouTube search # True when this channel only entered the catalog because a live YouTube search
@@ -287,10 +328,12 @@ class Video(Base, TimestampMixin):
__tablename__ = "videos" __tablename__ = "videos"
id: Mapped[str] = mapped_column(String(16), primary_key=True) # video id id: Mapped[str] = mapped_column(String(16), primary_key=True) # video id
# No standalone channel_id index: the composite `ix_videos_channel_pub_desc` # No standalone channel_id index: the composite `ix_videos_channel_pub_cover`
# (channel_id, published_at DESC NULLS LAST, id) from migration 0067 leads with channel_id, so it # (channel_id, published_at DESC NULLS LAST, id) from migration 0095 leads with channel_id, so it
# serves every channel_id lookup (FK cascade, sync upsert, channel-detail) AND the per-channel feed # serves every channel_id lookup (FK cascade, sync upsert, channel-detail) and the per-channel feed
# sort. Like the search_vector GIN, this composite is migration-defined, not an inline `index=True`. # sort. Its INCLUDEd columns make the channel manager's sums and the feed sidebar's counts Index Only
# Scans (no heap): a column those queries start reading belongs in it (test_channel_list_index_only).
# Like the search_vector GIN, this composite is migration-defined, not an inline `index=True`.
channel_id: Mapped[str] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE")) channel_id: Mapped[str] = mapped_column(ForeignKey("channels.id", ondelete="CASCADE"))
title: Mapped[str | None] = mapped_column(Text) # normalized for display (see app.titles) title: Mapped[str | None] = mapped_column(Text) # normalized for display (see app.titles)
original_title: Mapped[str | None] = mapped_column(Text) # raw YouTube title, preserved original_title: Mapped[str | None] = mapped_column(Text) # raw YouTube title, preserved
@@ -304,6 +347,9 @@ class Video(Base, TimestampMixin):
DateTime(timezone=True), index=True, nullable=False, server_default=func.now() DateTime(timezone=True), index=True, nullable=False, server_default=func.now()
) )
thumbnail_url: Mapped[str | None] = mapped_column(String(1024)) thumbnail_url: Mapped[str | None] = mapped_column(String(1024))
# The feed's views / duration / title sorts each ride a migration-defined index in their exact
# ORDER BY, one per direction (and per ICU collation for title): migration 0096, held to
# `feedquery.order_by` by test_feed_sort_indexes.
duration_seconds: Mapped[int | None] = mapped_column(Integer) duration_seconds: Mapped[int | None] = mapped_column(Integer)
view_count: Mapped[int | None] = mapped_column(BigInteger) view_count: Mapped[int | None] = mapped_column(BigInteger)
like_count: Mapped[int | None] = mapped_column(BigInteger) like_count: Mapped[int | None] = mapped_column(BigInteger)
@@ -539,6 +585,91 @@ class AuditLog(Base):
) )
class LogEvent(Base):
"""One stored log event (E22 S3) — the `log_events` table the diagnostics console reads. Rows are
written by app/logsink.py's background writer, never inside a request's transaction, already
redacted (the same `logs.record_fields` the stdout line is built from).
`user_id` has NO foreign key on purpose: the writer runs behind the event, so a line about an
account can arrive after that account was purged, and a constraint would make it fail the whole
batch it travels in. `auth.purge_user` deletes an account's rows explicitly instead, after
`logs.forget_user` makes every later write in this process store such a line without the account;
the retention job (`log_retention_days`) bounds whatever another process writes late."""
__tablename__ = "log_events"
__table_args__ = (
Index("ix_log_events_user_ts", "user_id", "ts"),
# E16: an issue's bundle is `WHERE debug_session_id = …`; nearly every row has none.
Index(
"ix_log_events_debug_session",
"debug_session_id",
postgresql_where="debug_session_id IS NOT NULL",
),
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
level: Mapped[str] = mapped_column(String(10))
sev: Mapped[int] = mapped_column(SmallInteger)
source: Mapped[str] = mapped_column(String(16))
logger: Mapped[str] = mapped_column(String(128))
event: Mapped[str | None] = mapped_column(String(64))
msg: Mapped[str] = mapped_column(Text)
attrs: Mapped[dict | None] = mapped_column(JSONB)
exc: Mapped[str | None] = mapped_column(Text)
request_id: Mapped[str | None] = mapped_column(String(64), index=True)
user_id: Mapped[int | None] = mapped_column(Integer)
debug_session_id: Mapped[str | None] = mapped_column(String(64))
job_id: Mapped[int | None] = mapped_column(Integer)
version: Mapped[str | None] = mapped_column(String(64))
class DebugSession(Base):
"""One recording: Start → reproduce → Stop (E16, app/debugsession.py). While it is open, every
request of its user that names it (`X-Debug-Session`) is logged at `level` and tagged with `id`,
and the issue it ends in is built from the rows carrying that tag. `until` is the hard cap the
server set at Start; an open session past it is over whether or not the sweep has closed it yet.
One OPEN session per user (`uq_debug_sessions_open_per_user`, partial on `ended_at IS NULL`)."""
__tablename__ = "debug_sessions"
__table_args__ = (
Index(
"uq_debug_sessions_open_per_user",
"user_id",
unique=True,
postgresql_where="ended_at IS NULL",
),
)
id: Mapped[str] = mapped_column(String(16), primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
level: Mapped[str] = mapped_column(String(8))
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
until: Mapped[datetime] = mapped_column(DateTime(timezone=True))
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# app/debugsession.py `EndReason`: stopped by the user, or past `until`.
ended_reason: Mapped[str | None] = mapped_column(String(16))
issue_url: Mapped[str | None] = mapped_column(String(512))
# The issue being written from it (E16 S3; app/debugsession.py `save_draft` holds its shape).
draft: Mapped[dict | None] = mapped_column(JSONB)
class DebugScreenshot(Base):
"""A screenshot attached to a debug session (E16) — pasted, dropped, or captured from the pill.
Stored in the row: bounded per session (app/debugsession.py), and gone with it by cascade."""
__tablename__ = "debug_screenshots"
id: Mapped[int] = mapped_column(primary_key=True)
session_id: Mapped[str] = mapped_column(
ForeignKey("debug_sessions.id", ondelete="CASCADE"), index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
mime: Mapped[str] = mapped_column(String(16))
size: Mapped[int] = mapped_column(Integer)
data: Mapped[bytes] = mapped_column(LargeBinary)
class AppState(Base): class AppState(Base):
"""Single-row global app state (admin-controlled).""" """Single-row global app state (admin-controlled)."""
@@ -853,11 +984,11 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
ForeignKey("download_profiles.id", ondelete="SET NULL") ForeignKey("download_profiles.id", ondelete="SET NULL")
) )
profile_snapshot: Mapped[dict] = mapped_column(JSON) profile_snapshot: Mapped[dict] = mapped_column(JSON)
display_name: Mapped[str | None] = mapped_column(String(255)) display_name: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
# Per-user display overrides + extras (like display_name — metadata only, never the file). # Per-user display overrides + extras (like display_name — metadata only, never the file).
# `display_uploader`/`display_uploader_url` override the asset's auto channel name/link; # `display_uploader`/`display_uploader_url` override the asset's auto channel name/link;
# `extra_links` is an optional list of extra reference URLs shown on the card + watch page. # `extra_links` is an optional list of extra reference URLs shown on the card + watch page.
display_uploader: Mapped[str | None] = mapped_column(String(255)) display_uploader: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
display_uploader_url: Mapped[str | None] = mapped_column(Text) display_uploader_url: Mapped[str | None] = mapped_column(Text)
extra_links: Mapped[list | None] = mapped_column(JSON) extra_links: Mapped[list | None] = mapped_column(JSON)
status: Mapped[str] = mapped_column( status: Mapped[str] = mapped_column(
@@ -1096,6 +1227,7 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
# Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched, # Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched,
# so serving falls back to the EN columns. `people_text` is language-neutral, reused for both vectors. # so serving falls back to the EN columns. `people_text` is language-neutral, reused for both vectors.
title_hu: Mapped[str | None] = mapped_column(String(512)) title_hu: Mapped[str | None] = mapped_column(String(512))
tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline (E30)
summary_hu: Mapped[str | None] = mapped_column(Text) summary_hu: Mapped[str | None] = mapped_column(Text)
genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
search_vector: Mapped[object | None] = mapped_column( search_vector: Mapped[object | None] = mapped_column(
@@ -1257,6 +1389,7 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
# Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched # Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched
# (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors. # (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors.
title_hu: Mapped[str | None] = mapped_column(String(512)) title_hu: Mapped[str | None] = mapped_column(String(512))
tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline (E30)
summary_hu: Mapped[str | None] = mapped_column(Text) summary_hu: Mapped[str | None] = mapped_column(Text)
genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
search_vector: Mapped[object | None] = mapped_column( search_vector: Mapped[object | None] = mapped_column(
@@ -1287,7 +1420,8 @@ class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
top few cast. Rows whose name no longer appears in ANY item's cast_names/directors (a library top few cast. Rows whose name no longer appears in ANY item's cast_names/directors (a library
removal orphaned them) are pruned by the maintenance sweep, mirroring how Plex drops orphaned removal orphaned them) are pruned by the maintenance sweep, mirroring how Plex drops orphaned
metadata. The filterable cast lives in plex_items/plex_shows.cast_names (self-cleaning with the metadata. The filterable cast lives in plex_items/plex_shows.cast_names (self-cleaning with the
row); this table is ONLY the headshot cache.""" row); this table is the people's own facts: the headshot link, the TMDB link and the TMDB bio
(the headshot FILE lives in the image cache, filled by the people_enrich job)."""
__tablename__ = "plex_people" __tablename__ = "plex_people"
@@ -1297,11 +1431,15 @@ class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
# TMDB person id, linked by name from a title's TMDB credits (E5 S2 lays the foundation; the /person # TMDB person id, linked by name from a title's TMDB credits (E5 S2 lays the foundation; the /person
# biography fetch + person-page render land in S6). NULL until a TMDB-enriched title names this person. # biography fetch + person-page render land in S6). NULL until a TMDB-enriched title names this person.
tmdb_person_id: Mapped[int | None] = mapped_column(Integer) tmdb_person_id: Mapped[int | None] = mapped_column(Integer)
# --- TMDB person enrichment (E5 S6): fetched from /person/{tmdb_person_id} the first time the person # --- TMDB person enrichment (E5 S6): fetched from /person/{tmdb_person_id} by the people_enrich job
# page is opened, then SERVED FROM THE ROW. `tmdb_enriched_at` is the watermark (set even on a # (E30 S6), or by the first person-page open the job hasn't reached, then SERVED FROM THE ROW. `tmdb_enriched_at` is the watermark (set even on a
# no-op fetch so it isn't retried every open). `known_for` = top combined-credits [{tmdb_id, # no-op fetch so it isn't retried every open). `known_for` = top combined-credits [{tmdb_id,
# media_type, title}], resolved to library rows by guid at serve time (like recommendations). --- # media_type, title}], resolved to library rows by guid at serve time (like recommendations). ---
biography: Mapped[str | None] = mapped_column(Text) biography: Mapped[str | None] = mapped_column(Text)
# TMDB's Hungarian biography (E30 S4), from the same request's `translations`; a Hungarian reader is
# served it, and the instance-language `biography` when TMDB has none. Birthplace, aliases and
# keywords have no Hungarian on TMDB, so they stay in the instance language.
biography_hu: Mapped[str | None] = mapped_column(Text)
birthday: Mapped[str | None] = mapped_column(String(10)) # "YYYY-MM-DD", served as-is birthday: Mapped[str | None] = mapped_column(String(10)) # "YYYY-MM-DD", served as-is
deathday: Mapped[str | None] = mapped_column(String(10)) deathday: Mapped[str | None] = mapped_column(String(10))
place_of_birth: Mapped[str | None] = mapped_column(Text) place_of_birth: Mapped[str | None] = mapped_column(Text)
+32 -14
View File
@@ -15,20 +15,35 @@ def _art_url(rating_key: str, art_key: str | None) -> str | None:
# Card hover-overview: a short synopsis for the reveal panel, trimmed so a strip/related row of cards # Card hover-overview: a short synopsis for the reveal panel, trimmed so a strip/related row of cards
# stays a few hundred bytes each rather than carrying full synopses. (These non-grid card paths serve # stays a few hundred bytes each rather than carrying full synopses.
# the EN summary/rating; the main grid coalesces the HU twin in browse.unified_library.)
def _card_summary(summary: str | None) -> str | None: def _card_summary(summary: str | None) -> str | None:
return (summary or "")[:300] or None return (summary or "")[:300] or None
def movie_card(it: PlexItem, st: PlexState | None) -> dict: def norm_lang(lang: str | None) -> str:
"""The overlay has an EN base + an optional HU twin; anything other than `hu` serves EN."""
return "hu" if lang == "hu" else "en"
# Every card speaks the reader's language (E30): `lang="hu"` serves the HU overlay twin's title and
# summary, falling back to EN where a row has none — the same rule as browse.unified_library's
# `title_col`/`summary_col`. These rails once served English to a Hungarian reader.
def display_title(row: PlexItem | PlexShow, lang: str) -> str:
return (row.title_hu or row.title) if lang == "hu" else row.title
def display_summary(row: PlexItem | PlexShow, lang: str) -> str | None:
return (row.summary_hu or row.summary) if lang == "hu" else row.summary
def movie_card(it: PlexItem, st: PlexState | None, *, lang: str) -> dict:
return { return {
"id": it.rating_key, "id": it.rating_key,
"type": "movie", "type": "movie",
"title": it.title, "title": display_title(it, lang),
"year": it.year, "year": it.year,
"rating": it.rating, "rating": it.rating,
"summary": _card_summary(it.summary), "summary": _card_summary(display_summary(it, lang)),
"duration_seconds": it.duration_s, "duration_seconds": it.duration_s,
"thumb": f"/api/plex/image/{it.rating_key}", "thumb": f"/api/plex/image/{it.rating_key}",
"art": _art_url(it.rating_key, it.art_key), "art": _art_url(it.rating_key, it.art_key),
@@ -39,14 +54,14 @@ def movie_card(it: PlexItem, st: PlexState | None) -> dict:
} }
def show_card(sh: PlexShow, status: str = "new") -> dict: def show_card(sh: PlexShow, status: str = "new", *, lang: str) -> dict:
return { return {
"id": sh.rating_key, "id": sh.rating_key,
"type": "show", "type": "show",
"title": sh.title, "title": display_title(sh, lang),
"year": sh.year, "year": sh.year,
"rating": sh.rating, "rating": sh.rating,
"summary": _card_summary(sh.summary), "summary": _card_summary(display_summary(sh, lang)),
"thumb": f"/api/plex/image/{sh.rating_key}", "thumb": f"/api/plex/image/{sh.rating_key}",
"art": _art_url(sh.rating_key, sh.art_key), "art": _art_url(sh.rating_key, sh.art_key),
"season_count": sh.child_count, "season_count": sh.child_count,
@@ -55,12 +70,12 @@ def show_card(sh: PlexShow, status: str = "new") -> dict:
} }
def episode_card(e: PlexItem, st: PlexState | None) -> dict: def episode_card(e: PlexItem, st: PlexState | None, *, lang: str) -> dict:
return { return {
"id": e.rating_key, "id": e.rating_key,
"type": "episode", "type": "episode",
"title": e.title, "title": display_title(e, lang),
"summary": e.summary, "summary": display_summary(e, lang),
"season_number": e.season_number, "season_number": e.season_number,
"episode_number": e.episode_number, "episode_number": e.episode_number,
"duration_seconds": e.duration_s, "duration_seconds": e.duration_s,
@@ -77,12 +92,15 @@ def leaf_card(
st: PlexState | None, st: PlexState | None,
show_title: str | None = None, show_title: str | None = None,
show_rk: str | None = None, show_rk: str | None = None,
*,
lang: str,
) -> dict: ) -> dict:
"""A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds. """A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds.
`show_rk` (the show's rating_key) lets the client group a playlist's episodes by show/season.""" `show_rk` (the show's rating_key) lets the client group a playlist's episodes by show/season.
`show_title` is the caller's, already in `lang` (see `display_title`)."""
if it.kind == "episode": if it.kind == "episode":
card = episode_card(it, st) card = episode_card(it, st, lang=lang)
card["show_title"] = show_title card["show_title"] = show_title
card["show_id"] = show_rk card["show_id"] = show_rk
return card return card
return movie_card(it, st) return movie_card(it, st, lang=lang)
+30 -9
View File
@@ -6,16 +6,15 @@ metadata, image proxying, and (P5) optional watch-state write-back. Requests JSO
``Accept: application/json`` header (Plex serves XML by default). ``Accept: application/json`` header (Plex serves XML by default).
""" """
import logging
from urllib.parse import quote from urllib.parse import quote
import httpx import httpx
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import sysconfig from app import logs, sysconfig
from app.config import settings from app.config import settings
log = logging.getLogger("siftlode.plex") log = logs.get_logger(__name__)
class PlexError(Exception): class PlexError(Exception):
@@ -79,9 +78,10 @@ class PlexClient:
# --- Catalog mirror (P1) --- # --- Catalog mirror (P1) ---
def section_items( def section_items(
self, key: str, item_type: int, start: int = 0, size: int = 200 self, key: str, item_type: int, start: int = 0, size: int = 200
) -> tuple[list[dict], int]: ) -> tuple[list[dict], int | None]:
"""A page of a section's top-level items. type 1 = movie, 2 = show. Returns """A page of a section's top-level items. type 1 = movie, 2 = show. Returns
(items, total_size).""" (items, total_size); the total is None when the answer carries none, so a container that
says "0" (a genuinely empty section) stays distinguishable from one that says nothing."""
mc = self._get( mc = self._get(
f"/library/sections/{key}/all", f"/library/sections/{key}/all",
params={ params={
@@ -94,7 +94,10 @@ class PlexClient:
"X-Plex-Container-Size": size, "X-Plex-Container-Size": size,
}, },
) )
return mc.get("Metadata", []) or [], int(mc.get("totalSize", mc.get("size", 0)) or 0) # `totalSize` only: `size` is THIS page's count, and read as the total it made a single
# page look like the whole section — "complete" to a prune or a reconcile (E32 review).
total = mc.get("totalSize")
return mc.get("Metadata", []) or [], None if total is None else int(total)
def children(self, rating_key: str) -> list[dict]: def children(self, rating_key: str) -> list[dict]:
"""Direct children (show → seasons, season → episodes).""" """Direct children (show → seasons, season → episodes)."""
@@ -106,7 +109,18 @@ class PlexClient:
Best-effort (needs the library's related data) — returns [] if Plex has none.""" Best-effort (needs the library's related data) — returns [] if Plex has none."""
try: try:
mc = self._get(f"/library/metadata/{rating_key}/related") mc = self._get(f"/library/metadata/{rating_key}/related")
except PlexError: except PlexError as exc:
# An empty list is also the answer for an item that genuinely has no related titles,
# so the caller cannot tell the two apart — and it deliberately declines to CACHE an
# empty one for exactly that reason. This line is what says which it was.
log.debug(
"the related items for a Plex item could not be read",
extra=logs.event(
"plex.client.related_unavailable",
rating_key=rating_key,
reason=type(exc).__name__,
),
)
return [] return []
out: list[dict] = [] out: list[dict] = []
for hub in mc.get("Hub", []) or []: for hub in mc.get("Hub", []) or []:
@@ -126,9 +140,14 @@ class PlexClient:
# --- Collection editing (P2 — write-back to Plex; all shared/global, admin-gated in the routes) --- # --- Collection editing (P2 — write-back to Plex; all shared/global, admin-gated in the routes) ---
@property @property
def machine_id(self) -> str: def machine_id(self) -> str:
"""Server machineIdentifier — needed in the `uri` of collection add/create calls. Cached.""" """Server machineIdentifier — needed in the `uri` of collection add/create calls. Cached.
Without one the uri would read `server:///…`, a malformed write Plex answers in its own way;
it is refused here, by name (E32 S2)."""
if not getattr(self, "_machine_id", None): if not getattr(self, "_machine_id", None):
self._machine_id = self._get("/identity").get("machineIdentifier") or "" mid = self._get("/identity").get("machineIdentifier")
if not mid:
raise PlexError("Plex did not report its machine identifier")
self._machine_id = mid
return self._machine_id return self._machine_id
def _write(self, method: str, path: str) -> dict: def _write(self, method: str, path: str) -> dict:
@@ -139,6 +158,8 @@ class PlexClient:
raise PlexError(str(e)) from e raise PlexError(str(e)) from e
try: try:
return (r.json() or {}).get("MediaContainer", {}) or {} if r.content else {} return (r.json() or {}).get("MediaContainer", {}) or {} if r.content else {}
# silent-ok: the HTTP call already succeeded — this is a write endpoint, and Plex answers
# those with an empty or non-JSON body as a matter of course.
except ValueError: except ValueError:
return {} return {}
+2
View File
@@ -22,6 +22,8 @@ def rich_detail(meta: dict) -> dict:
if str(r.get("image") or "").startswith("imdb://"): if str(r.get("image") or "").startswith("imdb://"):
try: try:
imdb_rating = round(float(r.get("value")), 1) imdb_rating = round(float(r.get("value")), 1)
# silent-ok: no IMDb score, which is the common and legitimate case — the UI has a
# separate audience score to fall back on and shows nothing if neither exists.
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
break break
+100
View File
@@ -0,0 +1,100 @@
"""One Hungarian word per Plex genre (E30).
Plex's Hungarian agent returns a MIXED vocabulary: some genres come back translated ("Dráma",
"Vígjáték"), others stay English ("Action", "Adventure", "Biography"), and a title with no HU twin has
only its English genres. The HU genre list therefore offered "Drama" beside "Dráma" and a dozen English
words. This map sends every genre word we have met — English (Plex EN agent, TMDB, IMDb) or Plex's
Hungarian — to the ONE word a Hungarian reader sees. The overlay sync writes it into `genres_hu`, and a
genre filter is read through it too, so a saved filter still holding "Action" keeps matching "Akció".
A word not in the map passes through unchanged: an unknown genre stays visible (in whatever language
Plex gave it) rather than disappearing.
"""
from collections.abc import Iterable
# Keys are matched case-insensitively (see `_BY_KEY`). The Hungarian values double as keys mapping to
# themselves, so running a list through twice changes nothing.
_HU: dict[str, str] = {
"Action": "Akció",
"Action & Adventure": "Akció és kaland",
"Adventure": "Kaland",
"Animation": "Animációs",
"Anime": "Anime",
"Biography": "Életrajzi",
"Children": "Gyerek",
"Comedy": "Vígjáték",
"Crime": "Bűnügyi",
"Documentary": "Dokumentum",
"Drama": "Dráma",
"Family": "Családi",
"Fantasy": "Fantasy",
"Film-Noir": "Film noir",
"Food": "Gasztronómia",
"Game Show": "Vetélkedő",
"Game-Show": "Vetélkedő",
"History": "Történelmi",
"Home and Garden": "Otthon és kert",
"Horror": "Horror",
"Indie": "Független",
"Kids": "Gyerek",
"Martial Arts": "Harcművészet",
"Mini-Series": "Minisorozat",
"Music": "Zenei",
"Musical": "Musical",
"Mystery": "Rejtély",
"News": "Hírek",
"Reality": "Valóságshow",
"Reality-TV": "Valóságshow",
"Romance": "Romantikus",
"Sci-Fi": "Sci-Fi",
"Sci-Fi & Fantasy": "Sci-Fi és fantasy",
"Science Fiction": "Sci-Fi",
"Short": "Rövidfilm",
"Soap": "Szappanopera",
"Sport": "Sport",
"Suspense": "Feszültség",
"Talk": "Talkshow",
"Talk Show": "Talkshow",
"Thriller": "Thriller",
"Travel": "Utazás",
"TV Movie": "TV film",
"War": "Háborús",
"War & Politics": "Háború és politika",
"Western": "Western",
# Plex's own Hungarian words, pinned to themselves (and to the spelling above where they differ).
"Akció": "Akció",
"Animációs": "Animációs",
"Bűnügyi": "Bűnügyi",
"Családi": "Családi",
"Dokumentum": "Dokumentum",
"Dokumentumfilm": "Dokumentum",
"Dráma": "Dráma",
"Életrajzi": "Életrajzi",
"Háborús": "Háborús",
"Kaland": "Kaland",
"Rejtély": "Rejtély",
"Romantikus": "Romantikus",
"Történelmi": "Történelmi",
"TV film": "TV film",
"Vígjáték": "Vígjáték",
"Zene": "Zenei",
"Zenei": "Zenei",
}
_BY_KEY = {k.casefold(): v for k, v in _HU.items()}
def hu_genre(word: str) -> str:
"""The Hungarian word for one genre; an unknown word comes back unchanged."""
return _BY_KEY.get(word.strip().casefold(), word)
def hu_genres(words: Iterable[str] | None) -> list[str] | None:
"""A genre list in Hungarian, in its first-seen order, each word once ("Drama" + "Dráma" → one
"Dráma"). None or empty stays None, like the listing's own `_tags`."""
out: list[str] = []
for w in words or ():
hu = hu_genre(w)
if hu not in out:
out.append(hu)
return out or None
+76 -5
View File
@@ -8,18 +8,38 @@ few hundred MB at most for a big library — acceptable, matching the download d
The cache directory (``cache.CACHE_DIR``) is shared with the subtitle cache (see ``subtitles.py``). The cache directory (``cache.CACHE_DIR``) is shared with the subtitle cache (see ``subtitles.py``).
""" """
import time
from datetime import timedelta
from fastapi import Response from fastapi import Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import logs
from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow
from app.plex.cache import CACHE_DIR from app.plex.cache import CACHE_DIR
from app.plex.lookups import by_rating_key from app.plex.lookups import by_rating_key
log = logs.get_logger(__name__)
_EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")] _EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")]
_CT_EXT = {ct: ext for ext, ct in _EXT_CT} _CT_EXT = {ct: ext for ext, ct in _EXT_CT}
# Target box per variant — Plex resizes to these server-side. Posters fill ≤176px cells (grid/info/ # Target box per variant — Plex resizes to these server-side. The full poster (400px) covers a detail
# strips), so 400px covers 2× DPR; the faint backdrop art only needs ~1280px. Keeps images ~tens of KB. # page's ≤224px poster at 2× DPR, the full art (1280px) a hero backdrop. A card is much smaller: a grid
SIZES = {"thumb": (400, 600), "art": (1280, 720)} # poster is ~144px and a shelf's landscape art ~212px, so the `_sm` pair serves those at 1.33× DPR
# (E31 S4e: cards fetched the 400 poster, 42 KB, and the 1280 art for a 212px picture). The browser
# picks between a pair itself (`srcset`, built by the frontend's `lib/plexImage` cardImage, whose
# widths plexImage.contract.test holds to these).
SIZES = {
"thumb": (400, 600),
"thumb_sm": (240, 360),
"art": (1280, 720),
"art_sm": (480, 270),
}
def picture(variant: str) -> str:
"""Which stored picture a size variant is cut from: the poster (`thumb`) or the backdrop (`art`)."""
return "art" if variant.startswith("art") else "thumb"
def key_for(db: Session, rating_key: str, variant: str) -> str | None: def key_for(db: Session, rating_key: str, variant: str) -> str | None:
@@ -46,11 +66,56 @@ def read(key: str) -> tuple[bytes, str] | None:
try: try:
if p.exists() and p.stat().st_size > 0: if p.exists() and p.stat().st_size > 0:
return p.read_bytes(), ct return p.read_bytes(), ct
# silent-ok: a miss. The caller fetches from Plex and writes the file again, and the write
# path below is the one that reports a cache that cannot be used.
except OSError: except OSError:
pass pass
return None return None
def has(key: str) -> bool:
"""A cached, non-empty file exists for `key` (a prefetch's cheap check — no read)."""
for ext, _ in _EXT_CT:
try:
if (CACHE_DIR / f"{key}.{ext}").stat().st_size > 0:
return True
# silent-ok: a miss, as in read().
except OSError:
pass
return False
def drop(key: str) -> None:
"""Remove `key`'s file, whatever its extension (a superseded variant). Best-effort."""
for ext, _ in _EXT_CT:
try:
(CACHE_DIR / f"{key}.{ext}").unlink(missing_ok=True)
# silent-ok: a leftover original only costs disk; the small twin is already written.
except OSError:
pass
def mark_miss(key: str) -> None:
"""Remember that `key`'s source could not be fetched (an empty `.miss` file; its mtime is when), so
a prefetch can leave a dead source alone for a while. Best-effort."""
try:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
(CACHE_DIR / f"{key}.miss").touch()
# silent-ok: without the mark the dead source is only asked for again on the next run.
except OSError:
pass
def missed_within(key: str, age: timedelta) -> bool:
"""`key` was marked as a miss less than `age` ago."""
try:
mtime = (CACHE_DIR / f"{key}.miss").stat().st_mtime
# silent-ok: no mark (the usual case).
except OSError:
return False
return time.time() - mtime < age.total_seconds()
def write(key: str, data: bytes, ct: str) -> None: def write(key: str, data: bytes, ct: str) -> None:
ext = _CT_EXT.get((ct or "").split(";")[0].strip(), "jpg") ext = _CT_EXT.get((ct or "").split(";")[0].strip(), "jpg")
try: try:
@@ -58,8 +123,14 @@ def write(key: str, data: bytes, ct: str) -> None:
tmp = CACHE_DIR / f"{key}.{ext}.tmp" tmp = CACHE_DIR / f"{key}.{ext}.tmp"
tmp.write_bytes(data) tmp.write_bytes(data)
tmp.replace(CACHE_DIR / f"{key}.{ext}") # atomic publish tmp.replace(CACHE_DIR / f"{key}.{ext}") # atomic publish
except OSError: except OSError as exc:
pass # cache is best-effort # Best-effort, and silently so: with an unwritable cache every poster on every grid goes
# to Plex live, for as long as nobody notices. Debug, because a grid is ~100 images and
# the point is the condition, not the count.
log.debug(
"the Plex image cache could not be written",
extra=logs.event("plex.imgcache.write_failed", reason=type(exc).__name__),
)
def response(data: bytes, ct: str) -> Response: def response(data: bytes, ct: str) -> Response:
+150
View File
@@ -0,0 +1,150 @@
"""The HU overlay: a Hungarian Plex section written onto its English twin's rows (E30), split out of
`plex/sync.py` (E32 S2), which runs it once per paired section during the full sync.
The pairing is admin-configured (`plex_overlay_map`, ``huKey:enKey,...``). An overlay section creates
no browsable rows of its own; it only fills the `*_hu` columns of the EN rows it matches by guid.
`plex/sync.py` and this module import each other as MODULES (attribute access at call time), which is
what lets either be imported first.
"""
from collections.abc import Iterable
from sqlalchemy import select
from sqlalchemy.orm import Session
from app import logs, sysconfig
from app.models import PlexItem, PlexLibrary, PlexShow
from app.plex import sync as plex_sync
from app.plex.client import PlexClient
from app.plex.genres import hu_genres
log = logs.get_logger(__name__)
def overlay_map(db: Session) -> dict[str, str]:
"""The configured HU-overlay pairing: {huSectionKey: enSectionKey} from `plex_overlay_map`
('huKey:enKey,...'). Each HU section is synced as a non-browsable overlay onto its EN twin.
Malformed pairs are skipped."""
raw = sysconfig.get_str(db, "plex_overlay_map")
out: dict[str, str] = {}
for pair in raw.split(","):
hu, sep, en = pair.partition(":")
hu, en = hu.strip(), en.strip()
if sep and hu and en:
out[hu] = en
return out
def sync_overlay(
db: Session, plex: PlexClient, lib: PlexLibrary, en_lib_id: int, stats: dict
) -> None:
"""Write an overlay section's Hungarian metadata onto its EN twin's rows, matched by guid. Creates
NO browsable rows — an overlay library is never listed on its own. Only title/summary/tagline/genres
differ by language; the language-neutral people_text feeds both search vectors. A show section's
episodes are overlaid too (E30): the HU section lists every episode with its Hungarian title and
summary, and the episode rows live in the EN show library.
This is a full RECONCILE: after a COMPLETE HU listing every guid-bearing EN row's HU fields are
cleared, then the matches are repopulated — so an EN row whose HU twin vanished (or was re-guid'd)
falls back to EN instead of keeping stale Hungarian metadata. A listing cut short only adds. HU
items with no EN match are skipped."""
if lib.kind == "movie":
passes = [
(
plex_sync._TYPE_MOVIE,
select(PlexItem).where(
PlexItem.library_id == en_lib_id,
PlexItem.kind == "movie",
PlexItem.guid.is_not(None),
),
)
]
else:
passes = [
(
plex_sync._TYPE_SHOW,
select(PlexShow).where(
PlexShow.library_id == en_lib_id, PlexShow.guid.is_not(None)
),
),
(
plex_sync._TYPE_EPISODE,
select(PlexItem).where(
PlexItem.library_id == en_lib_id,
PlexItem.kind == "episode",
PlexItem.guid.is_not(None),
),
),
]
for item_type, rows_q in passes:
_overlay_pass(db, plex, lib, item_type, db.execute(rows_q).scalars(), stats)
db.flush()
def _overlay_pass(
db: Session,
plex: PlexClient,
lib: PlexLibrary,
item_type: int,
rows: Iterable[PlexItem] | Iterable[PlexShow],
stats: dict,
) -> None:
"""One kind of the overlay (movies, shows or episodes) — see `sync_overlay`."""
# guid -> the EN rows carrying it (a list: two editions of one film can share a guid; both overlay).
by_guid: dict[str, list[PlexItem | PlexShow]] = {}
for r in rows:
if r.guid: # the passes select guid-bearing rows only
by_guid.setdefault(r.guid, []).append(r)
# The HU listing is read first and applied after, so the clear can wait for its verdict.
hu_by_guid, complete = _read_hu_listing(plex, lib, item_type, by_guid)
episodes = item_type == plex_sync._TYPE_EPISODE
if complete:
_clear_hu(by_guid, episodes)
else:
stats["overlay_incomplete"] = stats.get("overlay_incomplete", 0) + 1
log.warning(
"the HU listing ended before the server's total; existing HU text is kept",
extra=logs.event("plex.sync.overlay_incomplete", library=lib.plex_key),
)
for g, meta in hu_by_guid.items():
targets = by_guid[g]
title_hu = (meta.get("title") or "").strip() or None
summary_hu = meta.get("summary")
tagline_hu = (meta.get("tagline") or "").strip() or None
genres_hu = hu_genres(plex_sync._tags(meta, "Genre"))
for row in targets:
row.title_hu = title_hu
row.summary_hu = summary_hu
if not episodes:
row.tagline_hu = tagline_hu
row.genres_hu = genres_hu or row.genres_hu
stats["overlaid"] = stats.get("overlaid", 0) + 1
def _read_hu_listing(
plex: PlexClient, lib: PlexLibrary, item_type: int, by_guid: dict
) -> tuple[dict[str, dict], bool]:
"""The HU section's entries for the guids the EN side carries, and whether the walk reached the
server's total. The first HU entry for a guid wins; a dup HU item can't overwrite or double-count."""
progress: dict = {}
hu_by_guid: dict[str, dict] = {}
for meta in plex_sync._paginate(plex, lib.plex_key, item_type, progress):
g = plex_sync._canonical_guid(meta)
if g and g in by_guid and g not in hu_by_guid:
hu_by_guid[g] = meta
return hu_by_guid, bool(progress.get("complete"))
def _clear_hu(by_guid: dict, episodes: bool) -> None:
"""Reconcile step: clear HU fields on all guid-bearing rows; the matches are re-set after. Only
rows that actually held HU data become dirty (SQLAlchemy skips a no-net-change UPDATE), so this is
cheap. The genres are the exception: every row gets Hungarian ones, from its HU twin or, lacking
one, from its own English list — otherwise the HU genre list mixed "Drama" (no twin) with "Dráma".
Only after a COMPLETE listing: one a Plex hiccup cut short would clear the HU text of every title
it failed to reach, until the next full sync (E32 S2, the watch reconcile's twin)."""
for group in by_guid.values():
for row in group:
row.title_hu = row.summary_hu = None
if not episodes:
row.tagline_hu = None
row.genres_hu = hu_genres(row.genres)
+11 -2
View File
@@ -12,7 +12,9 @@ from pathlib import Path
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import sysconfig from app import logs, sysconfig
log = logs.get_logger(__name__)
def parse_map(raw: str) -> list[tuple[str, str]]: def parse_map(raw: str) -> list[tuple[str, str]]:
@@ -47,6 +49,13 @@ def local_media_path(db: Session, plex_path: str | None) -> Path | None:
return None return None
try: try:
p = Path(map_path(db, plex_path)).resolve() p = Path(map_path(db, plex_path)).resolve()
except OSError: except OSError as exc:
# None is answered as a 404 "no local file", which is also the answer for a title the
# server does not hold — so a dead mount or a wrong path map reads as missing media for
# the whole library at once. The path itself stays out: it carries the title.
log.warning(
"a Plex media path could not be resolved locally",
extra=logs.event("plex.paths.resolve_failed", reason=type(exc).__name__),
)
return None return None
return p if p.is_file() else None return p if p.is_file() else None
+176 -10
View File
@@ -3,13 +3,23 @@ or show (cast_names + the people_text search blob + the plex_people headshot cac
orphaned people. Used by BOTH the info-page lazy enrichment (routes.plex) and the background rich orphaned people. Used by BOTH the info-page lazy enrichment (routes.plex) and the background rich
re-sync (plex.sync), so the two paths never diverge.""" re-sync (plex.sync), so the two paths never diverge."""
from urllib.parse import quote import hashlib
import re
from datetime import timedelta
from typing import Literal
from urllib.parse import quote, unquote
import httpx
from sqlalchemy import select, text from sqlalchemy import select, text
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import logs, sysconfig
from app.models import PlexPerson from app.models import PlexPerson
from app.plex import imgcache
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
log = logs.get_logger(__name__)
# Cast/crew photos come from Plex's PUBLIC metadata CDN (no token). We proxy them (host-whitelisted) # Cast/crew photos come from Plex's PUBLIC metadata CDN (no token). We proxy them (host-whitelisted)
# so the user's browser makes no third-party request (privacy + self-host CSP friendliness). # so the user's browser makes no third-party request (privacy + self-host CSP friendliness).
@@ -19,9 +29,7 @@ PERSON_IMG_HOST = "https://metadata-static.plex.tv/"
def person_thumb_url(thumb: str | None) -> str | None: def person_thumb_url(thumb: str | None) -> str | None:
"""Proxied /person-image URL for a Plex CDN headshot, or None when it's missing / off-CDN.""" """Proxied /person-image URL for a Plex CDN headshot, or None when it's missing / off-CDN."""
t = str(thumb or "") t = str(thumb or "")
return ( return f"/api/plex/person-image?u={quote(t, safe='')}" if is_portrait_url(t) else None
f"/api/plex/person-image?u={quote(t, safe='')}" if t.startswith(PERSON_IMG_HOST) else None
)
def cast_from_meta(meta: dict, limit: int = 24) -> list[dict]: def cast_from_meta(meta: dict, limit: int = 24) -> list[dict]:
@@ -89,14 +97,23 @@ def enrich_row_people(db: Session, row, cast: list[dict]) -> bool:
return dirty return dirty
# Every credit reference: one person name per (movie/show, credit column). The ONE list of credit
# columns, so the orphan prune and the enrichment order never disagree about who is credited.
_CREDIT_COLUMNS = [
(table, col) for table in ("plex_items", "plex_shows") for col in ("cast_names", "directors")
]
def _credit_refs_sql(union: Literal["UNION", "UNION ALL"]) -> str:
return f" {union} ".join(
f"SELECT jsonb_array_elements_text({col}) FROM {table} WHERE jsonb_typeof({col}) = 'array'"
for table, col in _CREDIT_COLUMNS
)
# The person names still referenced by ANY movie/show (cast OR directors). plex_people rows outside # The person names still referenced by ANY movie/show (cast OR directors). plex_people rows outside
# this set are orphans left by a library removal. # this set are orphans left by a library removal.
_REFERENCED_PEOPLE_SQL = """ _REFERENCED_PEOPLE_SQL = _credit_refs_sql("UNION")
SELECT jsonb_array_elements_text(cast_names) FROM plex_items WHERE jsonb_typeof(cast_names) = 'array'
UNION SELECT jsonb_array_elements_text(directors) FROM plex_items WHERE jsonb_typeof(directors) = 'array'
UNION SELECT jsonb_array_elements_text(cast_names) FROM plex_shows WHERE jsonb_typeof(cast_names) = 'array'
UNION SELECT jsonb_array_elements_text(directors) FROM plex_shows WHERE jsonb_typeof(directors) = 'array'
"""
def prune_orphan_people(db: Session) -> int: def prune_orphan_people(db: Session) -> int:
@@ -113,3 +130,152 @@ def prune_orphan_people(db: Session) -> int:
) )
db.commit() db.commit()
return res.rowcount or 0 return res.rowcount or 0
# How often each person is credited across movies, shows and episodes (cast OR director) — the order the
# people_enrich job works in, so the faces met most often are local first.
_CREDIT_COUNTS_SQL = (
f"SELECT ref, count(*) AS n FROM ({_credit_refs_sql('UNION ALL')}) AS refs(ref) GROUP BY ref"
)
_NEEDS = {
# linked to TMDB, bio never fetched
"bio": "p.tmdb_person_id IS NOT NULL AND p.tmdb_enriched_at IS NULL",
# has a Plex headshot (whether it is cached is the disk's answer, not the row's)
"portrait": "p.photo_thumb IS NOT NULL",
}
def people_by_credits(
db: Session, need: Literal["bio", "portrait"], limit: int | None
) -> list[int]:
"""plex_people ids that `need` something, the most-credited first (ties by id)."""
rows = db.execute(
text(
f"SELECT p.id FROM plex_people p LEFT JOIN ({_CREDIT_COUNTS_SQL}) c ON c.ref = p.name "
f"WHERE {_NEEDS[need]} ORDER BY coalesce(c.n, 0) DESC, p.id"
+ (" LIMIT :limit" if limit is not None else "")
),
{"limit": limit} if limit is not None else {},
)
return [r[0] for r in rows]
# --- Portraits (E30 S6) ---------------------------------------------------------------------------
# A Plex CDN headshot is a full-size original (~170 KB on average, measured). Plex's photo transcoder
# fits it into this box (~13 KB), which covers the largest place a portrait is drawn (the person page's
# 6rem avatar) at 2x density and a raised text size. The box is part of the cache key, like a poster's:
# a new box re-caches instead of serving a stale size. Only a resized image is ever written under it —
# an original fetched without Plex keeps its own key, and goes once its small twin lands.
PORTRAIT_BOX = 320
# A headshot Plex answered "not found" for (a dead CDN file) is not asked for again this long, so a
# few dead faces never hold up the rest of the prefetch.
PORTRAIT_RETRY = timedelta(days=7)
# The one shape a Plex CDN headshot has (…/7/people/<hash>.jpg): plain path segments, no query, no
# fragment, no dot segment or empty segment (an HTTP client folds `a/../b` and the CDN `a//b` onto one
# file). The person-image proxy takes nothing else, so it cannot be made to cache endless spellings of
# one file (the image cache has no eviction). Used where a stored headshot is made, served and warmed.
_SEGMENT = r"[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*" # a name, dots only between its parts
_PORTRAIT_URL = re.compile(re.escape(PERSON_IMG_HOST) + _SEGMENT + rf"(?:/{_SEGMENT})*")
def is_portrait_url(url: str) -> bool:
return _PORTRAIT_URL.fullmatch(url) is not None
def _portrait_keys(url: str) -> tuple[str, str]:
"""(the resized portrait's key, the full-size original's key)."""
base = "person_" + hashlib.sha1(url.encode()).hexdigest()
return f"{base}_{PORTRAIT_BOX}", base
def portrait_cached(url: str) -> tuple[bytes, str] | None:
"""The cached portrait: the resized one, else an original kept from a time without Plex."""
small, original = _portrait_keys(url)
return imgcache.read(small) or imgcache.read(original)
def cache_portrait(plex: PlexClient | None, url: str) -> tuple[bytes, str]:
"""Fetch one CDN headshot and cache it. With a Plex client it is resized (the box key; the original
is removed); without one the original comes straight from the public CDN and is cached under its own
key, so the job still resizes it once Plex is there. Raises PlexError / httpx.HTTPError."""
small, original = _portrait_keys(url)
if plex is None:
with httpx.Client(timeout=10.0, follow_redirects=True) as c:
r = c.get(url)
r.raise_for_status()
data, ct = r.content, r.headers.get("content-type", "image/jpeg")
imgcache.write(original, data, ct)
return data, ct
data, ct = plex.image_bytes(url, PORTRAIT_BOX, PORTRAIT_BOX)
imgcache.write(small, data, ct)
imgcache.drop(original)
return data, ct
def cdn_url(photo_thumb: str) -> str | None:
"""The CDN url inside a stored proxied `/person-image?u=` value (see person_thumb_url)."""
_, _, q = photo_thumb.partition("?u=")
url = unquote(q)
return url if is_portrait_url(url) else None
# Plex failing this many headshots in a row ends a run: it is Plex that is failing, not the files.
_PLEX_DOWN_AFTER = 3
# The statuses that mean the one file is gone.
_DEAD = frozenset({404, 410})
def warm_portraits(db: Session, limit: int) -> dict:
"""Cache up to `limit` not-yet-cached headshots, the most-credited people first (E30 S6), so a
person's face comes from local disk. Needs Plex (the resize) and the Plex module on; without them
nothing is prefetched — full-size originals for every person would cost gigabytes. A headshot Plex
answers "not found" for is marked and left for PORTRAIT_RETRY; Plex failing in a row ends the run."""
if not sysconfig.get_bool(db, "plex_enabled"):
return {"cached": 0, "skipped": "disabled"}
try:
plex = PlexClient(db)
# silent-ok: no Plex server set up is a setting, not a failure; the run's result says "disabled".
except PlexNotConfigured:
return {"cached": 0, "skipped": "disabled"}
photos: dict[int, str] = {
pid: thumb
for pid, thumb in db.execute(
select(PlexPerson.id, PlexPerson.photo_thumb).where(PlexPerson.photo_thumb.is_not(None))
).tuples()
if thumb
}
# `down` counts Plex failing in a row (any answer from Plex breaks it), `plex_failed` in all
done = dead = plex_failed = down = 0
with plex:
for pid in people_by_credits(db, "portrait", None):
if done + dead >= limit or down >= _PLEX_DOWN_AFTER:
break
url = cdn_url(photos.get(pid) or "")
if url is None:
continue
key = _portrait_keys(url)[0]
if imgcache.has(key) or imgcache.missed_within(key, PORTRAIT_RETRY):
continue
try:
cache_portrait(plex, url)
done, down = done + 1, 0
except PlexError as exc:
# "Not found" is Plex's answer about this one file (a dead CDN headshot: measured 404,
# the CDN itself says 403). Anything else — no answer, a refused token, a 5xx — is
# Plex failing, which must neither mark the file nor keep the run going.
cause = exc.__cause__
if isinstance(cause, httpx.HTTPStatusError) and cause.response.status_code in _DEAD:
dead, down = dead + 1, 0 # Plex answered: it is up, the streak is broken
imgcache.mark_miss(key)
else:
plex_failed, down = plex_failed + 1, down + 1
log.warning(
"a portrait could not be cached",
extra=logs.event(
"plex.people.portrait_failed",
person_id=pid,
reason=type(exc.__cause__).__name__,
),
)
failed = dead + plex_failed
return {"cached": done, **({"failed": failed} if failed else {})}
+8 -5
View File
@@ -11,6 +11,7 @@ from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import PlexItem, PlexLibrary from app.models import PlexItem, PlexLibrary
from app.plex.genres import hu_genre
TS_CONFIG = "public.unaccent_simple" TS_CONFIG = "public.unaccent_simple"
@@ -183,7 +184,13 @@ def meta_filter_conds(model, p: dict, lang: str = "en") -> list:
gsel = split_csv(p.get("genres")) gsel = split_csv(p.get("genres"))
if gsel: if gsel:
gcol = genres_col(model, lang) gcol = genres_col(model, lang)
gc = [gcol.contains([g]) for g in gsel] if lang == "hu":
# The HU column holds one Hungarian word per genre (plex/genres.py), so a filter saved while
# it offered "Action" is read as "Akció" too. The word as picked still matches as well: a row
# the sync has not reached since keeps its English (or Plex's mixed) list until it does.
gc = [or_(*(gcol.contains([w]) for w in dict.fromkeys((hu_genre(g), g)))) for g in gsel]
else:
gc = [gcol.contains([g]) for g in gsel]
conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc)) conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc))
crs = split_csv(p.get("content_ratings")) crs = split_csv(p.get("content_ratings"))
if crs: if crs:
@@ -298,10 +305,6 @@ class LibraryFilters:
} }
def like_escape(s: str) -> str:
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
# --- Watch-state scope (shared by the /library grid AND the /facets aggregation) --------------- # --- Watch-state scope (shared by the /library grid AND the /facets aggregation) ---------------
# #
# The `show` scope (all | watched | in_progress | unwatched) filters the browse result by per-user # The `show` scope (all | watched | in_progress | unwatched) filters the browse result by per-user
+3 -1
View File
@@ -43,7 +43,9 @@ def rollup(cards: list[dict]) -> dict:
] ]
resume = None resume = None
if inprog: if inprog:
resume = inprog[-1] # continue the latest-started episode # The LAST in-progress episode in broadcast order — no timestamp is consulted. Home's
# `discover.queries.next_episode` picks the same one, so both Resume buttons agree.
resume = inprog[-1]
else: else:
resume = next((c for c in cards if c.get("status") != "watched"), None) resume = next((c for c in cards if c.get("status") != "watched"), None)
return { return {
+193 -54
View File
@@ -18,7 +18,6 @@ segments. The remux is CPU-light (video copy + a tiny audio transcode), so it ru
CPU-only prod host; only P3's full transcode is CPU-heavy. CPU-only prod host; only P3's full transcode is CPU-heavy.
""" """
import logging
import os import os
import re import re
import secrets import secrets
@@ -31,15 +30,14 @@ from pathlib import Path
from sqlalchemy.orm import Session as DbSession from sqlalchemy.orm import Session as DbSession
from app import sysconfig from app import logs, sysconfig
from app.config import settings from app.config import settings
from app.downloads.edit import encode_thread_cap from app.downloads.edit import encode_thread_cap
from app.models import PlexItem from app.models import PlexItem
from app.plex import paths from app.plex import paths
from app.plex.sync import HW_DECODABLE_CODECS from app.plex.sync import HW_DECODABLE_CODECS
log = logging.getLogger("siftlode.plex") log = logs.get_logger(__name__)
_HLS_ROOT = ( _HLS_ROOT = (
Path(settings.plex_hls_dir) Path(settings.plex_hls_dir)
if settings.plex_hls_dir if settings.plex_hls_dir
@@ -62,8 +60,10 @@ _WARMUP_SEG_TIMEOUT_S = 8.0
# its index for that one. See `segment_name` for why this is a constant and not a comment. # its index for that one. See `segment_name` for why this is a constant and not a comment.
_VIDEO_VARIANT = "0" _VIDEO_VARIANT = "0"
_SESSION_IDLE_S = 600 # reap a session with no access for this long _SESSION_IDLE_S = 600 # reap a session with no access for this long
# A session directory is named `{rating_key}_{int(start_s)}_{tag}_{aoff:+.2f}` (see start_session); # A session directory is named `{rating_key}_u{user_id}_{int(start_s)}_{tag}_{aoff:+.2f}` (see
# before multi-audio it was just `{rating_key}_{int(start_s)}`. `sweep_orphans` deletes ONLY names # start_session); before per-viewer sessions it was `{rating_key}_{int(start_s)}_{tag}_{aoff:+.2f}`,
# and before multi-audio just `{rating_key}_{int(start_s)}` — the older shapes stay sweepable, since a
# restart leaves them behind like any other. `sweep_orphans` deletes ONLY names
# matching one of those two shapes: `_HLS_ROOT` is admin-configurable (PLEX_HLS_DIR), so it may # matching one of those two shapes: `_HLS_ROOT` is admin-configurable (PLEX_HLS_DIR), so it may
# legitimately point at a shared scratch path we do not own, and a blanket "delete every directory # legitimately point at a shared scratch path we do not own, and a blanket "delete every directory
# under the root" would take the neighbours with it. # under the root" would take the neighbours with it.
@@ -74,7 +74,9 @@ _SESSION_IDLE_S = 600 # reap a session with no access for this long
# non-numeric rating_key would merely leave its directory unswept, which is the safe direction to # non-numeric rating_key would merely leave its directory unswept, which is the safe direction to
# fail. Covered by test_plex_stream.py — this regex is the only thing standing between the sweep # fail. Covered by test_plex_stream.py — this regex is the only thing standing between the sweep
# and someone else's data. # and someone else's data.
_SESSION_DIR_RE = re.compile(r"^\d+_\d+(_[^_]+_[+-]\d+\.\d{2})?$") _SESSION_DIR_RE = re.compile(
r"^\d+_\d+(_[^_]+_[+-]\d+\.\d{2})?$|^\d+_u\d+_\d+_[^_]+_[+-]\d+\.\d{2}$"
)
# When this process started. `sweep_orphans` only deletes directories older than this: everything # When this process started. `sweep_orphans` only deletes directories older than this: everything
# newer belongs to a session THIS run created, and since `start_session` reuses a directory path # newer belongs to a session THIS run created, and since `start_session` reuses a directory path
# for the same (rating_key, offset, tag), "stale name" is not the same as "stale directory". # for the same (rating_key, offset, tag), "stale name" is not the same as "stale directory".
@@ -90,7 +92,16 @@ _PROCESS_START = time.time()
_MTIME_GRACE_S = 2.0 _MTIME_GRACE_S = 2.0
_lock = threading.Lock() _lock = threading.Lock()
_sessions: dict[str, "HlsSession"] = {} # Keyed by VIEWER and item, (user_id, rating_key). Keyed by the item alone, a second account opening
# the same film `_kill`ed the first one's session: the displaced viewer got a 404, reclaimed at its
# playhead, and so evicted the other in turn — two people could not watch one film at once. (One
# account on two devices still shares a session; that case ends in the player's bounded reclaim.)
_sessions: dict[tuple[int, str], "HlsSession"] = {}
# How many live sessions one account may hold at once (two devices, or a next item starting while the
# last one winds down). A new one past it replaces that account's least recently used — whose waiting
# session request then returns at once, since `wait_for` watches the killed ffmpeg — so one account
# can neither pile up film-sized scratch copies nor park more than this many request workers.
_MAX_SESSIONS_PER_USER = 2
class HlsSession: class HlsSession:
@@ -104,16 +115,17 @@ class HlsSession:
transcode: bool = False, transcode: bool = False,
hw: bool = False, hw: bool = False,
tonemap: bool = False, tonemap: bool = False,
user_id: int = 0,
): ):
# Unguessable per-session id, carried in the URLs the client is handed. Sessions are keyed # Unguessable per-session id, carried in the URLs the client is handed. A seek-restart
# by rating_key alone, so a seek-restart or a SECOND viewer of the same item replaces the # replaces the viewer's registered session for the item — and the old player's next request
# registered one — and the old viewer's next request for `seg_12.ts`, resolved by rating_key, # for `seg_12.ts`, resolved by (viewer, rating_key) alone, was answered 200 with the NEW session's twelfth segment, produced from a different offset
# was answered 200 with the NEW session's twelfth segment, produced from a different offset
# under `-copyts`. Wrong bytes, not an error, so the client's reclaim never fired on it # under `-copyts`. Wrong bytes, not an error, so the client's reclaim never fired on it
# (deep-review round 5). A request naming a session that is no longer registered is a 404, # (deep-review round 5). A request naming a session that is no longer registered is a 404,
# which the player recovers from by restarting at the playhead. # which the player recovers from by restarting at the playhead.
self.id = secrets.token_urlsafe(9) self.id = secrets.token_urlsafe(9)
self.key = key self.key = key # the item's rating_key (what the logs name)
self.user_id = user_id # whose session it is: the registry key is (user_id, key)
self.dir = directory self.dir = directory
self.proc = proc self.proc = proc
self.transcode = transcode # video re-encoded (non-h264 source), not stream-copied self.transcode = transcode # video re-encoded (non-h264 source), not stream-copied
@@ -135,6 +147,8 @@ class HlsSession:
# single index. Derived, not passed, so it cannot disagree with the argv. # single index. Derived, not passed, so it cannot disagree with the argv.
self.entry = _MASTER_PL if self.variants else _INDEX_PL self.entry = _MASTER_PL if self.variants else _INDEX_PL
self.last_access = time.time() self.last_access = time.time()
# `artifact_may_arrive` reports a refusing session directory once, not per segment.
self.probe_failure_reported = False
def _seek_opts(start_s: float) -> list[str]: def _seek_opts(start_s: float) -> list[str]:
@@ -231,8 +245,19 @@ def _probe_video_kind(src: Path) -> str:
capture_output=True, capture_output=True,
text=True, text=True,
timeout=10, timeout=10,
# A non-zero exit is a failed probe (CalledProcessError, a SubprocessError → the branch
# below), not an answer: whatever it printed before refusing is not the file's truth.
check=True,
)
except (subprocess.SubprocessError, OSError) as exc:
# The safe kind is a real answer, but it is the answer for an 8-bit SDR source — a blind
# ffprobe sends every HDR file down it, so playback quietly degrades to a software
# transcode with nothing anywhere saying why. The path stays out of the line: it is the
# title of whatever is playing.
log.info(
"video-kind probe failed; assuming the HW-safe pipeline",
extra=logs.event("plex.stream.probe_kind_failed", reason=type(exc).__name__),
) )
except (subprocess.SubprocessError, OSError):
return _VK_HWSAFE return _VK_HWSAFE
pix_fmt = bits = transfer = "" pix_fmt = bits = transfer = ""
for line in out.stdout.splitlines(): for line in out.stdout.splitlines():
@@ -261,7 +286,14 @@ def _opencl_present() -> bool:
software libx264. Its own function so it's monkeypatchable in tests, mirroring _render_node_present.""" software libx264. Its own function so it's monkeypatchable in tests, mirroring _render_node_present."""
try: try:
return any(_OPENCL_ICD_DIR.glob("*.icd")) return any(_OPENCL_ICD_DIR.glob("*.icd"))
except OSError: except OSError as exc:
# A MISSING directory does not come through here — `glob` simply yields nothing for one.
# This is a directory we cannot read, and the answer it forces (no OpenCL) costs every HDR
# source the hardware tonemap path.
log.info(
"the OpenCL ICD directory could not be read; treating OpenCL as absent",
extra=logs.event("plex.stream.opencl_probe_failed", reason=type(exc).__name__),
)
return False return False
@@ -404,7 +436,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.""" source that has an OpenCL runtime, while a 10-bit SDR source drops to software."""
if not transcode: if not transcode:
return (False, False) 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(): if mode == "off" or not _render_node_present():
return (False, False) return (False, False)
if mode == "on": if mode == "on":
@@ -447,6 +479,8 @@ def _sw_child_preexec() -> None:
playback.""" playback."""
try: try:
os.nice(_SW_TRANSCODE_NICE) os.nice(_SW_TRANSCODE_NICE)
# silent-ok: this runs in the forked child, between fork and exec. Logging there can deadlock
# on a lock some other thread held at fork time, and the child is about to become ffmpeg.
except OSError: except OSError:
pass pass
@@ -471,9 +505,19 @@ def _probe_audio_langs(src: Path) -> list[str]:
capture_output=True, capture_output=True,
text=True, text=True,
timeout=10, timeout=10,
# A non-zero exit is a failed probe (CalledProcessError, a SubprocessError → the branch
# below), not an answer: whatever it printed before refusing is not the file's truth.
check=True,
) )
return [ln.strip() for ln in out.stdout.splitlines()] return [ln.strip() for ln in out.stdout.splitlines()]
except (subprocess.SubprocessError, OSError): except (subprocess.SubprocessError, OSError) as exc:
# An empty list IS the single-audio path, so a failed probe silently collapses a file with
# several audio tracks down to one — and the viewer gets a player with no language picker
# rather than an error.
log.warning(
"audio-track probe failed; falling back to a single-audio session",
extra=logs.event("plex.stream.probe_audio_failed", reason=type(exc).__name__),
)
return [] return []
@@ -577,13 +621,22 @@ def _probe_first_pts(seg: Path) -> float | None:
capture_output=True, capture_output=True,
text=True, text=True,
timeout=10, timeout=10,
# A non-zero exit is a failed probe (CalledProcessError, a SubprocessError → the branch
# below), not an answer: whatever it printed before refusing is not the file's truth.
check=True,
) )
for line in out.stdout.splitlines(): for line in out.stdout.splitlines():
tok = line.strip().strip(",") tok = line.strip().strip(",")
if tok: if tok:
return float(tok) return float(tok)
except (subprocess.SubprocessError, ValueError, OSError): except (subprocess.SubprocessError, ValueError, OSError) as exc:
pass # None means "keep the offset the client asked for", and the whole point of this probe is
# that the two differ: without it the subtitle track is shifted by whatever the container
# started its timestamps at. Seconds of drift, and nothing on screen calls it a failure.
log.warning(
"first-PTS probe failed; the requested offset is used as-is",
extra=logs.event("plex.stream.first_pts_probe_failed", reason=type(exc).__name__),
)
return None return None
@@ -592,29 +645,62 @@ def _kill(s: HlsSession) -> None:
s.proc.terminate() s.proc.terminate()
try: try:
s.proc.wait(timeout=3) s.proc.wait(timeout=3)
# silent-ok: the escalation IS the handling. A transcode that ignores TERM for three
# seconds gets KILL, which is exactly what this branch does.
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
s.proc.kill() s.proc.kill()
except Exception: except Exception as exc: # noqa: BLE001 — teardown finishes whatever the process does
pass # Not nothing: what survives this is an ffmpeg nobody owns any more, holding a VAAPI
# handle and a core, and the transcode cap that is supposed to bound them counts rows in
# `_sessions` rather than processes. One line is the only way that ever surfaces.
log.info(
"could not stop the transcode process",
extra=logs.event("plex.stream.kill_failed", key=s.key, reason=type(exc).__name__),
)
shutil.rmtree(s.dir, ignore_errors=True) shutil.rmtree(s.dir, ignore_errors=True)
def _enforce_cap(cap: int) -> None: def _enforce_caps(user_id: int, max_transcodes: int, max_sessions: int) -> None:
# Caller holds _lock. Only TRANSCODE sessions count toward plex_max_transcodes — remux """Caller holds _lock. Three bounds, applied after a session is registered, each dropping the
# (stream-copy) and direct-serve are cheap and deliberately uncapped (see config.py: least recently accessed sessions over it:
# plex_max_transcodes, and the user-facing config hint). Drop the least-recently-accessed
# transcode sessions over the cap, leaving any concurrent remux streams untouched. * this account's own sessions (`_MAX_SESSIONS_PER_USER`) — first, so a single account filling the
tx = [(key, s) for key, s in _sessions.items() if s.transcode] box evicts itself before anyone else;
if len(tx) <= cap: * TRANSCODE sessions (plex_max_transcodes): the CPU/GPU-heavy ones;
return * EVERY session (plex_max_sessions). A remux is cheap on the CPU but not on the disk: each one is a
for key, s in sorted(tx, key=lambda kv: kv[1].last_access)[: len(tx) - cap]: whole segmented copy of the film under `_HLS_ROOT`, written faster than realtime. Counted only
_kill(s) by the transcode cap, N distinct items were N ffmpegs and N film-sized scratch directories.
_sessions.pop(key, None)
Somebody's playback stops at each drop. The route turns the missing session into a retryable 404
and the player restarts at the playhead, so from the server nothing failed — and "my film keeps
rebuffering" has no other explanation anywhere, hence the line."""
def cull(pool: list[tuple[tuple[int, str], HlsSession]], cap: int, bound: str) -> None:
if len(pool) <= cap:
return
for reg, sess in sorted(pool, key=lambda kv: kv[1].last_access)[: len(pool) - cap]:
log.info(
"a session was dropped to stay under a cap",
extra=logs.event(
"plex.stream.session_culled",
key=sess.key,
bound=bound,
cap=cap,
sessions=len(pool),
),
)
_kill(sess)
_sessions.pop(reg, None)
cull([kv for kv in _sessions.items() if kv[0][0] == user_id], _MAX_SESSIONS_PER_USER, "user")
cull([kv for kv in _sessions.items() if kv[1].transcode], max_transcodes, "transcode")
cull(list(_sessions.items()), max_sessions, "total")
def start_session( def start_session(
db: DbSession, db: DbSession,
item: PlexItem, item: PlexItem,
user_id: int,
start_s: float, start_s: float,
audio_ord: int | None = None, audio_ord: int | None = None,
aoff: float = 0.0, aoff: float = 0.0,
@@ -634,6 +720,7 @@ def start_session(
# session respawn (the HW->SW fallback) must not pick up a mid-playback config change. # session respawn (the HW->SW fallback) must not pick up a mid-playback config change.
loudnorm = sysconfig.get_bool(db, "plex_audio_loudnorm") loudnorm = sysconfig.get_bool(db, "plex_audio_loudnorm")
key = item.rating_key key = item.rating_key
reg = (user_id, key) # the registry key: one session per viewer per item
start_s = max(0.0, float(start_s)) start_s = max(0.0, float(start_s))
audio_langs = _probe_audio_langs(src) if multi else [] audio_langs = _probe_audio_langs(src) if multi else []
is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks
@@ -646,7 +733,7 @@ def start_session(
# the tag with a DASH — `_SESSION_DIR_RE` treats the tag as one underscore-free field, and that # the tag with a DASH — `_SESSION_DIR_RE` treats the tag as one underscore-free field, and that
# regex is the only thing keeping the orphan sweep off a shared scratch path (see its comment). # regex is the only thing keeping the orphan sweep off a shared scratch path (see its comment).
tag = ("multi" if is_multi else str(audio_ord)) + ("-ln" if loudnorm else "") tag = ("multi" if is_multi else str(audio_ord)) + ("-ln" if loudnorm else "")
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}" directory = _HLS_ROOT / f"{key}_u{user_id}_{int(start_s)}_{tag}_{aoff:+.2f}"
def _seg(i: int) -> Path: def _seg(i: int) -> Path:
return directory / segment_name(i, _VIDEO_VARIANT if variants else None) return directory / segment_name(i, _VIDEO_VARIANT if variants else None)
@@ -658,13 +745,12 @@ def start_session(
# check-and-replace happens under ONE _lock, so a newer same-key session (a concurrent # check-and-replace happens under ONE _lock, so a newer same-key session (a concurrent
# seek-restart) installed between the fallback's decision and here is never clobbered. When the # seek-restart) installed between the fallback's decision and here is never clobbered. When the
# registered session is no longer `replace_only`, we abort and return None (the caller yields to # registered session is no longer `replace_only`, we abort and return None (the caller yields to
# whoever is registered now). The INITIAL spawn passes replace_only=None and always supersedes — # whoever is registered now). The INITIAL spawn never gets None — see its call site.
# that is the seek-restart's intended last-writer-wins.
# Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and # Software-fallback encode tuning — read only on the SW transcode path (the VAAPI path and
# remux ignore preset/crf/max_height, and remux must not touch the DB for them). # remux ignore preset/crf/max_height, and remux must not touch the DB for them).
preset, crf, max_height = "veryfast", 20, 0 preset, crf, max_height = "veryfast", 20, 0
if transcode and not hw: 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") crf = sysconfig.get_int(db, "plex_transcode_crf")
max_height = sysconfig.get_int(db, "plex_transcode_max_height") 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 # Nice the SOFTWARE transcode child (not the HW path, and not remux) so a CPU-bound re-encode
@@ -672,9 +758,9 @@ def start_session(
# platform without fork (the API always runs in a Linux container in prod) it's simply omitted. # platform without fork (the API always runs in a Linux container in prod) it's simply omitted.
preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None preexec = _sw_child_preexec if (transcode and not hw and os.name == "posix") else None
with _lock: with _lock:
if replace_only is not None and _sessions.get(key) is not replace_only: if replace_only is not None and _sessions.get(reg) is not replace_only:
return None # superseded/culled while we waited out of lock — don't clobber the newer one return None # superseded/culled while we waited out of lock — don't clobber the newer one
old = _sessions.pop(key, None) old = _sessions.pop(reg, None)
if old is not None: if old is not None:
_kill(old) _kill(old)
shutil.rmtree(directory, ignore_errors=True) shutil.rmtree(directory, ignore_errors=True)
@@ -708,14 +794,35 @@ def start_session(
transcode=transcode, transcode=transcode,
hw=hw, hw=hw,
tonemap=tonemap, tonemap=tonemap,
user_id=user_id,
)
_sessions[reg] = s
# The one line that says which pipeline a session actually got. Everything downstream
# — the cap, the HW→SW fallback, the subtitle offset — is about this choice, and until
# now it was only inferable from the ffmpeg command nobody keeps.
log.info(
"an HLS session started",
extra=logs.event(
"plex.stream.session_started",
key=key,
transcode=transcode,
hw=hw,
tonemap=tonemap,
variants=len(variants or ()),
start_s=round(start_s, 1),
),
)
# Admin-configurable caps (Configuration → Plex); at least 1 each so playback can't be
# capped to zero.
_enforce_caps(
user_id,
max(1, sysconfig.get_int(db, "plex_max_transcodes")),
max(1, sysconfig.get_int(db, "plex_max_sessions")),
) )
_sessions[key] = s
# Admin-configurable concurrency cap (Configuration → Plex → Max concurrent transcodes);
# at least 1 so playback can't be capped to zero.
_enforce_cap(max(1, sysconfig.get_int(db, "plex_max_transcodes")))
return s return s
s = _spawn(*_select_encode(db, item, transcode, src)) s = _spawn(*_select_encode(db, item, transcode, src))
assert s is not None # replace_only=None always supersedes: a seek-restart's last-writer-wins
# Wait for the first video segment outside the lock (this blocks on ffmpeg, and we must not hold up # Wait for the first video segment outside the lock (this blocks on ffmpeg, and we must not hold up
# other sessions). Pass the proc so a HW session that dies at VAAPI init fails fast (not after 25s). # other sessions). Pass the proc so a HW session that dies at VAAPI init fails fast (not after 25s).
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc) ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
@@ -728,7 +835,8 @@ def start_session(
# ONE lock in _spawn, so we can never tear down the newer session. s.hw is now False, so the route # ONE lock in _spawn, so we can never tear down the newer session. s.hw is now False, so the route
# honestly reports "CPU" and the player's Preparing-stream state stays correct. # honestly reports "CPU" and the player's Preparing-stream state stays correct.
log.warning( log.warning(
"plex transcode: HW session produced no segments — falling back to software key=%s", key "the hardware transcode produced no segments; falling back to software",
extra=logs.event("plex.stream.hw_fallback", key=key),
) )
respawned = _spawn(False, False, replace_only=s) respawned = _spawn(False, False, replace_only=s)
if respawned is None: if respawned is None:
@@ -737,7 +845,7 @@ def start_session(
# turns a None return into a "Media file not found on disk" 404 — dishonest for a file that # turns a None return into a "Media file not found on disk" 404 — dishonest for a file that
# exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" / # exists. A (dead) session instead degrades to a retryable 404 ("No active playback session" /
# "Not available yet") the client re-requests. # "Not available yet") the client re-requests.
return current_session(key) or s return current_session(user_id, key) or s
s = respawned s = respawned
ready = wait_for(_seg(0), timeout=25.0, proc=s.proc) ready = wait_for(_seg(0), timeout=25.0, proc=s.proc)
if ready: if ready:
@@ -768,9 +876,9 @@ def start_session(
return s return s
def current_session(key: str) -> HlsSession | None: def current_session(user_id: int, key: str) -> HlsSession | None:
with _lock: with _lock:
s = _sessions.get(key) s = _sessions.get((user_id, key))
if s is not None: if s is not None:
s.last_access = time.time() s.last_access = time.time()
return s return s
@@ -867,7 +975,23 @@ def artifact_may_arrive(s: "HlsSession", filename: str) -> bool:
return True return True
try: try:
return (s.dir / segment_name(seq - 1, variant)).exists() return (s.dir / segment_name(seq - 1, variant)).exists()
except OSError: except OSError as exc:
# `exists()` answers False for a missing file without raising, so this is the session
# directory itself refusing — a full or unmounted disk. Every segment then 404s, which the
# player shows as a stall. Once per session: this runs for every segment request and
# every hls.js retry of one, and the sink's limiter still stores 30 a minute of a
# template — stdout would get them all.
if not s.probe_failure_reported:
s.probe_failure_reported = True
log.warning(
"a session segment could not be checked",
extra=logs.event(
"plex.stream.segment_probe_failed",
key=s.key,
seq=seq,
reason=type(exc).__name__,
),
)
return False return False
@@ -884,12 +1008,16 @@ def wait_for(path: Path, timeout: float = 20.0, proc: subprocess.Popen | None =
try: try:
if path.exists() and path.stat().st_size > 0: if path.exists() and path.stat().st_size > 0:
return True return True
# silent-ok: this is a 150 ms poll — a line here is a line every 150 ms for the whole
# timeout. The final check below decides, and the 404 the caller then returns is honest.
except OSError: except OSError:
pass pass
if proc is not None and proc.poll() is not None: if proc is not None and proc.poll() is not None:
# the producer exited — no more segments are coming; one last check then give up. # the producer exited — no more segments are coming; one last check then give up.
try: try:
return path.exists() and path.stat().st_size > 0 return path.exists() and path.stat().st_size > 0
# silent-ok: one last look after the producer exited. False here becomes a 404 the
# player recovers from, which is the honest answer either way.
except OSError: except OSError:
return False return False
time.sleep(0.15) time.sleep(0.15)
@@ -922,7 +1050,16 @@ def sweep_orphans(older_than: float | None = None) -> int:
dropped = 0 dropped = 0
try: try:
entries = list(_HLS_ROOT.iterdir()) entries = list(_HLS_ROOT.iterdir())
except OSError: # the root doesn't exist yet — nothing to sweep # silent-ok: nothing has been transcoded yet, so there is nothing to sweep.
except FileNotFoundError:
return 0
except OSError as exc:
# Any OTHER reason the root will not list is not "nothing to sweep": the sweep reports 0
# like a clean run while every crashed session's segments stay on disk forever.
log.warning(
"the HLS root could not be listed; no orphans were swept",
extra=logs.event("plex.stream.sweep_root_unreadable", reason=type(exc).__name__),
)
return 0 return 0
for p in entries: for p in entries:
if not _SESSION_DIR_RE.match(p.name): if not _SESSION_DIR_RE.match(p.name):
@@ -932,7 +1069,9 @@ def sweep_orphans(older_than: float | None = None) -> int:
try: try:
if p.stat().st_mtime >= cutoff: if p.stat().st_mtime >= cutoff:
continue # this run made (or reused) it — never ours to delete continue # this run made (or reused) it — never ours to delete
except OSError: # vanished under us (a concurrent restart of that session) # silent-ok: vanished under us — a concurrent restart of that session removed it, which
# is the outcome this sweep wanted anyway.
except OSError:
continue continue
with _lock: with _lock:
if any(s.dir == p for s in _sessions.values()): if any(s.dir == p for s in _sessions.values()):
@@ -968,21 +1107,21 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
now = time.time() now = time.time()
dropped = 0 dropped = 0
with _lock: with _lock:
for key, s in list(_sessions.items()): for reg, s in list(_sessions.items()):
done = s.proc.poll() is not None done = s.proc.poll() is not None
idle = now - s.last_access idle = now - s.last_access
if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s): if idle > _SESSION_IDLE_S or (done and idle > finished_idle_s):
_kill(s) _kill(s)
_sessions.pop(key, None) _sessions.pop(reg, None)
dropped += 1 dropped += 1
return dropped return dropped
def touch_session(key: str) -> None: def touch_session(user_id: int, key: str) -> None:
"""Say a viewer is still there for `key`, without fetching anything. `last_access` used to move """Say this viewer is still there for `key`, without fetching anything. `last_access` used to move
only on an HLS file fetch, which a paused player does not do — so the only way to keep a paused only on an HLS file fetch, which a paused player does not do — so the only way to keep a paused
viewer's session alive was to keep EVERY abandoned one just as long.""" viewer's session alive was to keep EVERY abandoned one just as long."""
with _lock: with _lock:
s = _sessions.get(key) s = _sessions.get((user_id, key))
if s is not None: if s is not None:
s.last_access = time.time() s.last_access = time.time()
+265 -25
View File
@@ -32,18 +32,20 @@ window still took the full 145 s and emitted the whole track), so windows are ex
zero-based clock and shifted back to absolute time here with `shift_vtt`. zero-based clock and shifted back to absolute time here with `shift_vtt`.
""" """
import codecs
import json import json
import logging
import re import re
import subprocess import subprocess
import tempfile import tempfile
import threading import threading
import time import time
import logging
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from pathlib import Path from pathlib import Path
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import logs
from app.db import SessionLocal from app.db import SessionLocal
from app.models import PlexItem from app.models import PlexItem
from app.plex import paths as plex_paths from app.plex import paths as plex_paths
@@ -51,8 +53,7 @@ from app.plex.cache import CACHE_DIR
from app.plex.client import PlexClient, PlexError from app.plex.client import PlexClient, PlexError
from app.plex.lookups import by_rating_key from app.plex.lookups import by_rating_key
log = logging.getLogger("siftlode.plex") log = logs.get_logger(__name__)
# Image-based subtitle codecs (Blu-ray/DVD) — can't be turned into text WebVTT (would need burn-in). # Image-based subtitle codecs (Blu-ray/DVD) — can't be turned into text WebVTT (would need burn-in).
BITMAP_SUBS = { BITMAP_SUBS = {
"pgs", "pgs",
@@ -104,6 +105,7 @@ def shift_vtt(vtt: str, offset: float) -> str:
straddling 0 is clamped to start at 0.""" straddling 0 is clamped to start at 0."""
if not offset: if not offset:
return vtt return vtt
unshifted = 0
out_blocks: list[str] = [] out_blocks: list[str] = []
for block in re.split(r"\n[ \t]*\n", vtt): for block in re.split(r"\n[ \t]*\n", vtt):
if "-->" not in block: if "-->" not in block:
@@ -121,7 +123,12 @@ def shift_vtt(vtt: str, offset: float) -> str:
st = _vtt_ts_to_s(left.strip()) - offset st = _vtt_ts_to_s(left.strip()) - offset
en = _vtt_ts_to_s(rparts[0]) - offset en = _vtt_ts_to_s(rparts[0]) - offset
except (ValueError, IndexError): except (ValueError, IndexError):
break # unparseable timing → leave the block as-is # silent-ok: left as-is means left UNSHIFTED — the viewer sees that one cue at
# the wrong moment. Counted rather than logged here: this is per cue, and the file
# being parsed is one ffmpeg wrote for us a moment ago, so a broken timestamp is
# a fact about the extraction, reported once below.
unshifted += 1
break
if en <= 0: if en <= 0:
keep = False # whole cue is before the session start → drop the block keep = False # whole cue is before the session start → drop the block
else: else:
@@ -131,6 +138,11 @@ def shift_vtt(vtt: str, offset: float) -> str:
break break
if keep: if keep:
out_blocks.append("\n".join(lines)) out_blocks.append("\n".join(lines))
if unshifted:
log.info(
"some cues could not be shifted and stay at their original time",
extra=logs.event("plex.subtitles.cues_unshifted", cues=unshifted, offset_s=offset),
)
return "\n\n".join(out_blocks) return "\n\n".join(out_blocks)
@@ -150,7 +162,8 @@ def ffmpeg_to_vtt(src: str, sub_ord: int | None) -> str:
"webvtt", "webvtt",
"pipe:1", "pipe:1",
] ]
r = subprocess.run(cmd, capture_output=True, timeout=EXTRACT_TIMEOUT_S) # check=False: the exit status is read on the next line, with ffmpeg's own stderr as the reason.
r = subprocess.run(cmd, capture_output=True, timeout=EXTRACT_TIMEOUT_S, check=False)
if r.returncode != 0 or not r.stdout: if r.returncode != 0 or not r.stdout:
raise PlexError( raise PlexError(
(r.stderr or b"").decode("utf-8", "replace")[:200] or "ffmpeg subtitle convert failed" (r.stderr or b"").decode("utf-8", "replace")[:200] or "ffmpeg subtitle convert failed"
@@ -162,7 +175,15 @@ def cache_read(key: str) -> str | None:
try: try:
p = CACHE_DIR / f"{key}.vtt" p = CACHE_DIR / f"{key}.vtt"
return p.read_text(encoding="utf-8") if p.exists() else None return p.read_text(encoding="utf-8") if p.exists() else None
except OSError: except OSError as exc:
# None is the same answer as "never cached", so a cache we cannot read looks exactly like
# a cold one — and every request re-extracts instead of saying the disk is the problem.
log.warning(
"the cached subtitle could not be read",
extra=logs.event(
"plex.subtitles.cache_read_failed", key=key, reason=type(exc).__name__
),
)
return None return None
@@ -175,7 +196,16 @@ def cache_write(key: str, vtt: str) -> bool:
tmp.write_text(vtt, encoding="utf-8") tmp.write_text(vtt, encoding="utf-8")
tmp.replace(CACHE_DIR / f"{key}.vtt") # atomic publish tmp.replace(CACHE_DIR / f"{key}.vtt") # atomic publish
return True return True
except OSError: except OSError as exc:
# `publish` turns False into "the region was not claimed" and moves on, so the track just
# never appears. Which errno it was — full disk, read-only mount, permissions — is the
# whole diagnosis, and it exists only here.
log.warning(
"the subtitle cache could not be written",
extra=logs.event(
"plex.subtitles.cache_write_failed", key=key, reason=type(exc).__name__
),
)
return False return False
@@ -189,7 +219,11 @@ _FULL_END_S = 1_000_000_000.0 # stand-in for "to the end of the file" (JSON can
def cache_key(rating_key: str, sub_ord: int) -> str: def cache_key(rating_key: str, sub_ord: int) -> str:
return f"sub_{rating_key}_{sub_ord}" # Versioned whenever `decode_subtitle` changes what a track decodes to — `sub2_` when legacy code
# pages were cached as U+FFFD, `sub3_` when a stray byte, an untagged file or Serbian still went
# to the wrong code page. The cache has no eviction, so every track is read again once rather
# than a broken one being served for good; the older files are just left behind.
return f"sub3_{rating_key}_{sub_ord}"
def _vtt_path(key: str) -> Path: def _vtt_path(key: str) -> Path:
@@ -206,7 +240,17 @@ def read_meta(key: str) -> tuple[list[list[float]], bool]:
try: try:
raw = json.loads(_meta_path(key).read_text(encoding="utf-8")) raw = json.loads(_meta_path(key).read_text(encoding="utf-8"))
return [[float(a), float(b)] for a, b in raw.get("ranges", [])], bool(raw.get("complete")) return [[float(a), float(b)] for a, b in raw.get("ranges", [])], bool(raw.get("complete"))
except (OSError, ValueError, TypeError): # silent-ok: no sidecar at all is the documented older-build case the fallback exists for.
except FileNotFoundError:
return ([[0.0, _FULL_END_S]], True) if _exists(_vtt_path(key)) else ([], False)
except (OSError, ValueError, TypeError) as exc:
# A sidecar that EXISTS and will not parse is different, and the fallback is the dangerous
# answer to it: beside a cached VTT it reports the track as complete, so a partial track is
# never extended again and the viewer loses every cue past the window that was cached.
log.warning(
"the subtitle sidecar could not be read; the cached track is taken as complete",
extra=logs.event("plex.subtitles.meta_unreadable", key=key, reason=type(exc).__name__),
)
return ([[0.0, _FULL_END_S]], True) if _exists(_vtt_path(key)) else ([], False) return ([[0.0, _FULL_END_S]], True) if _exists(_vtt_path(key)) else ([], False)
@@ -216,6 +260,8 @@ def _exists(p: Path) -> bool:
escaped as a 500 from the status route.""" escaped as a 500 from the status route."""
try: try:
return p.exists() return p.exists()
# silent-ok: this function's whole job is to answer False for a path the OS refuses. The
# refusal IS the return value; a caller that needs to report one reports it itself.
except (OSError, ValueError): except (OSError, ValueError):
return False return False
@@ -226,8 +272,18 @@ def _write_meta(key: str, ranges: list[list[float]], complete: bool) -> None:
tmp = CACHE_DIR / f"{key}.json.tmp" tmp = CACHE_DIR / f"{key}.json.tmp"
tmp.write_text(json.dumps({"ranges": ranges, "complete": complete}), encoding="utf-8") tmp.write_text(json.dumps({"ranges": ranges, "complete": complete}), encoding="utf-8")
tmp.replace(_meta_path(key)) tmp.replace(_meta_path(key))
except OSError: except OSError as exc:
pass # Without the sidecar beside it, a cached window reads back as a COMPLETE track (see
# `read_meta`) — so one failed write here permanently costs the rest of that subtitle.
log.warning(
"the subtitle sidecar could not be written",
extra=logs.event(
"plex.subtitles.meta_write_failed",
key=key,
complete=complete,
reason=type(exc).__name__,
),
)
def generation(key: str) -> int: def generation(key: str) -> int:
@@ -235,7 +291,9 @@ def generation(key: str) -> int:
window is superseded by a wider one the browser refetches instead of keeping the short VTT.""" window is superseded by a wider one the browser refetches instead of keeping the short VTT."""
try: try:
return _vtt_path(key).stat().st_mtime_ns return _vtt_path(key).stat().st_mtime_ns
except (OSError, ValueError): # ValueError: a NUL in the key — `stat` refuses the path outright # silent-ok: 0 for a track that is not cached yet, which is the ordinary case on every first
# request. (ValueError: a NUL in the key — `stat` refuses the path outright.)
except (OSError, ValueError):
return 0 return 0
@@ -267,16 +325,27 @@ def merge_vtt(*sources: str) -> str:
happen to share a time (two speakers, a sign over dialogue) are both kept.""" happen to share a time (two speakers, a sign over dialogue) are both kept."""
seen: set[str] = set() seen: set[str] = set()
cues: list[tuple[float, str]] = [] cues: list[tuple[float, str]] = []
dropped = 0
for src in sources: for src in sources:
for block in re.split(r"\n[ \t]*\n", src or ""): for block in re.split(r"\n[ \t]*\n", src or ""):
canon = _canonical_cue(block.strip()) stripped = block.strip()
canon = _canonical_cue(stripped)
if canon is None: if canon is None:
# A block WITH a timing line that still would not parse is a cue we are throwing
# away, not a header — and the merged track would simply be missing those lines.
if "-->" in stripped:
dropped += 1
continue # header / NOTE / STYLE — the merged file gets its own single header continue # header / NOTE / STYLE — the merged file gets its own single header
start, key, text = canon start, key, text = canon
if key in seen: if key in seen:
continue continue
seen.add(key) seen.add(key)
cues.append((start, text)) cues.append((start, text))
if dropped:
log.info(
"some cues were dropped from the merged subtitle: their timing would not parse",
extra=logs.event("plex.subtitles.cues_dropped", cues=dropped, kept=len(cues)),
)
cues.sort(key=lambda c: c[0]) cues.sort(key=lambda c: c[0])
return "WEBVTT\n\n" + "\n\n".join(b for _, b in cues) + "\n" return "WEBVTT\n\n" + "\n\n".join(b for _, b in cues) + "\n"
@@ -295,6 +364,8 @@ def _canonical_cue(block: str) -> tuple[float, str, str] | None:
try: try:
st = _vtt_ts_to_s(left.strip()) st = _vtt_ts_to_s(left.strip())
en = _vtt_ts_to_s(rparts[0]) en = _vtt_ts_to_s(rparts[0])
# silent-ok: None here means "not a cue", and the caller tells a header apart from a
# broken timing line by whether the block had a `-->` at all — see `merge_vtt`.
except (ValueError, IndexError): except (ValueError, IndexError):
return None return None
lines[i] = f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}" lines[i] = f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}"
@@ -310,6 +381,8 @@ def _last_cue_end(vtt: str) -> float:
continue continue
try: try:
end = max(end, _vtt_ts_to_s(ln.partition("-->")[2].strip().split(None, 1)[0])) end = max(end, _vtt_ts_to_s(ln.partition("-->")[2].strip().split(None, 1)[0]))
# silent-ok: this is a floor — "how far the pass provably got". Skipping a line it cannot
# read makes the answer smaller, never larger, so the error is always on the safe side.
except (ValueError, IndexError): except (ValueError, IndexError):
continue continue
return end return end
@@ -394,11 +467,18 @@ _FULL_POOL = ThreadPoolExecutor(max_workers=2, thread_name_prefix="plex-subs-ful
# which time something has finished. # which time something has finished.
_MAX_WINDOWS_INFLIGHT = 8 _MAX_WINDOWS_INFLIGHT = 8
_MAX_FULL_INFLIGHT = 6 _MAX_FULL_INFLIGHT = 6
# …of which one account may hold this many. The full-pass slots are minutes each, and a hostile (or
# merely curious) account opening subtitles on film after film could otherwise hold all of them while
# everyone else's tracks never completed. A viewer over their share still gets windows — the part
# that makes a track watchable now — just not another whole-file read until one of theirs ends.
_MAX_FULL_PER_USER = 3
_MAX_TRACK_WINDOWS = 2 _MAX_TRACK_WINDOWS = 2
_LOCK = threading.Lock() _LOCK = threading.Lock()
# Queued or running jobs → the absolute span a window job reads (None for a full pass). # Queued or running jobs → the absolute span a window job reads (None for a full pass).
_INFLIGHT: dict[tuple, tuple[float, float] | None] = {} _INFLIGHT: dict[tuple, tuple[float, float] | None] = {}
# rating_key → the account whose poll queued that item's full pass (see _MAX_FULL_PER_USER).
_FULL_OWNERS: dict[str, int] = {}
# Track-level failures: a track that cannot be served at all (no item, a bitmap track, Plex down…). # Track-level failures: a track that cannot be served at all (no item, a bitmap track, Plex down…).
_FAILURES: dict[str, tuple[str, float]] = {} _FAILURES: dict[str, tuple[str, float]] = {}
# rating_key → when its full pass last failed (see FULL_RETRY_S). # rating_key → when its full pass last failed (see FULL_RETRY_S).
@@ -448,6 +528,7 @@ def reset_state() -> None:
global _STOPPING global _STOPPING
with _LOCK: with _LOCK:
_INFLIGHT.clear() _INFLIGHT.clear()
_FULL_OWNERS.clear()
_FAILURES.clear() _FAILURES.clear()
_FULL_FAILED.clear() _FULL_FAILED.clear()
_WIN_FAILED.clear() _WIN_FAILED.clear()
@@ -531,7 +612,7 @@ def _window_failed(key: str, at: float) -> bool:
return any(a <= at <= b for a, b, _ in _live_window_failures(key)) return any(a <= at <= b for a, b, _ in _live_window_failures(key))
def ensure(rating_key: str, sub_ord: int, at: float = 0.0) -> dict: def ensure(rating_key: str, sub_ord: int, at: float = 0.0, viewer: int = 0) -> dict:
"""State of one subtitle track, scheduling whatever extraction is still missing. NEVER blocks. """State of one subtitle track, scheduling whatever extraction is still missing. NEVER blocks.
``ready`` = fully extracted · ``partial`` = usable, covers ``at``, still growing · ``preparing`` ``ready`` = fully extracted · ``partial`` = usable, covers ``at``, still growing · ``preparing``
@@ -574,8 +655,23 @@ def ensure(rating_key: str, sub_ord: int, at: float = 0.0) -> dict:
if edge - at <= LOOKAHEAD_S and edge < _FULL_END_S: if edge - at <= LOOKAHEAD_S and edge < _FULL_END_S:
probe = edge + 1.0 probe = edge + 1.0
want_window = not _window_failed(key, probe) want_window = not _window_failed(key, probe)
_schedule(rating_key, sub_ord, probe, want_window=want_window, want_full=not full_failed) _schedule(
return {"state": "partial" if covered else "preparing", "code": None, "gen": gen} rating_key,
sub_ord,
probe,
want_window=want_window,
want_full=not full_failed,
viewer=viewer,
)
# `growing`: whether the track will fill in on its own. False once the full pass has failed and
# is backed off — a partial track then stays partial until a window is asked for, so the player
# may poll slowly (only to read ahead of an edge) instead of every two seconds for the whole film.
return {
"state": "partial" if covered else "preparing",
"code": None,
"gen": gen,
"growing": not full_failed,
}
def cached_track(rating_key: str, sub_ord: int) -> tuple[str | None, str | None]: def cached_track(rating_key: str, sub_ord: int) -> tuple[str | None, str | None]:
@@ -592,7 +688,12 @@ def cached_track(rating_key: str, sub_ord: int) -> tuple[str | None, str | None]
def _schedule( def _schedule(
rating_key: str, sub_ord: int, at: float, want_window: bool, want_full: bool = True rating_key: str,
sub_ord: int,
at: float,
want_window: bool,
want_full: bool = True,
viewer: int = 0,
) -> None: ) -> None:
"""Kick off the jobs this track still needs: the window that makes it watchable now, and the one """Kick off the jobs this track still needs: the window that makes it watchable now, and the one
full pass that makes it complete. A 1 s status poll (or a second viewer) joins a job already full pass that makes it complete. A 1 s status poll (or a second viewer) joins a job already
@@ -625,8 +726,15 @@ def _schedule(
ident = ("win", rating_key, sub_ord, w_start) ident = ("win", rating_key, sub_ord, w_start)
_INFLIGHT[ident] = (w_start, w_start + WINDOW_PRE_S + WINDOW_LEN_S) _INFLIGHT[ident] = (w_start, w_start + WINDOW_PRE_S + WINDOW_LEN_S)
jobs.append((ident, w_start)) jobs.append((ident, w_start))
if want_full and n_full < _MAX_FULL_INFLIGHT and ("full", rating_key) not in _INFLIGHT: mine = sum(1 for owner in _FULL_OWNERS.values() if owner == viewer)
if (
want_full
and n_full < _MAX_FULL_INFLIGHT
and ("full", rating_key) not in _INFLIGHT
and mine < _MAX_FULL_PER_USER
):
_INFLIGHT[("full", rating_key)] = None _INFLIGHT[("full", rating_key)] = None
_FULL_OWNERS[rating_key] = viewer
jobs.append((("full", rating_key), None)) jobs.append((("full", rating_key), None))
for ident, window_start in jobs: for ident, window_start in jobs:
pool = _FULL_POOL if window_start is None else _WIN_POOL pool = _FULL_POOL if window_start is None else _WIN_POOL
@@ -652,12 +760,35 @@ def _run(
return return
_extract_and_publish(rating_key, sub_ord, window_start) _extract_and_publish(rating_key, sub_ord, window_start)
except SubtitleUnavailable as e: except SubtitleUnavailable as e:
# The player's own state was the only place this ever showed. `_pass_failed` backs the item
# off for an hour and `_fail` records a code, but neither leaves a line, so "my subtitles
# never appear" had nothing on the server to read.
# A final code is a fact about the file (no such track, a bitmap subtitle); anything else
# is this instance failing at something it could do.
log.log(
logging.INFO if e.code in FINAL_CODES else logging.WARNING,
"subtitle extraction gave up",
extra=logs.event(
"plex.subtitles.pass_failed",
rating_key=rating_key,
sub_ord=sub_ord,
code=e.code,
windowed=window_start is not None,
),
)
if e.code == "extract_failed": if e.code == "extract_failed":
_pass_failed(rating_key, [key], window_start) _pass_failed(rating_key, [key], window_start)
else: else:
_fail(key, e.code) _fail(key, e.code)
except PlexError as e: except PlexError as e:
log.warning("subtitle extraction failed for %s/%s: %s", rating_key, sub_ord, e) # No traceback on purpose: a PlexError carries httpx's own message, which names the method,
# the URL and the status — the stack under it is this `except` and the client call, and
# says nothing the message does not. (The URL holds no token; it travels as a header.)
log.warning(
"subtitle extraction failed talking to Plex: %s",
e,
extra=logs.event("plex.subtitles.plex_error", rating_key=rating_key, sub_ord=sub_ord),
)
_fail(key, "plex_error") _fail(key, "plex_error")
except ( except (
Exception Exception
@@ -667,6 +798,8 @@ def _run(
finally: finally:
with _LOCK: with _LOCK:
_INFLIGHT.pop(ident, None) _INFLIGHT.pop(ident, None)
if ident[0] == "full":
_FULL_OWNERS.pop(rating_key, None)
def _pass_failed( def _pass_failed(
@@ -732,10 +865,13 @@ def _extract_and_publish(rating_key: str, sub_ord: int, window_start: float | No
for i, s in enumerate(subs) for i, s in enumerate(subs)
if s.get("index") is not None and not _is_bitmap(s) if s.get("index") is not None and not _is_bitmap(s)
} }
start = window_start or 0.0 # A full pass starts where every track's leading coverage ends: a pass that ran out of time
# (`EXTRACT_TIMEOUT_S`) kept what it reached, and restarting from 0 each `FULL_RETRY_S` meant a file
# too big to read in one go never completed. Windows handle an offset start already; so does this.
start = window_start if window_start is not None else _full_pass_start(rating_key, targets)
length = (WINDOW_LEN_S + WINDOW_PRE_S) if window_start is not None else None length = (WINDOW_LEN_S + WINDOW_PRE_S) if window_start is not None else None
end = _FULL_END_S if length is None else start + length end = _FULL_END_S if length is None else start + length
texts, clean = _ffmpeg_extract(str(src), sorted(targets), window_start, length) texts, clean = _ffmpeg_extract(str(src), sorted(targets), start or None, length)
for emb_ord, vtt in texts.items(): for emb_ord, vtt in texts.items():
key = cache_key(rating_key, targets[emb_ord]) key = cache_key(rating_key, targets[emb_ord])
# A pass that exited non-zero holds only what ffmpeg reached before it died. Claim no more # A pass that exited non-zero holds only what ffmpeg reached before it died. Claim no more
@@ -756,15 +892,108 @@ def _extract_and_publish(rating_key: str, sub_ord: int, window_start: float | No
_pass_failed(rating_key, [key], window_start, (covered_end, end)) _pass_failed(rating_key, [key], window_start, (covered_end, end))
def _full_pass_start(rating_key: str, targets: dict[int, int]) -> float:
"""Where the full pass may begin: the end of the span from 0 that EVERY target track already has
(one read feeds them all, so the least-covered track decides), backed off by `WINDOW_PRE_S` so a
cue running across the join is read whole — `merge_vtt` folds the overlap. 0 when any track has
nothing from the start."""
ends = []
for ord_ in targets.values():
key = cache_key(rating_key, ord_)
ranges, _ = read_meta(key) if _exists(_vtt_path(key)) else ([], False)
lead = [b for a, b in ranges if a <= 0.0]
if not lead:
return 0.0
ends.append(max(lead))
if not ends:
return 0.0
return max(0.0, float(f"{min(ends) - WINDOW_PRE_S:.3f}"))
# The legacy code page a subtitle in this language (Plex's ISO 639-2 `languageCode`) was most likely
# saved in. Serbian sits here, not with Cyrillic: its releases are overwhelmingly Latin script in
# Windows-1250, like Croatian and Bosnian.
_CP1250 = {
"hun",
"ces",
"cze",
"pol",
"slk",
"slo",
"slv",
"hrv",
"bos",
"srp",
"ron",
"rum",
"sqi",
"alb",
}
_CP1251 = {"rus", "ukr", "bel", "bul", "mkd", "mac"}
# Western European languages keep Windows-1252 (cp1250 has no à/è/ñ). An UNTAGGED file falls to
# Windows-1250: its ASCII and á/é/í/ó/ö/ú/ü match Windows-1252 byte for byte, and it also gets ő/ű
# right, which Windows-1252 turns into õ/û — the library this reads is mostly Hungarian.
_CP1252 = {
"eng",
"fre",
"fra",
"ger",
"deu",
"spa",
"ita",
"por",
"dut",
"nld",
"swe",
"dan",
"nor",
"fin",
"ice",
"isl",
"cat",
}
# A file that is almost all valid UTF-8 is UTF-8 with a few stray bytes (a pasted Windows-1252 quote),
# not a legacy file: accents in a legacy code page are nearly never valid UTF-8 sequences, so a real
# legacy file has hardly any. At least this many valid multi-byte characters per invalid byte.
_UTF8_VALID_PER_STRAY = 10
def decode_subtitle(raw: bytes, language_code: str | None) -> str:
"""A sidecar subtitle file as text. SubRip carries no charset, and a Hungarian release is
typically Windows-1250: decoded as UTF-8 with `replace`, every ő/ű/é came out as U+FFFD (found
live, 1391 of them in one episode). So: a BOM decides; else valid UTF-8 stays UTF-8 (a legacy
code page with accents is almost never valid UTF-8), and so does NEARLY valid UTF-8 — one stray
byte must not send a whole UTF-8 file to a legacy code page; else the language's legacy code page."""
if raw.startswith(codecs.BOM_UTF8):
return raw[len(codecs.BOM_UTF8) :].decode("utf-8", "replace")
if raw.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
return raw.decode("utf-16", "replace")
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
# silent-ok: not strictly UTF-8 — nearly-UTF-8 or a legacy code page, told apart below.
pass
lenient = raw.decode("utf-8", "replace")
stray = lenient.count("�")
valid = sum(1 for ch in lenient if ch > "" and ch != "�")
if valid >= stray * _UTF8_VALID_PER_STRAY:
return lenient
lang = (language_code or "").lower()
legacy = "cp1251" if lang in _CP1251 else "cp1252" if lang in _CP1252 else "cp1250"
return raw.decode(legacy, "replace")
def _sidecar_vtt(db: Session, stream: dict) -> str: def _sidecar_vtt(db: Session, stream: dict) -> str:
"""An external subtitle file, fetched through Plex and converted to WebVTT.""" """An external subtitle file, fetched through Plex and converted to WebVTT. Its bytes are made
UTF-8 first (`decode_subtitle`), on the ffmpeg path too: ffmpeg reads a text subtitle as UTF-8."""
with PlexClient(db) as plex: with PlexClient(db) as plex:
raw = plex.raw_get(stream["key"]) raw = plex.raw_get(stream["key"])
text = decode_subtitle(raw, stream.get("languageCode"))
codec = (stream.get("codec") or stream.get("format") or "").lower() codec = (stream.get("codec") or stream.get("format") or "").lower()
if codec in ("srt", "subrip", ""): if codec in ("srt", "subrip", ""):
return srt_to_vtt(raw.decode("utf-8", "replace")) return srt_to_vtt(text)
with tempfile.NamedTemporaryFile(suffix="." + (codec or "sub"), delete=False) as fh: with tempfile.NamedTemporaryFile(suffix="." + (codec or "sub"), delete=False) as fh:
fh.write(raw) fh.write(text.encode("utf-8"))
tmp = fh.name tmp = fh.name
try: try:
return ffmpeg_to_vtt(tmp, None) return ffmpeg_to_vtt(tmp, None)
@@ -794,9 +1023,10 @@ def _ffmpeg_extract(
cmd += ["-map", f"0:s:{emb_ord}"] cmd += ["-map", f"0:s:{emb_ord}"]
if length is not None: if length is not None:
cmd += ["-t", f"{length:.3f}"] cmd += ["-t", f"{length:.3f}"]
if start:
# The input seek lands on the keyframe at or BEFORE `start`, so a cue already running # The input seek lands on the keyframe at or BEFORE `start`, so a cue already running
# at `start` comes out at a negative time — and by default the muxer then shifts the # at `start` comes out at a negative time — and by default the muxer then shifts the
# WHOLE output so that cue sits at 0: every cue of the window late by the distance to # WHOLE output so that cue sits at 0: every cue of the read late by the distance to
# that keyframe (1–5 s measured on a real library file, a whole GOP on a synthetic # that keyframe (1–5 s measured on a real library file, a whole GOP on a synthetic
# one), and doubled for good once the full pass merged in beside it. Kept negative, the # one), and doubled for good once the full pass merged in beside it. Kept negative, the
# times are exact on the zero-based clock (`_vtt_ts_to_s` reads the sign). # times are exact on the zero-based clock (`_vtt_ts_to_s` reads the sign).
@@ -829,6 +1059,16 @@ def _ffmpeg_extract(
try: try:
_, err = proc.communicate(timeout=_TERM_GRACE_S) _, err = proc.communicate(timeout=_TERM_GRACE_S)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
# TERM is what makes a timed-out pass worth keeping: ffmpeg closes its outputs
# on it. An ffmpeg that ignores TERM leaves only whole blocks already flushed,
# so the partial track is short for a reason the later line cannot tell apart.
log.info(
"the subtitle extraction ignored TERM and was killed",
extra=logs.event(
"plex.subtitles.extract_killed_after_term",
windowed=start is not None,
),
)
proc.kill() proc.kill()
_, err = proc.communicate() _, err = proc.communicate()
finally: finally:
+84 -92
View File
@@ -15,20 +15,19 @@ dead `enabled` column) is a separate follow-up. Cast + intro/credit markers are
item-detail/player path (P2), not here. item-detail/player path (P2), not here.
""" """
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import sysconfig from app import logs, sysconfig
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow
from app.plex import detail, people from app.plex import detail, overlay, people
from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.genres import hu_genres
from app.plex.lookups import by_rating_key from app.plex.lookups import by_rating_key
log = logging.getLogger("siftlode.plex") log = logs.get_logger(__name__)
# Codecs/containers a browser can play in a native <video> without any transcoding. # Codecs/containers a browser can play in a native <video> without any transcoding.
_BROWSER_VIDEO = {"h264"} _BROWSER_VIDEO = {"h264"}
_BROWSER_AUDIO = {"aac", "mp3", "opus", "vorbis"} _BROWSER_AUDIO = {"aac", "mp3", "opus", "vorbis"}
@@ -102,6 +101,9 @@ def _video_stream_props(meta: dict) -> tuple[int | None, bool]:
bd = s.get("bitDepth") bd = s.get("bitDepth")
try: try:
depth = int(bd) if bd else None depth = int(bd) if bd else None
# silent-ok: unknown bit depth, which is also what most listings report. The
# playback class then falls back to the codec alone, and per-stream noise over a
# whole library would drown the sync's own lines.
except (TypeError, ValueError): except (TypeError, ValueError):
depth = None depth = None
hdr = str(s.get("colorTrc") or "").lower() in _HDR_TRANSFERS hdr = str(s.get("colorTrc") or "").lower() in _HDR_TRANSFERS
@@ -112,6 +114,8 @@ def _video_stream_props(meta: dict) -> tuple[int | None, bool]:
def _epoch(v) -> datetime | None: def _epoch(v) -> datetime | None:
try: try:
return datetime.fromtimestamp(int(v), tz=timezone.utc) if v else None return datetime.fromtimestamp(int(v), tz=timezone.utc) if v else None
# silent-ok: a field Plex leaves out of most listing rows. None is a legitimate stored value,
# and every caller treats it as "not known" rather than as an error.
except (TypeError, ValueError, OSError): except (TypeError, ValueError, OSError):
return None return None
@@ -120,6 +124,7 @@ def _date(v):
"""Parse a Plex 'YYYY-MM-DD' string (originallyAvailableAt) → date, or None.""" """Parse a Plex 'YYYY-MM-DD' string (originallyAvailableAt) → date, or None."""
try: try:
return datetime.strptime(v, "%Y-%m-%d").date() if v else None return datetime.strptime(v, "%Y-%m-%d").date() if v else None
# silent-ok: as `_epoch` above — an absent or unparsable air date is stored as no air date.
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
@@ -135,6 +140,7 @@ def _tags(meta: dict, key: str, limit: int | None = None) -> list | None:
def _rating(meta: dict) -> float | None: def _rating(meta: dict) -> float | None:
try: try:
return float(meta.get("audienceRating") or meta.get("rating")) return float(meta.get("audienceRating") or meta.get("rating"))
# silent-ok: TypeError on `float(None)` is the ordinary path — most items carry no rating.
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
@@ -187,20 +193,6 @@ def _enabled_section_keys(db: Session) -> set[str] | None:
return keys or None return keys or None
def _overlay_map(db: Session) -> dict[str, str]:
"""The configured HU-overlay pairing: {huSectionKey: enSectionKey} from `plex_overlay_map`
('huKey:enKey,...'). Each HU section is synced as a non-browsable overlay onto its EN twin.
Malformed pairs are skipped."""
raw = sysconfig.get_str(db, "plex_overlay_map")
out: dict[str, str] = {}
for pair in raw.split(","):
hu, sep, en = pair.partition(":")
hu, en = hu.strip(), en.strip()
if sep and hu and en:
out[hu] = en
return out
def sync(db: Session) -> dict: def sync(db: Session) -> dict:
"""Full reconcile of the enabled movie/show sections. Returns a small stats dict.""" """Full reconcile of the enabled movie/show sections. Returns a small stats dict."""
if not sysconfig.get_bool(db, "plex_enabled"): if not sysconfig.get_bool(db, "plex_enabled"):
@@ -216,7 +208,7 @@ def sync(db: Session) -> dict:
"pruned": 0, "pruned": 0,
} }
wanted = _enabled_section_keys(db) wanted = _enabled_section_keys(db)
overlay_map = _overlay_map(db) # {huKey: enKey} overlay_map = overlay.overlay_map(db) # {huKey: enKey}
overlay_keys = set(overlay_map) overlay_keys = set(overlay_map)
try: try:
with PlexClient(db) as plex: with PlexClient(db) as plex:
@@ -260,21 +252,25 @@ def sync(db: Session) -> dict:
lib.metadata_lang = "hu" lib.metadata_lang = "hu"
lib.overlay_of = en_lib.id lib.overlay_of = en_lib.id
db.flush() db.flush()
_sync_overlay(db, plex, lib, en_lib.id, stats) overlay.sync_overlay(db, plex, lib, en_lib.id, stats)
db.commit() db.commit()
# Rich-fetch a bounded batch of not-yet-enriched titles' FULL cast, so the whole library # Rich-fetch a bounded batch of not-yet-enriched titles' FULL cast, so the whole library
# becomes filterable over successive runs (the cheap listing above only stored the top ~3). # becomes filterable over successive runs (the cheap listing above only stored the top ~3).
try: try:
stats["people_enriched"] = _enrich_people_batch(db, plex) stats["people_enriched"] = _enrich_people_batch(db, plex)
except Exception as e: # best-effort — never fail the sync over the backfill except Exception: # best-effort; never fail the sync over a backfill
log.warning("Plex people enrich batch failed: %s", e) # With the traceback: a blind catch over a batch that fetches AND writes can be
# a Plex shape change, a DB error or our own bug, and `%s` told them apart badly.
log.exception("the Plex people-enrich batch failed")
# Probe a bounded batch of HW-decodable transcode items for bit-depth/HDR, so the # Probe a bounded batch of HW-decodable transcode items for bit-depth/HDR, so the
# playback-class filter's HW/SW split is precise (a 10-bit/HDR HEVC → software) over # playback-class filter's HW/SW split is precise (a 10-bit/HDR HEVC → software) over
# successive runs. Only HW-decodable-codec transcodes are probed — the rest are SW by codec. # successive runs. Only HW-decodable-codec transcodes are probed — the rest are SW by codec.
try: try:
stats["media_probed"] = _probe_media_batch(db, plex) stats["media_probed"] = _probe_media_batch(db, plex)
except Exception as e: # best-effort — never fail the sync over the backfill except Exception: # best-effort; never fail the sync over a backfill
log.warning("Plex media probe batch failed: %s", e) log.exception("the Plex media-probe batch failed")
# silent-ok: the return value IS the report. `{"skipped": …}` goes to the scheduler, which
# logs the job result, and to the admin route, which answers it to the caller.
except PlexNotConfigured as e: except PlexNotConfigured as e:
return {"skipped": str(e)} return {"skipped": str(e)}
except PlexError as e: except PlexError as e:
@@ -285,8 +281,8 @@ def sync(db: Session) -> dict:
# appear in ANY item's cast/directors — drop those rows, mirroring how Plex drops orphaned metadata. # appear in ANY item's cast/directors — drop those rows, mirroring how Plex drops orphaned metadata.
try: try:
stats["people_pruned"] = people.prune_orphan_people(db) stats["people_pruned"] = people.prune_orphan_people(db)
except Exception as e: # best-effort housekeeping — never fail the sync over it except Exception: # best-effort housekeeping; never fail the sync over it
log.warning("Plex people prune failed: %s", e) log.exception("the Plex people prune failed")
log.info("Plex sync done: %s", stats) log.info("Plex sync done: %s", stats)
return stats return stats
@@ -325,7 +321,9 @@ def _probe_media_batch(db: Session, plex: PlexClient, limit: int = _PROBE_BATCH)
for row in rows: for row in rows:
try: try:
meta = plex.metadata(row.rating_key) or {} meta = plex.metadata(row.rating_key) or {}
except Exception as e: # transient — leave media_probed_at NULL so it retries next run # No traceback and only debug: this is per item over a whole library, and the stack is
# the same `plex.metadata` call every time. The retry next run is the handling.
except Exception as e: # noqa: BLE001 — transient; leave media_probed_at NULL to retry
log.debug("Plex media probe fetch failed for %s: %s", row.rating_key, e) log.debug("Plex media probe fetch failed for %s: %s", row.rating_key, e)
continue continue
bit_depth, hdr = _video_stream_props(meta) bit_depth, hdr = _video_stream_props(meta)
@@ -372,7 +370,8 @@ def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BAT
for row in [*movies, *shows]: for row in [*movies, *shows]:
try: try:
meta = plex.metadata(row.rating_key) or {} meta = plex.metadata(row.rating_key) or {}
except Exception as e: # As in `_probe_media_batch` above: per item, so the message without the stack.
except Exception as e: # noqa: BLE001 — transient; leave people_enriched_at NULL
log.debug("Plex people enrich fetch failed for %s: %s", row.rating_key, e) log.debug("Plex people enrich fetch failed for %s: %s", row.rating_key, e)
continue # leave people_enriched_at NULL so it's retried on a later run continue # leave people_enriched_at NULL so it's retried on a later run
people.enrich_row_people(db, row, people.cast_from_meta(meta)) people.enrich_row_people(db, row, people.cast_from_meta(meta))
@@ -419,7 +418,10 @@ def _paginate(plex: PlexClient, key: str, item_type: int, progress: dict | None
if progress is not None: if progress is not None:
# A truthy `total` we reached = a trustworthy complete listing. `total` unknown/zero-with-rows # A truthy `total` we reached = a trustworthy complete listing. `total` unknown/zero-with-rows
# (a Plex that doesn't report totalSize) is treated as NOT complete, so prune stays conservative. # (a Plex that doesn't report totalSize) is treated as NOT complete, so prune stays conservative.
# `total` itself is kept for a caller to whom a server that SAID 0 is news it can trust (the
# watch reconcile); an empty mirror or overlay must not read it so (E32 S2).
progress["complete"] = bool(total) and start >= total progress["complete"] = bool(total) and start >= total
progress["total"] = total
# The section listing exposes at most the top ~20 cast (Plex's own cap); a stored cast longer than this # The section listing exposes at most the top ~20 cast (Plex's own cap); a stored cast longer than this
@@ -436,6 +438,10 @@ def _apply_facet_fields(row, meta: dict) -> None:
row.studio = (meta.get("studio") or None) and str(meta.get("studio"))[:255] row.studio = (meta.get("studio") or None) and str(meta.get("studio"))[:255]
row.originally_available_at = _date(meta.get("originallyAvailableAt")) row.originally_available_at = _date(meta.get("originallyAvailableAt"))
row.genres = _tags(meta, "Genre") row.genres = _tags(meta, "Genre")
# Every row has Hungarian genres, not only one with an HU overlay twin (the overlay pass, which runs
# after this, puts the twin's own list over it): the HU genre facets and filter read this column,
# and an English word there could never match a filter read in Hungarian.
row.genres_hu = hu_genres(row.genres)
row.directors = _tags(meta, "Director") row.directors = _tags(meta, "Director")
# The listing carries only the top ~20 cast, so re-applying it would DOWNGRADE an already-enriched # The listing carries only the top ~20 cast, so re-applying it would DOWNGRADE an already-enriched
# full cast (up to 24, grown by the rich backfill/info-page enrich), silently breaking the # full cast (up to 24, grown by the rich backfill/info-page enrich), silently breaking the
@@ -643,59 +649,6 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
_prune_stale(db, shows, shows_seen, stats, complete=shows_progress.get("complete", False)) _prune_stale(db, shows, shows_seen, stats, complete=shows_progress.get("complete", False))
def _sync_overlay(
db: Session, plex: PlexClient, lib: PlexLibrary, en_lib_id: int, stats: dict
) -> None:
"""Write an overlay section's Hungarian metadata onto its EN twin's rows, matched by guid. Creates
NO browsable rows — an overlay library is never listed on its own. Only title/summary/genres differ
by language; the language-neutral people_text feeds both search vectors.
This is a full RECONCILE: every guid-bearing EN row's HU fields are cleared first, then the HU
section repopulates the matches — so an EN row whose HU twin vanished (or was re-guid'd) falls back
to EN instead of keeping stale Hungarian metadata. HU items with no EN match are skipped."""
if lib.kind == "movie":
rows = db.execute(
select(PlexItem).where(
PlexItem.library_id == en_lib_id,
PlexItem.kind == "movie",
PlexItem.guid.is_not(None),
)
).scalars()
item_type = _TYPE_MOVIE
else:
rows = db.execute(
select(PlexShow).where(PlexShow.library_id == en_lib_id, PlexShow.guid.is_not(None))
).scalars()
item_type = _TYPE_SHOW
# guid -> the EN rows carrying it (a list: two editions of one film can share a guid; both overlay).
by_guid: dict[str, list] = {}
for r in rows:
by_guid.setdefault(r.guid, []).append(r)
# Reconcile step: clear HU fields on all guid-bearing rows. Only rows that actually held HU data
# become dirty (SQLAlchemy skips a no-net-change UPDATE), so this is cheap; matches are re-set below.
for group in by_guid.values():
for row in group:
row.title_hu = row.summary_hu = row.genres_hu = None
seen: set[str] = (
set()
) # first HU entry for a guid wins; a dup HU item can't overwrite or double-count
for meta in _paginate(plex, lib.plex_key, item_type):
g = _canonical_guid(meta)
targets = by_guid.get(g) if g else None
if not targets or g in seen:
continue
seen.add(g)
title_hu = (meta.get("title") or "").strip() or None
summary_hu = meta.get("summary")
genres_hu = _tags(meta, "Genre")
for row in targets:
row.title_hu = title_hu
row.summary_hu = summary_hu
row.genres_hu = genres_hu
stats["overlaid"] = stats.get("overlaid", 0) + 1
db.flush()
def _upsert_collection( def _upsert_collection(
db: Session, existing: PlexCollection | None, rk: str, lib_id: int, meta: dict db: Session, existing: PlexCollection | None, rk: str, lib_id: int, meta: dict
) -> PlexCollection: ) -> PlexCollection:
@@ -721,9 +674,11 @@ def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str)
meta = plex.metadata(rk) or {} meta = plex.metadata(rk) or {}
col = _upsert_collection(db, by_rating_key(db, PlexCollection, rk), rk, lib.id, meta) col = _upsert_collection(db, by_rating_key(db, PlexCollection, rk), rk, lib.id, meta)
Model = PlexItem if lib.kind == "movie" else PlexShow Model = PlexItem if lib.kind == "movie" else PlexShow
new_members = { children = _believed_children(plex, rk, meta)
str(c.get("ratingKey")) for c in plex.collection_children(rk) if c.get("ratingKey") if children is None:
} db.flush()
return col
new_members = {str(c.get("ratingKey")) for c in children if c.get("ratingKey")}
current = db.execute(select(Model).where(Model.collection_keys.contains([rk]))).scalars().all() current = db.execute(select(Model).where(Model.collection_keys.contains([rk]))).scalars().all()
for row in current: # drop rk from rows that are no longer members for row in current: # drop rk from rows that are no longer members
if row.rating_key not in new_members: if row.rating_key not in new_members:
@@ -736,6 +691,22 @@ def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str)
return col return col
def _believed_children(plex: PlexClient, rk: str, meta: dict) -> list[dict] | None:
"""A collection's members, or None when the answer is not believed: no members from a collection
whose own listing counts some (`childCount`) is a glitch, not a collection emptied — the same
empty-answer rule as the collection listing's (E32 review). Rebuilding on it showed the
collection empty until the next sync."""
children = plex.collection_children(rk)
count = str(meta.get("childCount") or "0")
if children or not count.isdigit() or int(count) == 0:
return children
log.warning(
"Plex listed no members for a non-empty collection; keeping its membership",
extra=logs.event("plex.sync.collection_children_empty", collection=rk),
)
return None
def delete_collection_local(db: Session, lib: PlexLibrary, rk: str) -> None: def delete_collection_local(db: Session, lib: PlexLibrary, rk: str) -> None:
"""Local cleanup after a collection is deleted from Plex: strip rk from members + drop the row.""" """Local cleanup after a collection is deleted from Plex: strip rk from members + drop the row."""
Model = PlexItem if lib.kind == "movie" else PlexShow Model = PlexItem if lib.kind == "movie" else PlexShow
@@ -747,6 +718,19 @@ def delete_collection_local(db: Session, lib: PlexLibrary, rk: str) -> None:
db.flush() db.flush()
def _rebuild_membership(
db: Session, lib: PlexLibrary, membership: dict[str, list[str]], kept: set[str]
) -> None:
"""Rebuild collection_keys on every member row of this library (clear + set), carrying over the
keys of a collection whose empty answer was not believed (`_believed_children`)."""
for Model in (PlexItem, PlexShow):
for row in db.execute(select(Model).where(Model.library_id == lib.id)).scalars():
held = [k for k in (row.collection_keys or []) if k in kept]
keys = [*membership.get(row.rating_key, []), *held]
row.collection_keys = list(dict.fromkeys(keys)) or None
db.flush()
def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None: def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None:
"""Mirror a library's collections + membership. Reads never touch Plex afterwards. Membership is """Mirror a library's collections + membership. Reads never touch Plex afterwards. Membership is
written as `collection_keys` on member movies (plex_items) / shows (plex_shows). A full reconcile: written as `collection_keys` on member movies (plex_items) / shows (plex_shows). A full reconcile:
@@ -758,7 +742,17 @@ def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: di
select(PlexCollection).where(PlexCollection.library_id == lib.id) select(PlexCollection).where(PlexCollection.library_id == lib.id)
).scalars() ).scalars()
} }
if not cols and existing:
# The catalog's own rule (see the module doc): an empty listing while we hold rows is a Plex
# glitch, not a library emptied of collections. Reconciling on it dropped every collection
# (its user-set `editable` flag with it) and every member's keys (E32 S2).
log.warning(
"Plex listed no collections for a library that has some; keeping them this run",
extra=logs.event("plex.sync.collections_empty", library=lib.plex_key),
)
return
seen: set[str] = set() seen: set[str] = set()
kept: set[str] = set() # collections whose empty children answer is not believed
membership: dict[str, list[str]] = {} # member rating_key -> [collection rating_key, ...] membership: dict[str, list[str]] = {} # member rating_key -> [collection rating_key, ...]
for meta in cols: for meta in cols:
rk = str(meta.get("ratingKey") or "") rk = str(meta.get("ratingKey") or "")
@@ -766,7 +760,10 @@ def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: di
continue continue
_upsert_collection(db, existing.get(rk), rk, lib.id, meta) _upsert_collection(db, existing.get(rk), rk, lib.id, meta)
seen.add(rk) seen.add(rk)
for child in plex.collection_children(rk): children = _believed_children(plex, rk, meta)
if children is None:
kept.add(rk)
for child in children or []:
crk = str(child.get("ratingKey") or "") crk = str(child.get("ratingKey") or "")
if crk: if crk:
membership.setdefault(crk, []).append(rk) membership.setdefault(crk, []).append(rk)
@@ -775,9 +772,4 @@ def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: di
for rk, col in existing.items(): for rk, col in existing.items():
if rk not in seen: if rk not in seen:
db.delete(col) db.delete(col)
# Rebuild collection_keys on every member row of this library (clear + set). _rebuild_membership(db, lib, membership, kept)
for row in db.execute(select(PlexItem).where(PlexItem.library_id == lib.id)).scalars():
row.collection_keys = membership.get(row.rating_key) or None
for row in db.execute(select(PlexShow).where(PlexShow.library_id == lib.id)).scalars():
row.collection_keys = membership.get(row.rating_key) or None
db.flush()
+199 -32
View File
@@ -10,21 +10,23 @@ One-parser rule (mirrors app/plex/detail.py): `parse_tmdb` is the pure parser, `
the row. The enrich batch + scheduler entry live here too so the whole TMDB integration is one module. the row. The enrich batch + scheduler entry live here too so the whole TMDB integration is one module.
""" """
import logging import re
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import cast
import httpx import httpx
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import sysconfig from app import logs, sysconfig
from app.config import settings from app.config import settings
from app.models import PlexItem, PlexPerson, PlexShow from app.models import PlexItem, PlexPerson, PlexShow
from app.plex.cards import display_title
from app.plex.people import people_by_credits, warm_portraits
log = logging.getLogger("siftlode.tmdb") log = logs.get_logger(__name__)
# Politeness throttle: TMDB tolerates ~50 req/s; ~33/s leaves comfortable headroom for a shared host. # Politeness throttle: TMDB tolerates ~50 req/s; ~33/s leaves comfortable headroom for a shared host.
_MIN_INTERVAL = 0.03 _MIN_INTERVAL = 0.03
@@ -126,9 +128,17 @@ class TmdbClient:
"""Resolve an external id (imdb_id / tvdb_id) to TMDB items via /find.""" """Resolve an external id (imdb_id / tvdb_id) to TMDB items via /find."""
return self._get(f"/find/{external_id}", {"external_source": source}) return self._get(f"/find/{external_id}", {"external_source": source})
def countries(self) -> list[dict] | None:
"""TMDB's country list: `[{iso_3166_1, english_name, native_name}]` (a JSON array, which
`_get`'s dict annotation does not describe)."""
return cast("list[dict] | None", self._get("/configuration/countries"))
def fetch_person(self, person_id: int) -> dict | None: def fetch_person(self, person_id: int) -> dict | None:
"""One person's bio + combined credits (E5 S6), in a single append_to_response request.""" """One person's bio + combined credits (E5 S6) + the bio's translations (E30 S4), in a single
return self._get(f"/person/{person_id}", {"append_to_response": "combined_credits"}) append_to_response request."""
return self._get(
f"/person/{person_id}", {"append_to_response": "combined_credits,translations"}
)
def guid_to_tmdb_id(guid: str | None, media_type: str, tmdb: TmdbClient) -> int | None: def guid_to_tmdb_id(guid: str | None, media_type: str, tmdb: TmdbClient) -> int | None:
@@ -142,6 +152,12 @@ def guid_to_tmdb_id(guid: str | None, media_type: str, tmdb: TmdbClient) -> int
try: try:
return int(ext) return int(ext)
except ValueError: except ValueError:
# The caller stamps the watermark on a None, so this title is never looked at again:
# one unreadable guid costs it its artwork and its cast permanently.
log.info(
"a Plex guid named TMDB but did not carry a numeric id",
extra=logs.event("plex.tmdb.guid_unparsable", provider=provider),
)
return None return None
source = {"imdb": "imdb_id", "tvdb": "tvdb_id"}.get(provider) source = {"imdb": "imdb_id", "tvdb": "tvdb_id"}.get(provider)
if not source: if not source:
@@ -250,8 +266,12 @@ def parse_tmdb(payload: dict, media_type: str) -> dict:
c.get("name") for c in (payload.get("production_companies") or []) if c.get("name") c.get("name") for c in (payload.get("production_companies") or []) if c.get("name")
][:8] ][:8]
or None, or None,
# The ISO code beside TMDB's English name (E30): the client names the country in the reader's
# language from the code; the name is the fallback for a code it cannot name.
"production_countries": [ "production_countries": [
c.get("name") for c in (payload.get("production_countries") or []) if c.get("name") {"code": c.get("iso_3166_1") or None, "name": c["name"]}
for c in (payload.get("production_countries") or [])
if c.get("name")
] ]
or None, or None,
"recommendations": recs or None, "recommendations": recs or None,
@@ -297,8 +317,8 @@ def _link_people(db: Session, links: dict[str, int]) -> None:
def enrich_batch(db: Session, tmdb: TmdbClient, limit: int) -> int: def enrich_batch(db: Session, tmdb: TmdbClient, limit: int) -> int:
"""Enrich up to `limit` not-yet-TMDB-enriched titles (movies first, shows fill the remainder — """Enrich up to `limit` not-yet-TMDB-enriched titles (movies first, shows fill the
mirrors _enrich_people_batch). A transient TmdbError leaves the watermark NULL for retry; a remainder). A transient TmdbError leaves the watermark NULL for retry; a
definitive no-match (unresolvable guid / TMDB 404) still stamps the watermark so it isn't re-fetched definitive no-match (unresolvable guid / TMDB 404) still stamps the watermark so it isn't re-fetched
every run. One commit at the end. Returns the number processed (matched OR definitively unmatched).""" every run. One commit at the end. Returns the number processed (matched OR definitively unmatched)."""
movies = ( movies = (
@@ -352,13 +372,62 @@ def enrich_batch(db: Session, tmdb: TmdbClient, limit: int) -> int:
return processed return processed
def _country(c: dict | str) -> dict:
"""One stored production country as served: `{code, name}`. A row enriched before E30 stored the
bare English name; until `backfill_country_codes` reaches it, it serves with no code (the client
shows the name)."""
return c if isinstance(c, dict) else {"code": None, "name": c}
def backfill_country_codes(db: Session, tmdb: TmdbClient) -> int:
"""Give the countries stored before E30 (bare English names) their ISO codes, from TMDB's own
country list — the names came from that list, so they match it exactly. One request, and only
while such a row is left: re-fetching each title instead would take the enrichment batch days.
A name the list does not hold keeps no code. Returns the number of rows rewritten."""
stale = [
row
for model in (PlexItem, PlexShow)
for row in db.execute(
select(model).where(func.jsonb_typeof(model.production_countries[0]) == "string")
).scalars()
]
if not stale:
return 0
codes = {
c["english_name"]: c["iso_3166_1"]
for c in tmdb.countries() or []
if c.get("english_name") and c.get("iso_3166_1")
}
for row in stale:
row.production_countries = [
{"code": codes.get(c), "name": c} if isinstance(c, str) else c
for c in row.production_countries or []
]
db.commit()
return len(stale)
def run_enrich(db: Session) -> dict: def run_enrich(db: Session) -> dict:
"""Scheduler entry point. No-op (never an error) when the TMDB key isn't configured.""" """Scheduler entry point. No-op (never an error) when the TMDB key isn't configured."""
if not sysconfig.get_str(db, "tmdb_api_key"): if not sysconfig.get_str(db, "tmdb_api_key"):
return {"enriched": 0, "skipped": "tmdb_api_key not set"} return {"enriched": 0, "skipped": "tmdb_api_key not set"}
with TmdbClient(db) as tmdb: with TmdbClient(db) as tmdb:
coded = backfill_country_codes(db, tmdb)
n = enrich_batch(db, tmdb, settings.tmdb_enrich_batch) n = enrich_batch(db, tmdb, settings.tmdb_enrich_batch)
return {"enriched": n} return {"enriched": n, **({"countries_coded": coded} if coded else {})}
def run_people_enrich(db: Session) -> dict:
"""Scheduler entry point of people_enrich (E30 S6): a batch of person bios (the most-credited
first) and a batch of portraits. Each half is a no-op — never an error — without its TMDB key /
without Plex."""
batch = settings.people_enrich_batch
if sysconfig.get_str(db, "tmdb_api_key"):
with TmdbClient(db) as tmdb:
bios = enrich_people_batch(db, tmdb, batch)
else:
bios = {"skipped": "tmdb_api_key not set"}
return {"bios": bios, "portraits": warm_portraits(db, batch)}
def resolve_library_guids( def resolve_library_guids(
@@ -402,10 +471,11 @@ def resolve_library_guids(
return movies, shows return movies, shows
def resolve_recs(db: Session, recs: list[dict] | None) -> list[dict]: def resolve_recs(db: Session, recs: list[dict] | None, *, lang: str) -> list[dict]:
"""Tag each recommendation with whether it's in OUR library (matched by guid `tmdb:<id>`, bucketed """Tag each recommendation with whether it's in OUR library (matched by guid `tmdb:<id>`, bucketed
by media_type — see resolve_library_guids) and, if so, its rating_key — so the client can deep-link by media_type — see resolve_library_guids) and, if so, its rating_key — so the client can deep-link
the ones we have and merely display the rest.""" the ones we have and merely display the rest. One we have is named as the library names it, in
`lang` (E30); one we lack keeps the title TMDB gave."""
if not recs: if not recs:
return [] return []
movies, shows = resolve_library_guids(db, recs) movies, shows = resolve_library_guids(db, recs)
@@ -413,13 +483,19 @@ def resolve_recs(db: Session, recs: list[dict] | None) -> list[dict]:
for r in recs: for r in recs:
g = f"tmdb:{r.get('tmdb_id')}" g = f"tmdb:{r.get('tmdb_id')}"
row = shows.get(g) if r.get("media_type") == "tv" else movies.get(g) row = shows.get(g) if r.get("media_type") == "tv" else movies.get(g)
named = {"title": display_title(row, lang)} if row is not None else {}
out.append( out.append(
{**r, "in_library": row is not None, "rating_key": row.rating_key if row else None} {
**r,
**named,
"in_library": row is not None,
"rating_key": row.rating_key if row else None,
}
) )
return out return out
def serve_block(db: Session, row) -> dict | None: def serve_block(db: Session, row, *, lang: str) -> dict | None:
"""The TMDB extras for a movie/show detail response, served from the row (no live TMDB call). """The TMDB extras for a movie/show detail response, served from the row (no live TMDB call).
None until the row has been TMDB-enriched (the client then shows nothing extra).""" None until the row has been TMDB-enriched (the client then shows nothing extra)."""
if row.tmdb_enriched_at is None: if row.tmdb_enriched_at is None:
@@ -433,30 +509,58 @@ def serve_block(db: Session, row) -> dict | None:
"keywords": row.keywords or [], "keywords": row.keywords or [],
"crew": row.crew or [], "crew": row.crew or [],
"studios": row.studios or [], "studios": row.studios or [],
"production_countries": row.production_countries or [], "production_countries": [_country(c) for c in row.production_countries or []],
"recommendations": resolve_recs(db, row.recommendations), "recommendations": resolve_recs(db, row.recommendations, lang=lang),
"backdrops": row.backdrops or [], "backdrops": row.backdrops or [],
} }
# --- Person enrichment (E5 S6) ------------------------------------------------------------------- # --- Person enrichment (E5 S6) -------------------------------------------------------------------
# A person's bio + birth facts + a "Known for" credit pool, fetched once (the first person-page open) # A person's bio + birth facts + a "Known for" credit pool, fetched once — by the people_enrich job, or
# and served from the plex_people row. `known_for` stores the top combined-credits ids; the person # by the first person-page open if the job has not reached them yet — and served from the plex_people row. `known_for` stores the top combined-credits ids; the person
# endpoint resolves them to library rows by guid at serve time (like recommendations). # endpoint resolves them to library rows by guid at serve time (like recommendations).
_KNOWN_FOR_MAX = 15 # The candidate pool kept per person. Only the titles that are in the library are shown (at most
# routes.plex.people._KNOWN_FOR_SHOWN), so the pool is deeper than the rail: a star's 15 most-voted
# credits are often not the 15 a given library holds.
_KNOWN_FOR_MAX = 40
# TMDB genre ids of the shows a person visits as a guest (News, Reality, Talk). Ranked by popularity,
# talk-show visits crowded out the films (Tom Cruise: 14 of 15 were talk shows; E30 S6).
_GUEST_GENRES = {10763, 10764, 10767}
# A credit in which the person plays themselves ("Self", "Himself - Guest", "Herself (archive footage)").
_SELF_CREDIT = re.compile(r"^\s*(self|himself|herself|themselves)\b", re.IGNORECASE)
# The crew jobs that make a title the person's own work (a director is known for what they directed).
_KNOWN_FOR_CREW_JOBS = {"Director", "Creator"}
def _known_for_credit(c: dict, crew: bool) -> bool:
if crew:
return c.get("job") in _KNOWN_FOR_CREW_JOBS
if _SELF_CREDIT.match(c.get("character") or ""):
return False
return not _GUEST_GENRES.intersection(c.get("genre_ids") or [])
def parse_person(payload: dict) -> dict: def parse_person(payload: dict) -> dict:
"""TMDB /person/{id} (+combined_credits) → the persisted bio fields. `known_for` = the person's """TMDB /person/{id} (+combined_credits) → the persisted bio fields. `known_for` = the person's
most-popular movie/tv credits (deduped by id), top `_KNOWN_FOR_MAX`, resolved to library later.""" most-voted movie/tv credits — roles and directed works, not guest visits as themselves — deduped,
credits = (payload.get("combined_credits") or {}).get("cast") or [] the top `_KNOWN_FOR_MAX`, resolved to library rows at serve time. Ranked by vote count, as TMDB's
own "Known for" is: popularity is this week's attention, which a talk-show visit wins."""
combined = payload.get("combined_credits") or {}
credits = [c for c in combined.get("cast") or [] if _known_for_credit(c, crew=False)] + [
c for c in combined.get("crew") or [] if _known_for_credit(c, crew=True)
]
# Dedup on (id, media_type), not the bare id: TMDB movie ids and tv ids are independent sequences # Dedup on (id, media_type), not the bare id: TMDB movie ids and tv ids are independent sequences
# (id 603 can be both a movie and a show — see resolve_library_guids), so a same-id movie+show pair # (id 603 can be both a movie and a show — see resolve_library_guids), so a same-id movie+show pair
# is two distinct credits and both must survive. # is two distinct credits and both must survive.
seen: set[tuple[int, str]] = set() seen: set[tuple[int, str]] = set()
known: list[dict] = [] known: list[dict] = []
for c in sorted(credits, key=lambda c: c.get("popularity") or 0, reverse=True): ranked = sorted(
credits,
key=lambda c: (c.get("vote_count") or 0, c.get("popularity") or 0),
reverse=True,
)
for c in ranked:
tid, mt = c.get("id"), c.get("media_type") tid, mt = c.get("id"), c.get("media_type")
if not tid or mt not in ("movie", "tv") or (tid, mt) in seen: if not tid or mt not in ("movie", "tv") or (tid, mt) in seen:
continue continue
@@ -466,6 +570,7 @@ def parse_person(payload: dict) -> dict:
break break
return { return {
"biography": (payload.get("biography") or "").strip() or None, "biography": (payload.get("biography") or "").strip() or None,
"biography_hu": _translated_bio(payload, "hu"),
"birthday": payload.get("birthday") or None, "birthday": payload.get("birthday") or None,
"deathday": payload.get("deathday") or None, "deathday": payload.get("deathday") or None,
"place_of_birth": payload.get("place_of_birth") or None, "place_of_birth": payload.get("place_of_birth") or None,
@@ -475,8 +580,17 @@ def parse_person(payload: dict) -> dict:
} }
def _translated_bio(payload: dict, lang: str) -> str | None:
"""The `lang` biography from the appended `translations` (TMDB leaves an untranslated one blank)."""
for tr in (payload.get("translations") or {}).get("translations") or []:
if tr.get("iso_639_1") == lang:
return ((tr.get("data") or {}).get("biography") or "").strip() or None
return None
def apply_person(row: PlexPerson, parsed: dict) -> None: def apply_person(row: PlexPerson, parsed: dict) -> None:
row.biography = parsed["biography"] row.biography = parsed["biography"]
row.biography_hu = parsed["biography_hu"]
row.birthday = parsed["birthday"] row.birthday = parsed["birthday"]
row.deathday = parsed["deathday"] row.deathday = parsed["deathday"]
row.place_of_birth = parsed["place_of_birth"] row.place_of_birth = parsed["place_of_birth"]
@@ -486,11 +600,22 @@ def apply_person(row: PlexPerson, parsed: dict) -> None:
row.tmdb_enriched_at = datetime.now(timezone.utc) row.tmdb_enriched_at = datetime.now(timezone.utc)
def _fetch_person_into(tmdb: TmdbClient, person: PlexPerson) -> None:
"""Fetch one person from TMDB and write the row (not committed). A 404 (deleted person) stamps the
watermark with empty fields so it isn't fetched again. Raises TmdbError on a transient failure,
leaving the row untouched and `tmdb_enriched_at` NULL for a retry."""
payload = tmdb.fetch_person(cast(int, person.tmdb_person_id))
if payload is None:
person.tmdb_enriched_at = datetime.now(timezone.utc)
else:
apply_person(person, parse_person(payload))
def enrich_person(db: Session, person: PlexPerson) -> bool: def enrich_person(db: Session, person: PlexPerson) -> bool:
"""Lazily fetch + persist one person's TMDB bio (first person-page open), committing the write so it """Lazily fetch + persist one person's TMDB bio (a person page opened before the background job
survives regardless of the read path. No-op (False) without a key or a `tmdb_person_id`; a transient reached them), committing the write so it survives regardless of the read path. No-op (False)
error leaves `tmdb_enriched_at` NULL so the next open retries. A 404 (deleted person) stamps the without a key or a `tmdb_person_id`; a transient error leaves `tmdb_enriched_at` NULL so the next
watermark with empty fields so we don't refetch it every open.""" open retries."""
if not person.tmdb_person_id or not sysconfig.get_str(db, "tmdb_api_key"): if not person.tmdb_person_id or not sysconfig.get_str(db, "tmdb_api_key"):
return False return False
try: try:
@@ -498,12 +623,54 @@ def enrich_person(db: Session, person: PlexPerson) -> bool:
# longer blocks a serve; still cap the outbound TMDB call with a short timeout so a hanging TMDB # longer blocks a serve; still cap the outbound TMDB call with a short timeout so a hanging TMDB
# doesn't tie up a threadpool worker for the default 30s. # doesn't tie up a threadpool worker for the default 30s.
with TmdbClient(db, timeout=8.0) as tmdb: with TmdbClient(db, timeout=8.0) as tmdb:
payload = tmdb.fetch_person(person.tmdb_person_id) _fetch_person_into(tmdb, person)
except TmdbError: except TmdbError as exc:
# False leaves the person un-enriched and unstamped, so it is retried — but the page in
# front of the user right now shows an empty biography with nothing to say why.
log.warning(
"the TMDB person lookup failed",
extra=logs.event(
"plex.tmdb.person_enrich_failed",
person_id=person.id,
reason=type(exc).__name__,
),
)
return False return False
if payload is None:
person.tmdb_enriched_at = datetime.now(timezone.utc)
else:
apply_person(person, parse_person(payload))
db.commit() db.commit()
return True return True
# Lookups failing this many people in a row end a people batch: TMDB itself is degraded, and hammering
# it helps nobody. One person failing alone is skipped, so an id TMDB keeps refusing never stalls the rest.
_PEOPLE_FAILS_IN_A_ROW = 3
def enrich_people_batch(db: Session, tmdb: TmdbClient, limit: int) -> dict:
"""Fetch up to `limit` linked, not-yet-enriched people (E30 S6), the most-credited first, so a
person page reads its bio from the row instead of waiting on TMDB. Each person is committed as it
lands: a page open during the run already sees it. A failed lookup is skipped (left unstamped for the
next run); _PEOPLE_FAILS_IN_A_ROW of them in a row end the batch."""
ids = people_by_credits(db, "bio", limit)
done = failed = in_a_row = 0
for pid in ids:
if in_a_row >= _PEOPLE_FAILS_IN_A_ROW:
break
person = db.get(PlexPerson, pid)
# A page open may have enriched this person since the select: skip, don't fetch twice.
if person is None or person.tmdb_enriched_at is not None:
continue
try:
_fetch_person_into(tmdb, person)
except TmdbError as exc:
db.rollback()
failed, in_a_row = failed + 1, in_a_row + 1
log.warning(
"a TMDB person lookup in the batch failed",
extra=logs.event(
"plex.tmdb.people_batch_failed", person_id=pid, reason=type(exc).__name__
),
)
continue
db.commit()
done, in_a_row = done + 1, 0
return {"enriched": done, **({"failed": failed} if failed else {})}
+91 -26
View File
@@ -17,16 +17,19 @@ and the incremental pull resolves a genuine divergence to Plex's state (see `_pu
running a last-write-wins comparison. running a last-write-wins comparison.
""" """
import logging # On the PlexError lines in this module: none of them carries a traceback, deliberately. A
# PlexError is httpx's own message — the method, the URL and the status of one call — and the stack
# beneath it is always this `except` plus `PlexClient._get`. The attrs beside the line say which
# user and which phase, which is the part the message cannot know.
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import sysconfig from app import logs, sysconfig
from app.db import SessionLocal from app.db import SessionLocal
from app.models import PlexItem, PlexLink, PlexState from app.models import PlexItem, PlexLibrary, PlexLink, PlexState
from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.sync import ( from app.plex.sync import (
_TYPE_EPISODE, _TYPE_EPISODE,
@@ -36,8 +39,7 @@ from app.plex.sync import (
_paginate, _paginate,
) )
log = logging.getLogger("siftlode.plex.watch") log = logs.get_logger(__name__)
# A resume position below this many seconds isn't worth mirroring — matches the player's own # A resume position below this many seconds isn't worth mirroring — matches the player's own
# _PROGRESS_MIN_S (a stray few-second seek is not "in progress"). # _PROGRESS_MIN_S (a stray few-second seek is not "in progress").
_PROGRESS_MIN_S = 5 _PROGRESS_MIN_S = 5
@@ -70,13 +72,27 @@ def _rk_to_id(db: Session) -> dict[str, int]:
def _scan_plex_states( def _scan_plex_states(
db: Session, plex: PlexClient db: Session, plex: PlexClient
) -> tuple[dict[int, tuple[str, int, datetime | None, datetime | None]], int]: ) -> tuple[dict[int, tuple[str, int, datetime | None, datetime | None]], int, set[str], set[str]]:
"""Scan the enabled movie/show sections and return ``{item_id: (status, pos, watched_at, """Scan the enabled movie/show sections and return ``{item_id: (status, pos, watched_at,
prog_at)}`` for every mirrored leaf Plex holds a watch signal for, plus the scanned-leaf count. prog_at)}`` for every mirrored leaf Plex holds a watch signal for, the scanned-leaf count, the
Shared by the one-time import and the full reconcile (both need Plex's whole current picture).""" keys of the sections SETTLED — walked to the server's total (or said empty by a server we mirror
nothing from) — and the keys of every section walked.
Shared by the one-time import and the full reconcile (both need Plex's whole current picture).
The settled keys matter to the reconcile alone: it reads "absent from the scan" as "Plex
un-watched it", which a walk cut short by a Plex hiccup does not mean (see `_paginate`). Settled
is decided PER SECTION: one section that never settles (a library emptied on Plex while its
mirrored items are kept) must not stop un-watches syncing in all the others (E32 review)."""
item_id_by_rk = _rk_to_id(db) item_id_by_rk = _rk_to_id(db)
wanted = _enabled_section_keys(db) wanted = _enabled_section_keys(db)
# The sections we mirror something from: a total of 0 from one of those contradicts the mirror.
mirrored = set(
db.execute(
select(PlexLibrary.plex_key).join(PlexItem, PlexItem.library_id == PlexLibrary.id)
).scalars()
)
scanned = 0 scanned = 0
settled: set[str] = set()
walked: set[str] = set()
out: dict[int, tuple[str, int, datetime | None, datetime | None]] = {} out: dict[int, tuple[str, int, datetime | None, datetime | None]] = {}
for s in plex.sections(): for s in plex.sections():
stype = s.get("type") stype = s.get("type")
@@ -86,7 +102,10 @@ def _scan_plex_states(
if wanted is not None and key not in wanted: if wanted is not None and key not in wanted:
continue continue
item_type = _TYPE_MOVIE if stype == "movie" else _TYPE_EPISODE item_type = _TYPE_MOVIE if stype == "movie" else _TYPE_EPISODE
for meta in _paginate(plex, key, item_type): walked.add(key)
progress: dict = {}
before = scanned
for meta in _paginate(plex, key, item_type, progress):
scanned += 1 scanned += 1
item_id = item_id_by_rk.get(str(meta.get("ratingKey") or "")) item_id = item_id_by_rk.get(str(meta.get("ratingKey") or ""))
if item_id is None: if item_id is None:
@@ -96,7 +115,15 @@ def _scan_plex_states(
) )
if res is not None: if res is not None:
out[item_id] = res out[item_id] = res
return out, scanned # A section the server SAYS is empty (total 0, nothing listed) settles too. Believed only for
# a section we mirror nothing from: one whose items we hold answering 0 is a Plex that lost
# the library for a moment, not one that un-watched all of it (E32 review) — so it is left
# unsettled, and its rows are not cleared.
listed = scanned > before
said_empty = progress.get("total") == 0 and not listed and key not in mirrored
if (progress.get("complete") and listed) or said_empty:
settled.add(key)
return out, scanned, settled, walked
def import_owner_watch_state(db: Session, link: PlexLink) -> dict: def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
@@ -109,7 +136,8 @@ def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
stats = {"watched": 0, "in_progress": 0, "scanned": 0} stats = {"watched": 0, "in_progress": 0, "scanned": 0}
try: try:
with PlexClient(db) as plex: with PlexClient(db) as plex:
scanned_states, stats["scanned"] = _scan_plex_states(db, plex) # A partial scan is still safe here: the import only ever ADDS what Plex reports.
scanned_states, stats["scanned"], _settled, _walked = _scan_plex_states(db, plex)
# One row per item (keyed by item_id → inherently de-duped) then bulk-UPSERT. An upsert is # One row per item (keyed by item_id → inherently de-duped) then bulk-UPSERT. An upsert is
# idempotent AND concurrency-safe: a repeated/overlapping enable (the toggle firing twice # idempotent AND concurrency-safe: a repeated/overlapping enable (the toggle firing twice
# while the multi-second import runs) can't raise a duplicate-key error — it re-updates. # while the multi-second import runs) can't raise a duplicate-key error — it re-updates.
@@ -148,6 +176,8 @@ def import_owner_watch_state(db: Session, link: PlexLink) -> dict:
link.initial_import_done = True link.initial_import_done = True
link.last_watch_sync_at = datetime.now(timezone.utc) link.last_watch_sync_at = datetime.now(timezone.utc)
db.commit() db.commit()
# silent-ok: the `{"skipped": …}` return is the report — the admin route answers it and the
# scheduler logs it on the job line.
except PlexNotConfigured as e: except PlexNotConfigured as e:
return {"skipped": str(e)} return {"skipped": str(e)}
except PlexError as e: except PlexError as e:
@@ -326,8 +356,18 @@ def _resolve_account_id(db: Session, plex: PlexClient, link: PlexLink) -> int:
if int(a.get("id") or -1) == 1: if int(a.get("id") or -1) == 1:
username = a.get("name") username = a.get("name")
break break
except PlexError: except PlexError as exc:
pass # The fallback account id 1 is right for the admin-token setup, so sync keeps working —
# but the username is never stored, and the UI then has no name to show for the link,
# exactly as if the server had not offered one.
log.info(
"the Plex account list could not be read; the link keeps the default account",
extra=logs.event(
"plex.watch.accounts_probe_failed",
user_id=link.user_id,
reason=type(exc).__name__,
),
)
link.plex_account_id = acct_id link.plex_account_id = acct_id
if username: if username:
link.plex_username = username link.plex_username = username
@@ -566,6 +606,7 @@ def run_plex_watch_sync(db: Session) -> dict:
total["repushed"] += _repush_dirty(db, plex, link) total["repushed"] += _repush_dirty(db, plex, link)
link.last_watch_sync_at = now link.last_watch_sync_at = now
db.commit() db.commit()
# silent-ok: the `{"skipped": …}` return is the report, as above.
except PlexNotConfigured as e: except PlexNotConfigured as e:
return {"skipped": str(e)} return {"skipped": str(e)}
except PlexError as e: except PlexError as e:
@@ -575,6 +616,28 @@ def run_plex_watch_sync(db: Session) -> dict:
return total return total
def _clear_unwatched(db: Session, user_id: int, plex_states: dict, settled: set[str]) -> int:
"""Clear the user's rows Plex no longer has a record for — the mirrored ones (Plex un-watched
them); hidden and dirty rows stay (a dirty one is re-pushed by `_repush_dirty`). Only rows of the
sections the scan SETTLED: a walk a Plex hiccup cut short leaves out rows it merely failed to
reach, and clearing those un-watched them here until the next complete run (E32 S2); an item of
a library the allowlist leaves out is absent from every scan, and was cleared on every run (E32
review)."""
n = 0
rows = db.execute(
select(PlexState)
.join(PlexItem, PlexItem.id == PlexState.item_id)
.join(PlexLibrary, PlexLibrary.id == PlexItem.library_id)
.where(PlexState.user_id == user_id, PlexLibrary.plex_key.in_(settled))
)
for st in rows.scalars().all():
if st.item_id in plex_states or st.status == "hidden" or not st.synced_to_plex:
continue
db.delete(st)
n += 1
return n
def run_plex_watch_reconcile(db: Session) -> dict: def run_plex_watch_reconcile(db: Session) -> dict:
"""Scheduler job (default daily) — the authoritative full reconcile. Rescans every section and, """Scheduler job (default daily) — the authoritative full reconcile. Rescans every section and,
per item, uses `synced_to_plex` to settle what the incremental feed can't: per item, uses `synced_to_plex` to settle what the incremental feed can't:
@@ -593,7 +656,7 @@ def run_plex_watch_reconcile(db: Session) -> dict:
try: try:
with PlexClient(db) as plex: with PlexClient(db) as plex:
_resolve_account_id(db, plex, link) _resolve_account_id(db, plex, link)
plex_states, scanned = _scan_plex_states(db, plex) plex_states, scanned, settled, walked = _scan_plex_states(db, plex)
total["scanned"] += scanned total["scanned"] += scanned
for item_id, (status, pos, watched_at, prog_at) in plex_states.items(): for item_id, (status, pos, watched_at, prog_at) in plex_states.items():
@@ -601,22 +664,24 @@ def run_plex_watch_reconcile(db: Session) -> dict:
if ch: if ch:
total["applied"] += 1 total["applied"] += 1
# Siftlode rows Plex no longer has a record for: clear the ones that were mirrored total["cleared"] += _clear_unwatched(db, link.user_id, plex_states, settled)
# (Plex un-watched them); leave hidden + dirty (dirty gets re-pushed just below). if unsettled := sorted(walked - settled):
for st in ( total["incomplete"] = total.get("incomplete", 0) + 1
db.execute(select(PlexState).where(PlexState.user_id == link.user_id)) log.warning(
.scalars() "Plex sections %s were cut short or answered empty; their rows are not cleared"
.all() " this run",
): ", ".join(unsettled),
if st.item_id in plex_states or st.status == "hidden": extra=logs.event(
continue "plex.watch.reconcile_incomplete",
if st.synced_to_plex: user_id=link.user_id,
db.delete(st) sections=unsettled,
total["cleared"] += 1 ),
)
total["repushed"] += _repush_dirty(db, plex, link) total["repushed"] += _repush_dirty(db, plex, link)
link.last_watch_sync_at = datetime.now(timezone.utc) link.last_watch_sync_at = datetime.now(timezone.utc)
db.commit() db.commit()
# silent-ok: the `{"skipped": …}` return is the report, as above.
except PlexNotConfigured as e: except PlexNotConfigured as e:
return {"skipped": str(e)} return {"skipped": str(e)}
except PlexError as e: except PlexError as e:
+17 -2
View File
@@ -8,7 +8,7 @@ its own thread/context) never cross wires. When no sink is bound (e.g. a manual
""" """
import contextvars import contextvars
from typing import Callable, Optional from typing import Callable, Literal, Optional
Sink = Callable[[int, Optional[int], Optional[str]], None] Sink = Callable[[int, Optional[int], Optional[str]], None]
@@ -24,6 +24,19 @@ def report(current: int, total: int | None = None, phase: str | None = None) ->
sink(current, total, phase) sink(current, total, phase)
class JobFailed(RuntimeError):
"""A job's own failure, said so the reader's language can name it (E31): the client reads
`scheduler.failure.<reason>` and interpolates `params` (keep them to values the reader may see —
numbers, ids, short codes). The message is the English line the log and `last_error` keep. An
upstream's own error (a timeout, a refused connection) is not one of these: it stays as raised,
being diagnostic."""
def __init__(self, reason: str, message: str, **params: object):
super().__init__(message)
self.reason = reason
self.params = params
class bind: class bind:
"""Context manager that binds a progress sink for the duration of a job run.""" """Context manager that binds a progress sink for the duration of a job run."""
@@ -35,7 +48,9 @@ class bind:
self._token = _sink.set(self._sink) self._token = _sink.set(self._sink)
return self return self
def __exit__(self, *exc) -> bool: # Literal[False], not bool: this never swallows the job's exception, and saying so is what lets
# a type checker know a name assigned inside `with bind(...)` is bound after it.
def __exit__(self, *exc) -> Literal[False]:
if self._token is not None: if self._token is not None:
_sink.reset(self._token) _sink.reset(self._token)
return False return False
+19 -2
View File
@@ -7,11 +7,13 @@ always be allowed; expensive backfill must yield to the remaining budget.
import contextlib import contextlib
import contextvars import contextvars
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any, cast
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from sqlalchemy import func, select from sqlalchemy import delete, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.engine import CursorResult
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import sysconfig from app import sysconfig
@@ -37,6 +39,7 @@ class QuotaAction:
PLAYLISTS_DELETE = "playlists_delete" PLAYLISTS_DELETE = "playlists_delete"
VIDEOS_BACKFILL_RECENT = "videos_backfill_recent" VIDEOS_BACKFILL_RECENT = "videos_backfill_recent"
VIDEOS_BACKFILL_FULL = "videos_backfill_full" VIDEOS_BACKFILL_FULL = "videos_backfill_full"
VIDEOS_FEED_FALLBACK = "videos_feed_fallback"
VIDEOS_ENRICH = "videos_enrich" VIDEOS_ENRICH = "videos_enrich"
VIDEOS_LOOKUP = "videos_lookup" VIDEOS_LOOKUP = "videos_lookup"
VIDEOS_SEARCH = "videos_search" VIDEOS_SEARCH = "videos_search"
@@ -95,6 +98,20 @@ def pacific_today():
return datetime.now(_PACIFIC).date() return datetime.now(_PACIFIC).date()
def gc(db: Session, retention_days: int) -> int:
"""Delete quota-history rows older than `retention_days` (<= 0 keeps them forever) and return how
many went. Flushes only; the cleanup job commits. Today's rows are never in reach: the per-user
search cap counts them, and the floor of two days outlasts any Pacific day (25 h at most)."""
if retention_days <= 0:
return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=max(2, retention_days))
# A DELETE answers with a CursorResult; `execute` is typed as the general Result, without rowcount.
result = cast(
CursorResult[Any], db.execute(delete(QuotaEvent).where(QuotaEvent.created_at < cutoff))
)
return result.rowcount or 0
def pacific_day_start_utc() -> datetime: def pacific_day_start_utc() -> datetime:
"""UTC instant of the current Pacific day's midnight — aligns 'today' with the budget reset.""" """UTC instant of the current Pacific day's midnight — aligns 'today' with the budget reset."""
start = datetime.now(_PACIFIC).replace(hour=0, minute=0, second=0, microsecond=0) start = datetime.now(_PACIFIC).replace(hour=0, minute=0, second=0, microsecond=0)
+17 -11
View File
@@ -17,10 +17,14 @@ from app.config import settings
class RateLimiter: class RateLimiter:
def __init__(self, max_events: int, window_seconds: float): """At most `max_events` attempts per `window_seconds` per key — or, when each attempt passes a
`cost`, at most that much total cost (a byte budget counts bytes, not calls)."""
def __init__(self, max_events: float, window_seconds: float):
self.max_events = max_events self.max_events = max_events
self.window = window_seconds self.window = window_seconds
self._hits: dict[str, list[float]] = {} # key → (monotonic time, cost) of every counted attempt still inside the window
self._hits: dict[str, list[tuple[float, float]]] = {}
self._lock = threading.Lock() self._lock = threading.Lock()
def reset(self) -> None: def reset(self) -> None:
@@ -36,27 +40,27 @@ class RateLimiter:
count.""" count."""
cutoff = time.monotonic() - self.window cutoff = time.monotonic() - self.window
with self._lock: with self._lock:
return sum(1 for t in self._hits.get(key, []) if t > cutoff) >= self.max_events return sum(c for t, c in self._hits.get(key, []) if t > cutoff) >= self.max_events
def allow(self, key: str) -> bool: def allow(self, key: str, cost: float = 1) -> bool:
"""Record an attempt for ``key`` and return whether it is within the limit. """Record an attempt costing ``cost`` for ``key`` and return whether it is within the limit.
True -> under the cap (the attempt is counted). True -> within the cap (the attempt is counted).
False -> the cap for the current window is already reached (attempt NOT counted, so a False -> the attempt would pass the cap for the current window (attempt NOT counted, so a
blocked caller can't keep pushing the window forward).""" blocked caller can't keep pushing the window forward)."""
now = time.monotonic() now = time.monotonic()
cutoff = now - self.window cutoff = now - self.window
with self._lock: with self._lock:
hits = [t for t in self._hits.get(key, []) if t > cutoff] hits = [(t, c) for t, c in self._hits.get(key, []) if t > cutoff]
if len(hits) >= self.max_events: if sum(c for _, c in hits) + cost > self.max_events:
self._hits[key] = hits self._hits[key] = hits
return False return False
hits.append(now) hits.append((now, cost))
self._hits[key] = hits self._hits[key] = hits
# Opportunistic cleanup so abandoned keys don't accumulate unboundedly. # Opportunistic cleanup so abandoned keys don't accumulate unboundedly.
if len(self._hits) > 4096: if len(self._hits) > 4096:
for k in list(self._hits): for k in list(self._hits):
fresh = [t for t in self._hits[k] if t > cutoff] fresh = [(t, c) for t, c in self._hits[k] if t > cutoff]
if fresh: if fresh:
self._hits[k] = fresh self._hits[k] = fresh
else: else:
@@ -69,6 +73,8 @@ def _norm_ip(s: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
plain-IPv4 allowlist still matches a peer that surfaces in mapped form. None for a non-IP string.""" plain-IPv4 allowlist still matches a peer that surfaces in mapped form. None for a non-IP string."""
try: try:
ip = ipaddress.ip_address(s.strip()) ip = ipaddress.ip_address(s.strip())
# silent-ok: parsing an untrusted header value, where "not an IP" is a documented answer this
# function returns — and a line per request is what an attacker would aim for.
except ValueError: except ValueError:
return None return None
return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip
+13 -6
View File
@@ -13,12 +13,12 @@ mutated from a single thread.
""" """
import asyncio import asyncio
import logging
from collections import defaultdict from collections import defaultdict
from starlette.websockets import WebSocket from starlette.websockets import WebSocket
from app import logs
log = logging.getLogger("siftlode.realtime") log = logs.get_logger(__name__)
class ConnectionManager: class ConnectionManager:
@@ -42,9 +42,16 @@ class ConnectionManager:
for ws in list(self._conns.get(user_id, ())): for ws in list(self._conns.get(user_id, ())):
try: try:
await ws.send_json(payload) await ws.send_json(payload)
except Exception: except Exception as exc: # noqa: BLE001 — one dead socket must not stop the others
# Stale socket; it'll be cleaned up by its own disconnect handler. # Stale socket; it'll be cleaned up by its own disconnect handler. But this is
pass # also where a live update goes missing: the UI simply never changes, and there
# is no request behind it whose failure anyone could look up.
log.debug(
"a live update could not be pushed to a socket",
extra=logs.event(
"realtime.push_failed", user_id=user_id, reason=type(exc).__name__
),
)
def push(self, user_id: int, payload: dict) -> None: def push(self, user_id: int, payload: dict) -> None:
"""Schedule a push onto the WebSocket event loop. Safe to call from a sync route """Schedule a push onto the WebSocket event loop. Safe to call from a sync route
@@ -54,7 +61,7 @@ class ConnectionManager:
return return
try: try:
asyncio.run_coroutine_threadsafe(self._push(user_id, payload), loop) asyncio.run_coroutine_threadsafe(self._push(user_id, payload), loop)
except Exception: except Exception: # noqa: BLE001 — a closed loop or socket must not fail the caller
log.debug("ws push failed", exc_info=True) log.debug("ws push failed", exc_info=True)
+37
View File
@@ -0,0 +1,37 @@
"""A request the app refuses, said in a way the UI can put in the reader's language.
Every refusal the API answers carries a structured `detail` — `{code, reason, …params}` — and the client
translates it (`lib/api/core.ts` `localizeDetail`). A bare English string reached the Hungarian UI
verbatim: the blocking error dialog, and every inline surface that printed `HttpError.detail`, showed
whatever sentence the route happened to hand-write. `refused` is the generic family
(`errors.refused.<reason>`, the params interpolated into the sentence); the older specific codes
(`not_found`, `youtube`, `playlist`, the admin refusals, …) stay, since each is already translated.
A test (`test_http_details.py`) reads every `HTTPException` in the app and fails on a string detail,
and the frontend's contract test requires every `refused(…, "<reason>")` to be named in both
languages — so the rule is enforced, not described.
"""
from fastapi import HTTPException
def refused(status: int, reason: str, **params: object) -> HTTPException:
"""The refusal to raise: `raise refused(409, "view_name_taken", name=name)`. `params` travel in
the detail for the sentence to interpolate (`{{name}}`); keep them to values the reader may see."""
return HTTPException(status_code=status, detail={"code": "refused", "reason": reason, **params})
# `sysconfig.InvalidSetting.reason` → the refusal it answers as. A dict of literals, so the frontend's
# contract test can read every reason this can produce.
_SETTING_REASONS = {
"not_a_number": "setting_not_a_number",
"too_low": "setting_too_low",
"too_high": "setting_too_high",
"not_a_choice": "setting_not_a_choice",
"no_encryption_key": "no_encryption_key",
}
def setting_refused(exc) -> HTTPException:
"""A value the admin entered that `sysconfig.set_value` refused (`exc`: its `InvalidSetting`)."""
return refused(400, _SETTING_REASONS[exc.reason], key=exc.key, **exc.params)
+104 -9
View File
@@ -6,13 +6,16 @@ and "fetch a row this user owns or 404" idioms have one definition, one status c
one existence-leak policy. one existence-leak policy.
""" """
from typing import Any, TypeVar from typing import IO, Any, TypeVar
from fastapi import HTTPException from fastapi import HTTPException, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.db import Base from app.db import Base
from app.models import User from app.models import User
from app.issues import gitea
from app.refusal import refused
from app.youtube.client import YouTubeError, refusal_reason
M = TypeVar("M", bound=Base) M = TypeVar("M", bound=Base)
@@ -25,10 +28,13 @@ def not_found(what: str = "generic") -> HTTPException:
return HTTPException(status_code=404, detail={"code": "not_found", "reason": what}) return HTTPException(status_code=404, detail={"code": "not_found", "reason": what})
def get_or_404(db: Session, model: type[M], id_: Any, *, what: str = "generic") -> M: def get_or_404(
db: Session, model: type[M], id_: Any, *, what: str = "generic", lock: bool = False
) -> M:
"""Fetch a row by primary key or raise 404. Replaces the `db.get(Model, id); if None: 404` """Fetch a row by primary key or raise 404. Replaces the `db.get(Model, id); if None: 404`
idiom repeated across routes.""" idiom repeated across routes. `lock` takes it FOR UPDATE (bypassing the identity map), so two
obj = db.get(model, id_) requests that read-then-write its children (a playlist's item positions) run one after the other."""
obj = db.get(model, id_, with_for_update=True if lock else None)
if obj is None: if obj is None:
raise not_found(what) raise not_found(what)
return obj return obj
@@ -42,12 +48,101 @@ def owned_or_404(
*, *,
owner_attr: str = "user_id", owner_attr: str = "user_id",
what: str = "generic", what: str = "generic",
lock: bool = False,
) -> M: ) -> M:
"""Fetch a row by primary key that `user` owns, or raise 404. A row owned by someone """Fetch a row by primary key that `user` owns, or raise 404 (`lock`: see `get_or_404`). A row owned by someone
else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger. else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger.
Generalizes the near-identical per-route ownership helpers (e.g. plex's It replaced the near-identical per-route ownership helpers, and is called directly — a thin
`_own_playlist_or_404`, the saved-views owner check).""" `_own_x` wrapper around it is the ad-hoc shape CLAUDE.md's patterns table rules out."""
obj = get_or_404(db, model, id_, what=what) obj = get_or_404(db, model, id_, what=what, lock=lock)
if getattr(obj, owner_attr) != user.id: if getattr(obj, owner_attr) != user.id:
raise not_found(what) raise not_found(what)
return obj return obj
def youtube_refusal(exc: Exception, *, playlist: bool = False) -> HTTPException:
"""The answer to an action YouTube refused: a 422 whose reason the UI translates
(`errors.youtube.<reason>`). One definition, because the routes had grown three shapes of it —
a bare English string quoting the upstream message, a playlist-scoped reason, and nothing at
all (a raw 500 from the manual syncs). The upstream failure is already logged where it
happened (`youtube.api.error`). A failure that is not the Data API's (the search scraper's) is
YouTube being unavailable to us — the retryable answer. `playlist`: the route acts on one
playlist the user picked, so a missing playlist is THAT one, gone (`refusal_reason`)."""
reason = (
refusal_reason(exc, playlist=playlist) if isinstance(exc, YouTubeError) else "unavailable"
)
return HTTPException(status_code=422, detail={"code": "youtube", "reason": reason})
async def capped_body(request: Request, limit: int) -> bytes:
"""The request body, refused (413 `too_large`) the moment it passes `limit` bytes while still
arriving. Counted, not taken from Content-Length: a declared length can be absent (chunked) or
wrong, and counting stops reading at the same point a declared-length check would have."""
body = bytearray()
async for chunk in request.stream():
body += chunk
if len(body) > limit:
raise HTTPException(status_code=413, detail={"code": "too_large"})
return bytes(body)
async def capped_stream_to(
request: Request, out: IO[bytes], limit: int, refusal: HTTPException
) -> int:
"""`capped_body`'s sibling for a body too large to hold: the same counting rule, written
straight to `out` instead of accumulated, returning the size.
Two deliberate differences from `capped_body`. It never builds a `bytearray` — the upload route
admits 500 MiB by default, so holding the body would trade a disk problem for a memory one. And
the refusal is the CALLER'S, passed in rather than assumed: `capped_body`'s flat 413 is right
for a screenshot, while the upload route answers 422 `upload_too_large` carrying the admin
knob's own number, which is what its dialog shows the user. One counting rule, two answers,
neither of them a second copy of the loop.
The caller owns `out` and owns cleaning it up: a body refused half-way has already written what
arrived before the limit was passed.
**The refusal DRAINS the rest of the body before it is raised, and that is not politeness.**
Answering mid-upload and closing the connection is what the client sees as a network failure,
not as a refusal: the browser's XHR fires `onerror` before it can read the response, so a file
over the limit reported itself as "the server couldn't be reached" instead of naming the limit —
measured in a real browser, not reasoned about. Reading the remainder to nowhere lets the client
finish sending, read the 422 and say the true thing.
The bytes are discarded, never written, and the whole request is already bounded by
`bodylimit`'s per-route ceiling — so the worst case is bandwidth an authenticated caller was
sending anyway, against the old behaviour which wrote exactly those bytes to disk."""
size = 0
over = False
async for chunk in request.stream():
size += len(chunk)
if over:
continue # draining — see the docstring; counted, not stored
if size > limit:
over = True
continue
out.write(chunk)
if over:
raise refusal
return size
def issue_refused(exc: gitea.GiteaError) -> HTTPException:
"""The refusal an issue-tracker failure (E16) answers as — the send, the preview and the
admin's connection test alike. Literal reasons, one per case: the frontend's contract test reads
them to require a sentence for each in both languages."""
match exc.reason:
case "not_configured":
return refused(400, "issues_not_configured")
case "unreachable":
return refused(422, "issues_unreachable")
case "bad_token":
return refused(422, "issues_bad_token")
case "repo_not_found":
return refused(422, "issues_repo_not_found")
case "public_repo":
return refused(422, "issues_public_repo")
case "issues_disabled":
return refused(422, "issues_disabled")
case _:
return refused(422, "issues_rejected")
+34 -26
View File
@@ -39,6 +39,7 @@ from app.routes.auth import (
send_verification_link, send_verification_link,
) )
from app.utils import canonical_email, iso, valid_email from app.utils import canonical_email, iso, valid_email
from app.refusal import refused
router = APIRouter(prefix="/api/admin", tags=["admin"]) router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -169,7 +170,7 @@ def _activate_user_by_email(db: Session, email: str) -> None:
user = next((u for u in candidates if u.email == email), None) or next(iter(candidates), None) user = next((u for u in candidates if u.email == email), None) or next(iter(candidates), None)
if user is not None and not user.is_active: if user is not None and not user.is_active:
user.is_active = True user.is_active = True
db.commit() db.flush() # the handler commits (CLAUDE.md: helpers flush)
def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite: def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
@@ -179,7 +180,7 @@ def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
# sending a duplicate decision mail, even flipping an already-approved invite to "denied" and # sending a duplicate decision mail, even flipping an already-approved invite to "denied" and
# mailing a rejection to an already-active account. # mailing a rejection to an already-active account.
if inv.status != "pending": if inv.status != "pending":
raise HTTPException(status_code=409, detail="Invite already decided") raise refused(409, "invite_decided")
inv.status = "approved" if approved else "denied" inv.status = "approved" if approved else "denied"
inv.decided_at = datetime.now(timezone.utc) inv.decided_at = datetime.now(timezone.utc)
inv.decided_by = admin.email inv.decided_by = admin.email
@@ -191,7 +192,7 @@ def _decide(db: Session, invite_id: int, admin: User, approved: bool) -> Invite:
target_id=inv.id, target_id=inv.id,
summary=inv.email, summary=inv.email,
) )
db.commit() db.flush() # the handler commits, with the activation that goes with it
return inv return inv
@@ -210,6 +211,9 @@ def approve_invite(
link, has_account, hours = _welcome_mail_parts(db, inv.email) link, has_account, hours = _welcome_mail_parts(db, inv.email)
lang = mailtext.lang_for_email(db, inv.email) lang = mailtext.lang_for_email(db, inv.email)
background.add_task(email_mod.send_access_approved, inv.email, lang, link, has_account, hours) background.add_task(email_mod.send_access_approved, inv.email, lang, link, has_account, hours)
# Before the response, so the mail (a background task, after it) never describes a decision
# the database does not hold yet.
db.commit()
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email]))) return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
@@ -226,6 +230,7 @@ def deny_invite(
background.add_task( background.add_task(
email_mod.send_access_denied, inv.email, mailtext.lang_for_email(db, inv.email) email_mod.send_access_denied, inv.email, mailtext.lang_for_email(db, inv.email)
) )
db.commit()
return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email]))) return _serialize(inv, has_account=bool(_accounts_for(db, [inv.email])))
@@ -242,7 +247,7 @@ def add_invite(
"""Manually whitelist an email (approved straight away). Convenience for the admin.""" """Manually whitelist an email (approved straight away). Convenience for the admin."""
email = body.email.strip().lower() email = body.email.strip().lower()
if not valid_email(email): if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.") raise refused(400, "invalid_email")
# Reuse a canonically-equal row instead of adding a second one for the same mailbox. Prod carries # Reuse a canonically-equal row instead of adding a second one for the same mailbox. Prod carries
# exactly that pair for one person from 2026-06-16 (hartmann.peter1986@ / hartmannpeter1986@), # exactly that pair for one person from 2026-06-16 (hartmann.peter1986@ / hartmannpeter1986@),
# which is how an admin ends up approving the same human twice and still seeing them bounce. # which is how an admin ends up approving the same human twice and still seeing them bounce.
@@ -259,11 +264,13 @@ def add_invite(
inv.status = "approved" inv.status = "approved"
inv.decided_at = datetime.now(timezone.utc) inv.decided_at = datetime.now(timezone.utc)
inv.decided_by = admin.email inv.decided_by = admin.email
db.flush() # a new invite's id, for the audit row — before it, the row named no target
audit.record( audit.record(
db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email db, admin.id, AuditAction.INVITE_ADD, target_type="invite", target_id=inv.id, summary=email
) )
db.commit() # One transaction: the whitelisting and the activation it implies land together or not at all.
_activate_user_by_email(db, email) _activate_user_by_email(db, email)
db.commit()
return _serialize(inv, has_account=bool(_accounts_for(db, [email]))) return _serialize(inv, has_account=bool(_accounts_for(db, [email])))
@@ -366,10 +373,7 @@ def delete_invite(
promoted to admin on its next Google sign-in). The admin has to hear that, not guess it.""" promoted to admin on its next Google sign-in). The admin has to hear that, not guess it."""
inv = get_or_404(db, Invite, invite_id, what="invite") inv = get_or_404(db, Invite, invite_id, what="invite")
if _accounts_for(db, [inv.email]): if _accounts_for(db, [inv.email]):
raise HTTPException( raise refused(409, "address_has_account")
status_code=409,
detail="That address already has an account — delete the account instead.",
)
still_allowed_by_env = _env_allowed(inv.email) still_allowed_by_env = _env_allowed(inv.email)
audit.record( audit.record(
db, db,
@@ -408,6 +412,8 @@ def _serialize_user(u: User) -> dict:
# confusing sign-in. # confusing sign-in.
"google_email": u.google_email if u.google_email != u.email else None, "google_email": u.google_email if u.google_email != u.email else None,
"created_at": iso(u.created_at), "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),
} }
@@ -433,19 +439,19 @@ def set_user_role(
account, and can't remove the last remaining admin. Emails the user when it actually changes.""" account, and can't remove the last remaining admin. Emails the user when it actually changes."""
role = body.role role = body.role
if role not in ("user", "admin"): if role not in ("user", "admin"):
raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'") raise refused(400, "invalid_role")
target = get_or_404(db, User, user_id, what="user") target = get_or_404(db, User, user_id, what="user")
if target.is_demo: if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account's role can't be changed.") raise refused(400, "demo_role")
if target.id == admin.id: if target.id == admin.id:
raise HTTPException(status_code=400, detail="You can't change your own role.") raise refused(400, "own_role")
if ( if (
target.role == "admin" target.role == "admin"
and role == "user" and role == "user"
and not target.is_suspended and not target.is_suspended
and count_admins(db, active_only=True) <= 1 and count_admins(db, active_only=True) <= 1
): ):
raise HTTPException(status_code=400, detail="Can't remove the last admin.") raise refused(400, "last_admin")
changed = target.role != role changed = target.role != role
if changed: if changed:
audit.record( audit.record(
@@ -491,16 +497,16 @@ def set_user_suspended(
suspended = body.suspended suspended = body.suspended
target = get_or_404(db, User, user_id, what="user") target = get_or_404(db, User, user_id, what="user")
if target.is_demo: if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account can't be suspended.") raise refused(400, "demo_suspend")
if target.id == admin.id: if target.id == admin.id:
raise HTTPException(status_code=400, detail="You can't suspend your own account.") raise refused(400, "own_suspend")
if ( if (
suspended suspended
and target.role == "admin" and target.role == "admin"
and not target.is_suspended and not target.is_suspended
and count_admins(db, active_only=True) <= 1 and count_admins(db, active_only=True) <= 1
): ):
raise HTTPException(status_code=400, detail="Can't suspend the last active admin.") raise refused(400, "last_active_admin")
was_suspended = target.is_suspended was_suspended = target.is_suspended
if was_suspended != suspended: if was_suspended != suspended:
audit.record( audit.record(
@@ -535,17 +541,15 @@ def admin_delete_user(
not the demo account, not yourself (use Settings → Account), and not the last admin.""" not the demo account, not yourself (use Settings → Account), and not the last admin."""
target = get_or_404(db, User, user_id, what="user") target = get_or_404(db, User, user_id, what="user")
if target.is_demo: if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account can't be deleted.") raise refused(400, "demo_delete")
if target.id == admin.id: if target.id == admin.id:
raise HTTPException( raise refused(400, "own_delete")
status_code=400, detail="Delete your own account from Settings → Account."
)
if ( if (
target.role == "admin" target.role == "admin"
and not target.is_suspended and not target.is_suspended
and count_admins(db, active_only=True) <= 1 and count_admins(db, active_only=True) <= 1
): ):
raise HTTPException(status_code=400, detail="Can't delete the last admin.") raise refused(400, "last_admin")
# Record BEFORE the purge — the target row (and its email) is gone afterwards. # Record BEFORE the purge — the target row (and its email) is gone afterwards.
audit.record( audit.record(
db, db,
@@ -689,7 +693,7 @@ def activate_user(
`_activate_user_by_email`). What was missing was any way to know they had to.""" `_activate_user_by_email`). What was missing was any way to know they had to."""
target = get_or_404(db, User, user_id, what="user") target = get_or_404(db, User, user_id, what="user")
if target.is_demo: if target.is_demo:
raise HTTPException(status_code=400, detail="The demo account can't be activated.") raise refused(400, "demo_activate")
if target.is_active: if target.is_active:
return _serialize_user(target) # idempotent: a double click is not an error return _serialize_user(target) # idempotent: a double click is not an error
if not is_allowed(db, target.email): if not is_allowed(db, target.email):
@@ -786,7 +790,7 @@ def add_demo_whitelist(
sign-in). Idempotent — re-adding an existing email just returns it.""" sign-in). Idempotent — re-adding an existing email just returns it."""
email = body.email.strip().lower() email = body.email.strip().lower()
if not valid_email(email): if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.") raise refused(400, "invalid_email")
row = db.execute(select(DemoWhitelist).where(DemoWhitelist.email == email)).scalar_one_or_none() row = db.execute(select(DemoWhitelist).where(DemoWhitelist.email == email)).scalar_one_or_none()
if row is None: if row is None:
row = DemoWhitelist( row = DemoWhitelist(
@@ -830,10 +834,14 @@ def reset_demo(admin: User = Depends(admin_user), db: Session = Depends(get_db))
demo = get_or_create_demo_user(db) demo = get_or_create_demo_user(db)
seeded = reset_demo_state(db, demo) seeded = reset_demo_state(db, demo)
audit.record( audit.record(
db, admin.id, AuditAction.DEMO_RESET, summary=f"{seeded} sample playlists re-seeded" db,
admin.id,
AuditAction.DEMO_RESET,
summary=f"{seeded} sample playlists re-seeded",
after={"playlists_seeded": seeded}, # the count as data: named in the reader's language
) )
db.commit() db.commit()
return {"reset": True, "playlists_seeded": seeded} return {"ok": True, "reset": True, "playlists_seeded": seeded}
@router.post("/purge-discovery") @router.post("/purge-discovery")
@@ -852,4 +860,4 @@ def purge_discovery(admin: User = Depends(admin_user), db: Session = Depends(get
after=result, after=result,
) )
db.commit() db.commit()
return result return {"ok": True, **result}
+216
View File
@@ -0,0 +1,216 @@
"""The admin diagnostics console's read side (E22.6): query `log_events`, the server's stored log.
Admin-only and read-only — rows leave the table through the retention job (`log_retention_days`),
never through here. Reading is not audited: it changes nothing, and the console's live tail polls,
so a row per read would bury the audit log's real entries.
What a row holds was redacted when it was written (`logs.record_fields`, and the ingest route for a
browser's rows), so this serves the columns as they are; a value the redactor took out reads as
`[redacted]` / `[email]`, which is the point — the console shows that something was there.
Paging is a keyset over `(ts desc, id desc)`: a browser row's `ts` is clamped into its consent
window, so `ts` alone is not unique, and an offset would skip or repeat rows while the writer adds
new ones at the top. The live tail re-reads the first page rather than asking for "ids after N":
several processes write this table, and a batch's ids are taken when it inserts, not when it
commits, so a slower batch can land below an id the tail already passed — and would never show.
"""
import base64
import binascii
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import Text, and_, cast, func, or_, select
from sqlalchemy.orm import Session
from app import logsink
from app.auth import admin_user
from app.db import get_db
from app.models import LogEvent, User
from app.utils import like_escape
router = APIRouter(prefix="/api/admin/logs", tags=["admin"])
PAGE_MAX = 200
# A facet list is a chip row, not a report: past this many distinct events the rest are reachable
# through the free-text search, and the response stays small whatever a self-host logs.
FACET_EVENTS_MAX = 100
Level = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
# The filters a facet can leave out of its own count (`LogFilters.conditions`).
Facet = Literal["levels", "sources", "user", "event"]
# `none` selects the rows that have no account / no event code (a scheduler line, an anonymous
# request) — a real value to filter on, not the absence of a filter.
NONE = "none"
def _utc(value: datetime | None) -> datetime | None:
"""A bound without a zone is UTC — the column is `timestamptz`, and a naive value would be read
in the database session's zone instead."""
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=timezone.utc)
def _encode_cursor(row: LogEvent) -> str:
return base64.urlsafe_b64encode(json.dumps([row.ts.isoformat(), row.id]).encode()).decode()
def _decode_cursor(cursor: str) -> tuple[datetime, int]:
try:
ts, row_id = json.loads(base64.urlsafe_b64decode(cursor.encode()))
return _utc(datetime.fromisoformat(ts)), int(row_id)
except (binascii.Error, json.JSONDecodeError, ValueError, TypeError) as exc:
raise HTTPException(status_code=400, detail={"code": "bad_cursor"}) from exc
def _accounts(db: Session, user_ids: set[int]) -> dict[str, str]:
"""The accounts a page names, by id → address. An id with no account left (erased after the
line was written) is simply absent, and the client shows the bare id."""
if not user_ids:
return {}
rows = db.execute(select(User.id, User.email).where(User.id.in_(user_ids))).all()
return {str(uid): email for uid, email in rows}
@dataclass(frozen=True)
class LogFilters:
"""The console's filters, shared by the page and its facet counts."""
levels: list[str]
sources: list[str]
user: str | None
event: str | None
request_id: str | None
job_id: int | None
q: str | None
since: datetime | None
until: datetime | None
def conditions(self, skip: Facet | None = None) -> list:
"""The WHERE conditions, ANDed by the caller. `skip` leaves one filter out: a facet counts
its own values under every OTHER filter, so picking a level does not zero the other
levels' counts (the way back), while a request pivot does narrow them (honest counts)."""
out = []
if self.levels and skip != "levels":
out.append(LogEvent.level.in_(self.levels))
if self.sources and skip != "sources":
out.append(LogEvent.source.in_(self.sources))
if self.user is not None and skip != "user":
out.append(
LogEvent.user_id.is_(None)
if self.user == NONE
else LogEvent.user_id == int(self.user)
)
if self.event is not None and skip != "event":
out.append(
LogEvent.event.is_(None) if self.event == NONE else LogEvent.event == self.event
)
if self.request_id is not None:
out.append(LogEvent.request_id == self.request_id)
if self.job_id is not None:
out.append(LogEvent.job_id == self.job_id)
if self.since is not None:
out.append(LogEvent.ts >= _utc(self.since))
if self.until is not None:
out.append(LogEvent.ts < _utc(self.until))
if self.q and self.q.strip():
like = f"%{like_escape(self.q.strip())}%"
out.append(
or_(
LogEvent.msg.ilike(like),
LogEvent.event.ilike(like),
LogEvent.request_id.ilike(like),
LogEvent.exc.ilike(like),
cast(LogEvent.attrs, Text).ilike(like),
)
)
return out
def log_filters(
levels: list[Level] = Query(default=[]),
sources: list[str] = Query(default=[], max_length=8),
user: str | None = Query(default=None, pattern=r"^(none|[0-9]{1,10})$"),
event: str | None = Query(
default=None, max_length=64, pattern=r"^(none|[a-z][a-z0-9_]*(\.[a-z0-9_]+)*)$"
),
request_id: str | None = Query(default=None, pattern=r"^[A-Za-z0-9._-]{8,64}$"),
job_id: int | None = Query(default=None, ge=1),
q: str | None = Query(default=None, max_length=200),
since: datetime | None = None,
until: datetime | None = None,
) -> LogFilters:
return LogFilters(list(levels), list(sources), user, event, request_id, job_id, q, since, until)
@router.get("")
def list_logs(
filters: LogFilters = Depends(log_filters),
before: str | None = Query(default=None, max_length=200),
limit: int = Query(default=100, ge=1, le=PAGE_MAX),
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""One page of rows, newest first, matching every filter given (they AND together); `before`
continues from a previous page's `next_cursor`."""
query = select(LogEvent).where(*filters.conditions())
if before is not None:
ts, row_id = _decode_cursor(before)
# The `ts <=` conjunct is implied by the OR; it is there so the planner can bound the
# `ts` index scan instead of filtering every newer row first.
query = query.where(
and_(
LogEvent.ts <= ts,
or_(LogEvent.ts < ts, and_(LogEvent.ts == ts, LogEvent.id < row_id)),
)
)
# One more than asked, to know whether there is a next page without a second count query.
rows = (
db.execute(query.order_by(LogEvent.ts.desc(), LogEvent.id.desc()).limit(limit + 1))
.scalars()
.all()
)
more = len(rows) > limit
rows = rows[:limit]
return {
"items": [logsink.serialize(r) for r in rows],
"accounts": _accounts(db, {r.user_id for r in rows if r.user_id is not None}),
"next_cursor": _encode_cursor(rows[-1]) if more else None,
}
@router.get("/facets")
def log_facets(
filters: LogFilters = Depends(log_filters),
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""What the filter chips offer, with counts: the levels, sources and accounts present, and the
most frequent events — each counted under every filter but its own (`LogFilters.conditions`).
`total` is the "all levels" count: every filter but the level one."""
def counts(col, skip: Facet, cap: int | None = None) -> list[dict]:
n = func.count().label("n")
stmt = select(col, n).where(*filters.conditions(skip)).group_by(col).order_by(n.desc(), col)
if cap is not None:
stmt = stmt.limit(cap)
return [
{"value": NONE if value is None else value, "count": count}
for value, count in db.execute(stmt).all()
]
users = counts(LogEvent.user_id, "user")
total = select(func.count()).select_from(LogEvent).where(*filters.conditions("levels"))
return {
"levels": counts(LogEvent.level, "levels"),
"sources": counts(LogEvent.source, "sources"),
"events": counts(LogEvent.event, "event", FACET_EVENTS_MAX),
"users": [{**u, "value": str(u["value"])} for u in users],
"accounts": _accounts(db, {u["value"] for u in users if u["value"] != NONE}),
"total": db.scalar(total) or 0,
}
+8 -2
View File
@@ -66,6 +66,12 @@ def list_audit(
def clear_audit(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: def clear_audit(admin: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
"""Clear the audit log. Leaves ONE row recording who cleared it + how many (tamper-evidence).""" """Clear the audit log. Leaves ONE row recording who cleared it + how many (tamper-evidence)."""
n = db.execute(delete(AuditLog)).rowcount or 0 n = db.execute(delete(AuditLog)).rowcount or 0
audit.record(db, admin.id, AuditAction.AUDIT_CLEAR, summary=f"Cleared {n} audit entries") audit.record(
db,
admin.id,
AuditAction.AUDIT_CLEAR,
summary=f"Cleared {n} audit entries",
after={"cleared": n}, # the count as data: the log names it in the reader's language
)
db.commit() db.commit()
return {"cleared": n} return {"ok": True, "cleared": n}
+70 -48
View File
@@ -17,6 +17,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import email as email_mod from app import email as email_mod
from app import logs
from app import mailtext from app import mailtext
from app import sysconfig from app import sysconfig
from app.auth import ( from app.auth import (
@@ -38,10 +39,11 @@ from app.auth import (
) )
from app.config import settings from app.config import settings
from app.db import SessionLocal, get_db from app.db import SessionLocal, get_db
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User, display_text
from app.ratelimit import RateLimiter, client_key from app.ratelimit import RateLimiter, client_key
from app.security import encrypt, hash_password, hash_token, verify_password from app.security import encrypt, hash_password, hash_token, verify_password
from app.utils import canonical_email, valid_email from app.utils import canonical_email, valid_email
from app.refusal import refused
VERIFY_TTL = timedelta(hours=24) VERIFY_TTL = timedelta(hours=24)
# A confirmation link that rides an APPROVAL or an invitation lives longer than one the person is # A confirmation link that rides an APPROVAL or an invitation lives longer than one the person is
@@ -377,7 +379,7 @@ async def callback(
userinfo = token.get("userinfo") userinfo = token.get("userinfo")
if not userinfo or not userinfo.get("sub"): if not userinfo or not userinfo.get("sub"):
raise HTTPException(status_code=400, detail="No user info returned by Google") raise refused(400, "google_no_userinfo")
sub = userinfo["sub"] sub = userinfo["sub"]
email = (userinfo.get("email") or "").lower() email = (userinfo.get("email") or "").lower()
@@ -386,7 +388,15 @@ async def callback(
return _complete_link(request, db, link_uid, link_explicit, sub, userinfo, token) return _complete_link(request, db, link_uid, link_explicit, sub, userinfo, token)
if not is_allowed(db, email): if not is_allowed(db, email):
log.warning("Login denied (not approved): %s", email or "<no email>") # No uid to name — having no account here is the branch. And the address is left out on
# purpose: `redact_text` turns it into `[email]` before either sink sees it, so a line
# whose only variable is always `[email]` says nothing. WHO asked is in the pending
# invite recorded just below, which is where an admin looks anyway.
log.warning(
"Google sign-in denied: address not approved",
# log-pii-ok: whether an address came back, not what it was
extra=logs.event("auth.login.denied", reason="not_approved", has_email=bool(email)),
)
# A denied Google login doubles as an access request: record it for the admin and # A denied Google login doubles as an access request: record it for the admin and
# land the user on a friendly "request received" screen instead of a raw 403. # land the user on a friendly "request received" screen instead of a raw 403.
if email: if email:
@@ -419,15 +429,20 @@ async def callback(
if existing.google_sub is not None and existing.google_sub != sub: if existing.google_sub is not None and existing.google_sub != sub:
# A different Google identity is already bound to this email — refuse rather than # A different Google identity is already bound to this email — refuse rather than
# hijack it. (Near-impossible with real Google accounts: email↔sub is stable.) # hijack it. (Near-impossible with real Google accounts: email↔sub is stable.)
log.warning("Google login email collision (different sub): %s", email) log.warning(
"Google sign-in refused: a different Google identity holds this address",
extra=logs.event("auth.login.denied", reason="sub_collision", uid=existing.id),
)
return RedirectResponse(url=_access_notice_url("denied")) return RedirectResponse(url=_access_notice_url("denied"))
if not userinfo.get("email_verified", False): if not userinfo.get("email_verified", False):
# Adopting (and activating) a pre-existing password account requires Google to # Adopting (and activating) a pre-existing password account requires Google to
# actually attest the address — otherwise an unverified Google email that merely # actually attest the address — otherwise an unverified Google email that merely
# collides with a pending registration could seize that account. Defense-in-depth. # collides with a pending registration could seize that account. Defense-in-depth.
log.warning( log.warning(
"Google login: unverified email, refusing to adopt account: %s", "Google sign-in refused: unverified address, not adopting the account",
email, extra=logs.event(
"auth.login.denied", reason="email_unverified", uid=existing.id
),
) )
return RedirectResponse(url=_access_notice_url("denied")) return RedirectResponse(url=_access_notice_url("denied"))
user = existing user = existing
@@ -445,7 +460,10 @@ async def callback(
# A suspended account can't sign in by any method. Block before establishing the session and # A suspended account can't sign in by any method. Block before establishing the session and
# tell the (verified) owner why — a successful Google auth proves they control this account. # tell the (verified) owner why — a successful Google auth proves they control this account.
if user.is_suspended: if user.is_suspended:
log.info("Suspended account blocked at Google login: %s", email) log.info(
"Google sign-in blocked: the account is suspended",
extra=logs.event("auth.login.denied", reason="suspended", uid=user.id),
)
_notify_suspended(background, user) _notify_suspended(background, user)
return RedirectResponse(url="/?login=suspended") return RedirectResponse(url="/?login=suspended")
# Record what Google actually answers with, beside the address the user registered with. The two # Record what Google actually answers with, beside the address the user registered with. The two
@@ -454,7 +472,7 @@ async def callback(
if email: if email:
user.google_email = email user.google_email = email
_sync_google_email(db, user, email) _sync_google_email(db, user, email)
user.display_name = userinfo.get("name") user.display_name = display_text(userinfo.get("name"))
user.avatar_url = userinfo.get("picture") user.avatar_url = userinfo.get("picture")
# ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't # ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't
# be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a # be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a
@@ -479,17 +497,12 @@ async def callback(
db.flush() db.flush()
_store_token(db, user, token) _store_token(db, user, token)
_establish_session(request, user)
db.commit() db.commit()
log.info(
# Multi-session: remember every account that has authenticated in this browser so the "signed in",
# user can switch between them without going through Google again. Only accounts that extra=logs.event("auth.login.ok", method="google", uid=user.id, role=user.role),
# 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="/") return RedirectResponse(url="/")
@@ -581,7 +594,7 @@ def _complete_link(
if linked_email: if linked_email:
user.google_email = linked_email # see the sign-in callback: the other half of the identity user.google_email = linked_email # see the sign-in callback: the other half of the identity
if not user.display_name: if not user.display_name:
user.display_name = userinfo.get("name") user.display_name = display_text(userinfo.get("name"))
if not user.avatar_url: if not user.avatar_url:
user.avatar_url = userinfo.get("picture") user.avatar_url = userinfo.get("picture")
_store_token(db, user, token) _store_token(db, user, token)
@@ -641,15 +654,15 @@ async def request_access(
endpoint can't be probed for throttling either.""" endpoint can't be probed for throttling either."""
email = (body.email or "").strip().lower() email = (body.email or "").strip().lower()
if not valid_email(email): if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.") raise refused(400, "invalid_email")
if not _request_access_limiter.allow(client_key(request)): if not _request_access_limiter.allow(client_key(request)):
return {"status": "pending"} return {"ok": True, "status": "pending"}
if is_allowed(db, email): if is_allowed(db, email):
return {"status": "pending"} # already allowed — don't reveal it return {"ok": True, "status": "pending"} # already allowed — don't reveal it
req = upsert_pending_invite(db, email, body.lang) req = upsert_pending_invite(db, email, body.lang)
if req.is_new: if req.is_new:
notify_admins_of_request(db, background, email) notify_admins_of_request(db, background, email)
return {"status": "pending"} return {"ok": True, "status": "pending"}
@router.post("/demo") @router.post("/demo")
@@ -660,7 +673,7 @@ def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -
exactly like a non-match (authenticated=false) so the field can't be hammered as an exactly like a non-match (authenticated=false) so the field can't be hammered as an
enumeration oracle. On a match it sets the session and the client reloads into the app.""" enumeration oracle. On a match it sets the session and the client reloads into the app."""
if not _demo_limiter.allow(client_key(request)): if not _demo_limiter.allow(client_key(request)):
return {"authenticated": False} return {"ok": True, "authenticated": False}
email = (body.email or "").strip().lower() email = (body.email or "").strip().lower()
if valid_email(email) and is_demo_allowed(db, email): if valid_email(email) and is_demo_allowed(db, email):
demo = get_or_create_demo_user(db) demo = get_or_create_demo_user(db)
@@ -669,19 +682,25 @@ def demo_login(body: EmailIn, request: Request, db: Session = Depends(get_db)) -
request.session.clear() request.session.clear()
request.session["user_id"] = demo.id request.session["user_id"] = demo.id
_remember_epoch(request, demo) _remember_epoch(request, demo)
log.info("Demo login (key=%s, demo_id=%s)", email, demo.id) # The demo key IS an address, so it is not in the line — see the denied branch above.
return {"authenticated": True} log.info("signed in", extra=logs.event("auth.login.ok", method="demo", uid=demo.id))
return {"authenticated": False} return {"ok": True, "authenticated": True}
return {"ok": True, "authenticated": False}
def _establish_session(request: Request, user: User) -> None: def _establish_session(request: Request, user: User) -> None:
"""Sign `user` in for this browser session, mirroring the Google callback's multi-account """Sign `user` in for this browser session — the ONE sign-in path, shared by the Google callback
handling so password sign-in joins the same account switcher.""" 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 = [a for a in (request.session.get("account_ids") or []) if a != user.id]
accounts.append(user.id) accounts.append(user.id)
request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:] request.session["account_ids"] = accounts[-MAX_SESSION_ACCOUNTS:]
request.session["user_id"] = user.id request.session["user_id"] = user.id
_remember_epoch(request, user) _remember_epoch(request, user)
user.last_login_at = datetime.now(timezone.utc)
def _app_base() -> str: def _app_base() -> str:
@@ -797,23 +816,20 @@ def register(
password = body.password or "" password = body.password or ""
# Input validation is safe to reveal (independent of whether the email exists). # Input validation is safe to reveal (independent of whether the email exists).
if not valid_email(email): if not valid_email(email):
raise HTTPException(status_code=400, detail="Enter a valid email address.") raise refused(400, "invalid_email")
if len(password) < PASSWORD_MIN_LEN: if len(password) < PASSWORD_MIN_LEN:
raise HTTPException( raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
status_code=400,
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
)
if not sysconfig.get_bool(db, "allow_registration"): if not sysconfig.get_bool(db, "allow_registration"):
raise HTTPException(status_code=403, detail="Registration is currently closed.") raise refused(403, "registration_closed")
if not _register_limiter.allow(client_key(request)): if not _register_limiter.allow(client_key(request)):
return {"status": "ok"} # silently throttle; uniform response return {"ok": True, "status": "ok"} # silently throttle; uniform response
# Do the existence check + account creation OFF the response path (a background task with its own # Do the existence check + account creation OFF the response path (a background task with its own
# session). The create path hashes the password + writes several rows + schedules emails; doing it # session). The create path hashes the password + writes several rows + schedules emails; doing it
# inline would make a NEW email respond measurably slower than an already-registered one (which # inline would make a NEW email respond measurably slower than an already-registered one (which
# skips all that) — a timing oracle for enumeration. Off-path, any valid email responds the same. # skips all that) — a timing oracle for enumeration. Off-path, any valid email responds the same.
background.add_task(_register_account, email, password, body.lang) background.add_task(_register_account, email, password, body.lang)
return {"status": "ok"} return {"ok": True, "status": "ok"}
def _register_account(email: str, password: str, lang: str | None = None) -> None: def _register_account(email: str, password: str, lang: str | None = None) -> None:
@@ -883,7 +899,12 @@ def _register_account(email: str, password: str, lang: str | None = None) -> Non
except IntegrityError: except IntegrityError:
# A concurrent registration for the same new email won the race between the pre-check # A concurrent registration for the same new email won the race between the pre-check
# above and this flush (the unique-email constraint fires). Same already-registered # above and this flush (the unique-email constraint fires). Same already-registered
# no-op as the pre-check hit — roll back and stop. # no-op as the pre-check hit — roll back and stop. The line carries nothing: the
# answer to the caller is deliberately identical either way, and so is this.
log.info(
"a registration lost the race for its address",
extra=logs.event("auth.register.race_lost"),
)
db.rollback() db.rollback()
return return
upsert_pending_invite(db, email) # admin-approval gate upsert_pending_invite(db, email) # admin-approval gate
@@ -974,11 +995,11 @@ def verify_email_resend(
mail had no way back. Uniform response + timing regardless of whether the email has a pending mail had no way back. Uniform response + timing regardless of whether the email has a pending
account (the lookup runs off the response path), so it can't probe for registered emails.""" account (the lookup runs off the response path), so it can't probe for registered emails."""
if not _verify_resend_limiter.allow(client_key(request)): if not _verify_resend_limiter.allow(client_key(request)):
return {"status": "ok"} # silently throttle; uniform response return {"ok": True, "status": "ok"} # silently throttle; uniform response
email = (body.email or "").strip().lower() email = (body.email or "").strip().lower()
if valid_email(email): if valid_email(email):
background.add_task(_resend_verify_if_eligible, email) background.add_task(_resend_verify_if_eligible, email)
return {"status": "ok"} return {"ok": True, "status": "ok"}
def _resend_verify_if_eligible(email: str) -> None: def _resend_verify_if_eligible(email: str) -> None:
@@ -1051,7 +1072,11 @@ def password_login(
if not user.is_active: if not user.is_active:
raise _login_refused(403, "pending_approval", user) raise _login_refused(403, "pending_approval", user)
_establish_session(request, user) _establish_session(request, user)
log.info("Password login: %s (id=%s, role=%s)", email, user.id, user.role) db.commit()
log.info(
"signed in",
extra=logs.event("auth.login.ok", method="password", uid=user.id, role=user.role),
)
return {"ok": True} return {"ok": True}
@@ -1064,14 +1089,14 @@ def password_reset_request(
"""Request a password-reset link. Uniform response + timing regardless of whether the email has a """Request a password-reset link. Uniform response + timing regardless of whether the email has a
password account (the lookup runs off the response path), so it can't probe for registered emails.""" password account (the lookup runs off the response path), so it can't probe for registered emails."""
if not _reset_limiter.allow(client_key(request)): if not _reset_limiter.allow(client_key(request)):
return {"status": "ok"} return {"ok": True, "status": "ok"}
email = (body.email or "").strip().lower() email = (body.email or "").strip().lower()
# Do the account lookup + token issue + email OFF the response path (a background task with its # Do the account lookup + token issue + email OFF the response path (a background task with its
# own session), so the endpoint takes the same time for any valid email whether or not it has a # own session), so the endpoint takes the same time for any valid email whether or not it has a
# password account — no timing-based enumeration. Any valid email schedules the same task. # password account — no timing-based enumeration. Any valid email schedules the same task.
if valid_email(email): if valid_email(email):
background.add_task(_send_reset_if_eligible, email) background.add_task(_send_reset_if_eligible, email)
return {"status": "ok"} return {"ok": True, "status": "ok"}
def _send_reset_if_eligible(email: str) -> None: def _send_reset_if_eligible(email: str) -> None:
@@ -1266,13 +1291,10 @@ def set_password(
raise HTTPException(status_code=403, detail={"code": "demo"}) raise HTTPException(status_code=403, detail={"code": "demo"})
new_password = body.password or "" new_password = body.password or ""
if len(new_password) < PASSWORD_MIN_LEN: if len(new_password) < PASSWORD_MIN_LEN:
raise HTTPException( raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
status_code=400,
detail=f"Password must be at least {PASSWORD_MIN_LEN} characters.",
)
if user.password_hash: if user.password_hash:
if not verify_password(body.current_password or "", user.password_hash): if not verify_password(body.current_password or "", user.password_hash):
raise HTTPException(status_code=403, detail="Current password is incorrect.") raise refused(403, "wrong_password")
user.password_hash = hash_password(new_password) user.password_hash = hash_password(new_password)
user.password_revoked_at = None # same as the reset path: a password exists again user.password_revoked_at = None # same as the reset path: a password exists again
# SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so # SA4: a password change logs out every OTHER session; re-stamp this cookie at the new epoch so
+104 -67
View File
@@ -2,15 +2,16 @@
hidden, tags) and per-channel sync state. Local reads/writes plus YouTube-side writes hidden, tags) and per-channel sync state. Local reads/writes plus YouTube-side writes
(subscribe / unsubscribe) that require the optional write scope.""" (subscribe / unsubscribe) that require the optional write scope."""
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException from fastapi import APIRouter, BackgroundTasks, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func, select, update from sqlalchemy import Select, func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import quota from app.collation import collated, sorted_words
from app.mailtext import lang_for_user
from app import logs, quota
from app.auth import admin_user, current_user, require_human, require_write_scope from app.auth import admin_user, current_user, require_human, require_write_scope
from app.db import get_db from app.db import get_db
from app.models import ( from app.models import (
@@ -26,16 +27,33 @@ from app.models import (
User, User,
Video, Video,
) )
from app.routes._common import get_or_404, not_found, owned_or_404 from app.routes._common import get_or_404, not_found, owned_or_404, youtube_refusal
from app.sync.explore import explore_ingest_page from app.sync.explore import explore_ingest_page
from app.sync.runner import backfill_channel_now, run_recent_backfill from app.sync.runner import backfill_channel_now, run_recent_backfill
from app.sync.subscriptions import apply_channel_details from app.sync.subscriptions import apply_channel_details, details_stale
from app.utils import iso from app.utils import iso
from app.youtube.client import YouTubeClient, YouTubeError from app.youtube.client import YouTubeClient, YouTubeError
from app.refusal import refused
router = APIRouter(prefix="/api/channels", tags=["channels"]) router = APIRouter(prefix="/api/channels", tags=["channels"])
log = logging.getLogger("siftlode.api") log = logs.get_logger(__name__)
def channel_aggregates(channel_ids: list[str]) -> Select:
"""Per-channel stored count, last upload and total duration, in one grouped pass. Every column it
reads is in `ix_videos_channel_pub_cover` (migration 0095), so Postgres answers it from the index
alone; a test holds that (test_channel_list_index_only)."""
return (
select(
Video.channel_id,
func.count(Video.id),
func.max(Video.published_at),
func.coalesce(func.sum(Video.duration_seconds), 0),
)
.where(Video.channel_id.in_(channel_ids))
.group_by(Video.channel_id)
)
@router.get("") @router.get("")
@@ -44,27 +62,18 @@ def list_channels(user: User = Depends(current_user), db: Session = Depends(get_
select(Subscription, Channel) select(Subscription, Channel)
.join(Channel, Channel.id == Subscription.channel_id) .join(Channel, Channel.id == Subscription.channel_id)
.where(Subscription.user_id == user.id) .where(Subscription.user_id == user.id)
.order_by(Subscription.priority.desc(), func.lower(Channel.title)) .order_by(Subscription.priority.desc(), collated(Channel.title, lang_for_user(user)))
).all() ).all()
# Per-channel aggregates over the shared catalog in ONE grouped pass: stored count, last # Per-channel aggregates over the shared catalog (channel_aggregates), computed on read rather than
# upload, total duration. Cheap — a single grouped scan, so computed on read rather than # denormalised: an Index Only Scan over a ~20 MB index, where it used to read every video's heap page
# denormalised (measure-first: reads aren't frequent/hot enough to warrant cached columns). # (681 MB on prod, a 2–6 s first open once something else had pushed it out of memory, E31 S4d).
# (The Normal/Shorts/Live breakdown moved to the channel-detail page in R14, so it is no longer # (The Normal/Shorts/Live breakdown moved to the channel-detail page in R14, so it is no longer
# computed here — the manager table dropped its N/S/L column.) # computed here — the manager table dropped its N/S/L column.)
channel_ids = [c.id for _, c in rows] channel_ids = [c.id for _, c in rows]
agg: dict[str, dict] = {} agg: dict[str, dict] = {}
if channel_ids: if channel_ids:
for cid, n, last_up, total_dur in db.execute( for cid, n, last_up, total_dur in db.execute(channel_aggregates(channel_ids)).all():
select(
Video.channel_id,
func.count(Video.id),
func.max(Video.published_at),
func.coalesce(func.sum(Video.duration_seconds), 0),
)
.where(Video.channel_id.in_(channel_ids))
.group_by(Video.channel_id)
).all():
agg[cid] = { agg[cid] = {
"stored": n, "stored": n,
"last_video_at": iso(last_up), "last_video_at": iso(last_up),
@@ -139,7 +148,7 @@ def _discovery_rows(db: Session, user: User) -> list:
return db.execute( return db.execute(
q.group_by(Channel.id).order_by( q.group_by(Channel.id).order_by(
func.count(func.distinct(PlaylistItem.video_id)).desc(), func.count(func.distinct(PlaylistItem.video_id)).desc(),
func.lower(Channel.title), collated(Channel.title, lang_for_user(user)),
) )
).all() ).all()
@@ -166,7 +175,15 @@ def discover_channels(
user.yt_channel_id = own user.yt_channel_id = own
db.commit() db.commit()
except YouTubeError as exc: except YouTubeError as exc:
log.warning("discovery: own-channel resolve failed (user %s): %s", user.id, exc) # No traceback anywhere in this file's YouTube handlers: for an upstream answer
# `_upstream_failure` already logged the structured `youtube.api.error`, and the
# message names the call, its status and its reason (or, with no answer at all, the
# transport failure). The stack under it is this `except` and the client call.
log.warning(
"discovery: own-channel resolve failed: %s",
exc,
extra=logs.event("channels.discovery.own_channel_failed", reason=exc.reason),
)
rows = _discovery_rows(db, user) rows = _discovery_rows(db, user)
@@ -182,15 +199,23 @@ def discover_channels(
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
YouTubeClient(db, user) as yt, YouTubeClient(db, user) as yt,
): ):
apply_channel_details(db, yt.get_channels(need)) apply_channel_details(db, yt.get_channels(need), need)
db.commit() db.commit()
# Match _discovery_rows' ORDER BY exactly: video-count DESC, then title (NULLs LAST, # Match _discovery_rows' ORDER BY exactly: video-count DESC, then title in the reader's
# as Postgres orders them — coercing NULL→"" would wrongly float untitled channels up). # alphabet (NULLs LAST, as Postgres orders them — coercing NULL→"" would wrongly float
# untitled channels up).
titles = [r[0].title for r in rows if r[0].title is not None]
alpha = {w: i for i, w in enumerate(sorted_words(db, titles, lang_for_user(user)))}
rows = sorted( rows = sorted(
rows, key=lambda r: (-int(r[1]), r[0].title is None, (r[0].title or "").lower()) rows,
key=lambda r: (-int(r[1]), r[0].title is None, alpha.get(r[0].title or "", 0)),
) )
except YouTubeError as exc: except YouTubeError as exc:
log.warning("discovery: channel enrich failed (user %s): %s", user.id, exc) log.warning(
"discovery: channel enrich failed: %s",
exc,
extra=logs.event("channels.discovery.enrich_failed", reason=exc.reason),
)
db.rollback() db.rollback()
return [ return [
@@ -284,21 +309,30 @@ def channel_detail(
"""One channel's "About" detail for the channel page header/About tab, plus this user's """One channel's "About" detail for the channel page header/About tab, plus this user's
relationship to it (subscribed / explored) and how many of its videos are in the catalog. relationship to it (subscribed / explored) and how many of its videos are in the catalog.
Lazily fills the About metadata (view count, join date, banner, links) the first time it's Lazily fills the About metadata (view count, join date, banner, links) the first time it's
needed — a stub, or a channel last enriched before those fields existed (published_at is a needed (a stub) and refreshes it once it is stale (`DETAILS_MAX_AGE`), which is how a channel nobody subscribes to (an explored one, outside the
reliable sentinel: every real channel has a creation date). The enrich uses the API key subscription import) gets a banner that still resolves. The enrich uses the API key (1 unit,
(1 unit, cached after), so it's skipped for the quota-less demo account.""" cached after), so it's skipped for the quota-less demo account."""
channel = get_or_404(db, Channel, channel_id, what="channel") channel = get_or_404(db, Channel, channel_id, what="channel")
if channel.published_at is None and not user.is_demo: # No `published_at is None` sentinel: a terminated channel never gets one, so it re-fetched on
# every view; rows enriched before that field existed are long past DETAILS_MAX_AGE anyway.
stale = details_stale(channel, datetime.now(timezone.utc))
if stale and not user.is_demo:
try: try:
with ( with (
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER), quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
YouTubeClient(db, user) as yt, YouTubeClient(db, user) as yt,
): ):
apply_channel_details(db, yt.get_channels([channel_id])) apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
db.commit() db.commit()
channel = db.get(Channel, channel_id) channel = db.get(Channel, channel_id)
except YouTubeError as exc: except YouTubeError as exc:
log.warning("channel detail enrich failed (%s): %s", channel_id, exc) log.warning(
"channel detail enrich failed: %s",
exc,
extra=logs.event(
"channels.detail.enrich_failed", channel_id=channel_id, reason=exc.reason
),
)
db.rollback() db.rollback()
sub = db.execute( sub = db.execute(
select(Subscription).where( select(Subscription).where(
@@ -366,12 +400,9 @@ def explore_channel(
backfill quota reserve; blocked for the demo account (it spends shared quota).""" backfill quota reserve; blocked for the demo account (it spends shared quota)."""
channel = get_or_404(db, Channel, channel_id, what="channel") channel = get_or_404(db, Channel, channel_id, what="channel")
if _is_blocked(db, user, channel_id): if _is_blocked(db, user, channel_id):
raise HTTPException(status_code=403, detail="You've blocked this channel.") raise refused(403, "channel_blocked")
if quota.backfill_reserve_reached(): if quota.backfill_reserve_reached():
raise HTTPException( raise refused(429, "quota_reserve")
status_code=429,
detail="The shared daily quota reserve is reached — try exploring later.",
)
# Record/refresh the exploration (DB only) before ingesting, so the explorer can see the # Record/refresh the exploration (DB only) before ingesting, so the explorer can see the
# videos as soon as they land. Flag the channel ephemeral unless it's organically followed. # videos as soon as they land. Flag the channel ephemeral unless it's organically followed.
@@ -397,18 +428,17 @@ def explore_channel(
YouTubeClient(db, user) as yt, YouTubeClient(db, user) as yt,
): ):
# Enrich for the About panel + the uploads playlist id (needed to ingest videos). # Enrich for the About panel + the uploads playlist id (needed to ingest videos).
if ( # Stale, not "a field is missing": a terminated channel never gets an uploads playlist
channel.details_synced_at is None # or a creation date, and asking again on every explore would spend quota on a miss.
or channel.uploads_playlist_id is None if details_stale(channel, datetime.now(timezone.utc)):
or channel.published_at is None apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
):
apply_channel_details(db, yt.get_channels([channel_id]))
db.commit() db.commit()
channel = db.get(Channel, channel_id) channel = db.get(Channel, channel_id)
result = explore_ingest_page(db, yt, channel, page_token) result = explore_ingest_page(db, yt, channel, page_token)
except YouTubeError as exc: except YouTubeError as exc:
raise HTTPException(status_code=422, detail=f"YouTube couldn't load this channel: {exc}") raise youtube_refusal(exc) from exc
return { return {
"ok": True,
"channel_id": channel_id, "channel_id": channel_id,
"ingested": result["ingested"], "ingested": result["ingested"],
"next_page_token": result["next_page_token"], "next_page_token": result["next_page_token"],
@@ -490,6 +520,7 @@ def update_channel(
background.add_task(backfill_channel_now, channel_id, user.id) background.add_task(backfill_channel_now, channel_id, user.id)
return { return {
"ok": True,
"id": channel_id, "id": channel_id,
"priority": sub.priority, "priority": sub.priority,
"hidden": sub.hidden, "hidden": sub.hidden,
@@ -524,7 +555,7 @@ def reset_backfill(
db.commit() db.commit()
with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT): with quota.attribute(user.id, quota.QuotaAction.VIDEOS_BACKFILL_RECENT):
run_recent_backfill(db, [channel], max_channels=1) run_recent_backfill(db, [channel], max_channels=1)
return {"id": channel_id, "reset": True} return {"ok": True, "id": channel_id, "reset": True}
@router.post("/{channel_id}/subscribe") @router.post("/{channel_id}/subscribe")
@@ -545,7 +576,7 @@ def subscribe(
) )
).scalar_one_or_none() ).scalar_one_or_none()
if existing is not None: if existing is not None:
raise HTTPException(status_code=409, detail="Already subscribed to this channel.") raise refused(409, "already_subscribed")
with ( with (
quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE), quota.attribute(user.id, quota.QuotaAction.CHANNELS_SUBSCRIBE),
YouTubeClient(db, user) as yt, YouTubeClient(db, user) as yt,
@@ -559,22 +590,31 @@ def subscribe(
# subscription resync fills in the resource id. Any other YouTube error is a real # subscription resync fills in the resource id. Any other YouTube error is a real
# problem — surface it with a clear message (422, not a 502 that the client would # problem — surface it with a clear message (422, not a 502 that the client would
# mistake for a transient "connection lost"). # mistake for a transient "connection lost").
msg = str(exc).lower() # On the reason code, never on the prose: the error no longer carries the body (it is
if "already exists" in msg or "duplicate" in msg: # the request quoted back), and a message YouTube rewords would silently turn "already
# following" back into a 422 the user cannot act on. `reason` is comma-joined when the
# body listed several.
if "subscriptionDuplicate" in exc.reason.split(","):
yt_sub_id = "" yt_sub_id = ""
else: else:
raise HTTPException( raise youtube_refusal(exc) from exc
status_code=422, detail=f"YouTube couldn't subscribe you: {exc}"
)
# Enrich a stub channel (title/thumbnail/subscriber count) so it shows up properly right # Enrich a stub channel (title/thumbnail/subscriber count) so it shows up properly right
# away, on BOTH the normal and the desync path — a resurfaced stub still needs metadata to # away, on BOTH the normal and the desync path — a resurfaced stub still needs metadata to
# render. Keep the client open (we're still inside the `with`); a metadata failure here # render. Keep the client open (we're still inside the `with`); a metadata failure here
# must not block recording the subscription the user asked for. # must not block recording the subscription the user asked for.
if channel.details_synced_at is None: if channel.details_synced_at is None:
try: try:
apply_channel_details(db, yt.get_channels([channel_id])) apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
except YouTubeError as exc: except YouTubeError as exc:
log.warning("subscribe: stub enrich failed (%s): %s", channel_id, exc) log.warning(
"subscribe: stub enrich failed: %s",
exc,
extra=logs.event(
"channels.subscribe.enrich_failed",
channel_id=channel_id,
reason=exc.reason,
),
)
db.add( db.add(
Subscription( Subscription(
user_id=user.id, user_id=user.id,
@@ -594,7 +634,7 @@ def subscribe(
.values(via_explore=False) .values(via_explore=False)
) )
db.commit() db.commit()
return {"subscribed": channel_id} return {"ok": True, "subscribed": channel_id}
@router.delete("/{channel_id}/subscription") @router.delete("/{channel_id}/subscription")
@@ -608,10 +648,7 @@ def unsubscribe(
should Hide instead.""" should Hide instead."""
sub = _user_subscription(db, user, channel_id) sub = _user_subscription(db, user, channel_id)
if not sub.yt_subscription_id: if not sub.yt_subscription_id:
raise HTTPException( raise refused(400, "no_subscription_id")
status_code=400,
detail="No YouTube subscription id on record — sync subscriptions first.",
)
try: try:
with ( with (
quota.attribute(user.id, quota.QuotaAction.CHANNELS_UNSUBSCRIBE), quota.attribute(user.id, quota.QuotaAction.CHANNELS_UNSUBSCRIBE),
@@ -621,10 +658,10 @@ def unsubscribe(
except YouTubeError as exc: except YouTubeError as exc:
# 422 (a real, explainable error → speaking modal), not 502 which the client treats as # 422 (a real, explainable error → speaking modal), not 502 which the client treats as
# a transient "connection lost" gateway blip. # a transient "connection lost" gateway blip.
raise HTTPException(status_code=422, detail=f"YouTube couldn't unsubscribe you: {exc}") raise youtube_refusal(exc) from exc
db.delete(sub) db.delete(sub)
db.commit() db.commit()
return {"unsubscribed": channel_id} return {"ok": True, "unsubscribed": channel_id}
@router.get("/blocked/list") @router.get("/blocked/list")
@@ -635,7 +672,7 @@ def list_blocked(user: User = Depends(current_user), db: Session = Depends(get_d
select(Channel) select(Channel)
.join(BlockedChannel, BlockedChannel.channel_id == Channel.id) .join(BlockedChannel, BlockedChannel.channel_id == Channel.id)
.where(BlockedChannel.user_id == user.id) .where(BlockedChannel.user_id == user.id)
.order_by(func.lower(Channel.title)) .order_by(collated(Channel.title, lang_for_user(user)))
) )
.scalars() .scalars()
.all() .all()
@@ -665,7 +702,7 @@ def block_channel(
) )
) )
db.commit() db.commit()
return {"blocked": channel_id} return {"ok": True, "blocked": channel_id}
@router.delete("/{channel_id}/block") @router.delete("/{channel_id}/block")
@@ -680,7 +717,7 @@ def unblock_channel(
) )
) )
db.commit() db.commit()
return {"unblocked": channel_id} return {"ok": True, "unblocked": channel_id}
class AttachTagIn(BaseModel): class AttachTagIn(BaseModel):
@@ -715,7 +752,7 @@ def attach_tag(
) )
) )
db.commit() db.commit()
return {"channel_id": channel_id, "tag_id": body.tag_id} return {"ok": True, "channel_id": channel_id, "tag_id": body.tag_id}
@router.delete("/{channel_id}/tags/{tag_id}") @router.delete("/{channel_id}/tags/{tag_id}")
@@ -735,4 +772,4 @@ def detach_tag(
if row is not None: if row is not None:
db.delete(row) db.delete(row)
db.commit() db.commit()
return {"channel_id": channel_id, "tag_id": tag_id} return {"ok": True, "channel_id": channel_id, "tag_id": tag_id}
+38 -13
View File
@@ -4,18 +4,23 @@ app.sysconfig. Secret values (e.g. SMTP password) are write-only — never retur
import re import re
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import audit from app import audit
from app import email as email_mod from app import email as email_mod
from app import logs
from app import mailtext from app import mailtext
from app import sysconfig from app import sysconfig
from app.audit import AuditAction from app.audit import AuditAction
from app.auth import admin_user from app.auth import admin_user
from app.db import get_db from app.db import get_db
from app.models import User from app.models import User
from app.refusal import refused
from app.issues import gitea
from app.routes._common import issue_refused, not_found
from app.refusal import setting_refused
router = APIRouter(prefix="/api/admin/config", tags=["admin"]) router = APIRouter(prefix="/api/admin/config", tags=["admin"])
@@ -29,6 +34,14 @@ def _redact(v):
return _URL_USERINFO.sub(r"\1***@", v) if isinstance(v, str) else 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("") @router.get("")
def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: def get_config(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
return sysconfig.describe(db) return sysconfig.describe(db)
@@ -50,19 +63,16 @@ def set_config(
) -> dict: ) -> dict:
s = sysconfig.spec(key) s = sysconfig.spec(key)
if s is None: if s is None:
raise HTTPException(status_code=404, detail="Unknown config key") raise not_found("config_key")
if "value" not in body.model_fields_set: if "value" not in body.model_fields_set:
raise HTTPException(status_code=400, detail="Missing 'value'") raise refused(400, "value_missing")
if s.secret and not sysconfig.secrets_manageable(): if s.secret and not sysconfig.secrets_manageable():
raise HTTPException( raise refused(400, "no_encryption_key")
status_code=400,
detail="Set TOKEN_ENCRYPTION_KEY to store secrets in the database.",
)
old = None if s.secret else sysconfig.get(db, key) old = None if s.secret else sysconfig.get(db, key)
try: try:
sysconfig.set_value(db, key, body.value) sysconfig.set_value(db, key, body.value)
except (TypeError, ValueError) as exc: except sysconfig.InvalidSetting as exc:
raise HTTPException(status_code=400, detail=str(exc) or "Invalid value") raise setting_refused(exc) from None
# Never log a secret's value — only that it was changed, by whom. Non-secret values are logged # Never log a secret's value — only that it was changed, by whom. Non-secret values are logged
# but with URL credentials redacted (a proxy/URL setting can embed user:pass). Skip the row when # but with URL credentials redacted (a proxy/URL setting can embed user:pass). Skip the row when
# a non-secret value didn't actually change (a no-op re-save shouldn't add trail noise); secrets # a non-secret value didn't actually change (a no-op re-save shouldn't add trail noise); secrets
@@ -80,6 +90,7 @@ def set_config(
after=None if s.secret else {"value": _redact(new)}, after=None if s.secret else {"value": _redact(new)},
) )
db.commit() db.commit()
_apply_runtime(db, key)
return sysconfig.describe(db) return sysconfig.describe(db)
@@ -91,7 +102,7 @@ def reset_config(
) -> dict: ) -> dict:
s = sysconfig.spec(key) s = sysconfig.spec(key)
if s is None: if s is None:
raise HTTPException(status_code=404, detail="Unknown config key") raise not_found("config_key")
old = None if s.secret else sysconfig.get(db, key) old = None if s.secret else sysconfig.get(db, key)
sysconfig.reset(db, key) sysconfig.reset(db, key)
audit.record( audit.record(
@@ -104,6 +115,7 @@ def reset_config(
before=None if s.secret else {"value": _redact(old)}, before=None if s.secret else {"value": _redact(old)},
) )
db.commit() db.commit()
_apply_runtime(db, key)
return sysconfig.describe(db) return sysconfig.describe(db)
@@ -115,8 +127,21 @@ def send_test_email(
"""Send a test email to the requesting admin using the current (DB or env) SMTP config — """Send a test email to the requesting admin using the current (DB or env) SMTP config —
lets the admin verify the settings actually work.""" lets the admin verify the settings actually work."""
if not email_mod.email_enabled(): if not email_mod.email_enabled():
raise HTTPException(status_code=400, detail="SMTP is not configured.") raise refused(400, "smtp_off")
sent = email_mod.send_test(user.email, mailtext.lang_for_user(user)) sent = email_mod.send_test(user.email, mailtext.lang_for_user(user))
if not sent: if not sent:
raise HTTPException(status_code=422, detail="SMTP send failed — check the settings.") raise refused(422, "smtp_failed")
return {"sent": True, "to": user.email} return {"ok": True, "sent": True, "to": user.email}
@router.post("/test-issues")
def test_issue_tracker(
_: User = Depends(admin_user),
db: Session = Depends(get_db),
) -> dict:
"""Check the configured issue tracker (E16): the repository answers with this token, takes
issues, and is PRIVATE — the one it must be, since an issue carries its reporter's log."""
try:
return {"ok": True, **gitea.check(db)}
except gitea.GiteaError as exc:
raise issue_refused(exc) from None
+418
View File
@@ -0,0 +1,418 @@
"""Debug sessions (E16): Start → reproduce → Stop, the screenshots, and the evidence under review.
The session itself — what tags a request, its cap, its level — is `app/debugsession.py`. These routes
are the user's handle on their OWN sessions only: another user's id 404s (`owned_or_404`), including
for an admin, who reads everyone's lines in the diagnostics console instead. The shared demo account
cannot record: its log would interleave every visitor under one account.
Screenshots arrive as a raw image body (`image/png`, `image/jpeg`, `image/webp`), not a multipart
form — one image per request, capped while it arrives, and kept only if its bytes really are one of
those types, whatever the Content-Type claimed.
"""
import json
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app import audit, debugsession, logs, logsink
from app.audit import AuditAction
from app.auth import require_human
from app.db import get_db, release_read_connection
from app.issues import bundle, gitea
from app.ratelimit import RateLimiter
from app.refusal import refused
from app.models import DebugScreenshot, DebugSession, User
from app.routes._common import capped_body, issue_refused, not_found, owned_or_404
from app.utils import iso
router = APIRouter(prefix="/api/debug-sessions", tags=["diagnostics"])
log = logs.get_logger(__name__)
# Sessions listed in Settings → Diagnosztika, newest first: enough to reach last week's unsent draft.
LIST_MAX = 20
class StartIn(BaseModel):
level: debugsession.SessionLevel
_view = debugsession.view
# New sessions a user may open per hour (joining the running one is not counted): a session is a
# place to keep screenshots, and this — with the per-user storage cap — bounds how many there are.
_new_sessions = RateLimiter(max_events=20, window_seconds=3600)
@router.post("")
def start_session(
body: StartIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""Start recording, or join the recording already running (another tab started it)."""
joining = debugsession.open_session(db, user) is not None
if not joining and not _new_sessions.allow(str(user.id)):
raise refused(429, "debug_session_rate_limited", limit=_new_sessions.max_events)
try:
session = debugsession.start(db, user, body.level)
db.commit()
except IntegrityError:
# A concurrent Start won the one-open-per-user index: join that one.
db.rollback()
session = debugsession.open_session(db, user)
if session is None:
raise refused(409, "debug_session_conflict") from None
# The rest of THIS request is part of the session too.
level, until = debugsession.level_for(user, session)
logs.set_user(user.id, level, until, session.id)
log.info(
"debug session started",
extra=logs.event("debug.session.started", level=session.level, until=iso(session.until)),
)
return {"ok": True, "session": _view(db, session)}
@router.post("/{sid}/stop")
def stop_session(
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
if session.ended_at is None:
# Logged BEFORE the session closes, while this request still carries its tag: the last line
# of the evidence is the Stop itself.
log.info("debug session stopped", extra=logs.event("debug.session.stopped"))
debugsession.stop(session)
db.commit()
logs.end_session_elevation(session.id)
return {"ok": True, "session": _view(db, session)}
@router.get("")
def list_sessions(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
sessions = db.scalars(
select(DebugSession)
.where(DebugSession.user_id == user.id)
.order_by(DebugSession.started_at.desc())
.limit(LIST_MAX)
).all()
return {"items": [_view(db, s) for s in sessions]}
@router.get("/{sid}")
def get_session(
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
return _view(db, owned_or_404(db, DebugSession, sid, user, what="debug_session"))
@router.get("/{sid}/events")
def session_events(
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
"""The rows an issue from this session would carry — browser and server, in time order."""
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
rows, total = debugsession.bundle(db, session)
return {"items": [logsink.serialize(r) for r in rows], "total": total}
# --- the draft and its export ---------------------------------------------------------------
# The composer's own fields (steps, description…) are its business, kept whole so it reopens as it
# was — bounded here so a draft cannot become a place to store anything else.
FORM_MAX_BYTES = 64 * 1024
# Zips a user may build per minute: each holds every screenshot in memory while it is built.
_exports = RateLimiter(max_events=6, window_seconds=60)
class DraftIn(BaseModel):
title: str = Field(max_length=200)
# The rendered markdown the export and the issue carry.
body: str = Field(max_length=50_000)
# Log rows taken out of the evidence in the review.
excluded: list[int] = Field(default_factory=list, max_length=debugsession.BUNDLE_MAX)
form: dict[str, Any] = Field(default_factory=dict)
@router.put("/{sid}/draft")
def save_draft(
sid: str,
body: DraftIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
if len(json.dumps(body.form, ensure_ascii=False).encode()) > FORM_MAX_BYTES:
raise HTTPException(status_code=413, detail={"code": "too_large"})
# Under the lock, and refused while a send is under way: the issue is being built from the draft
# as it was saved, and this would change it under the send.
debugsession.lock(db, session)
if debugsession.sending(session):
raise refused(409, "issue_sending")
if session.issue_url is not None:
# Sent: the issue is on the tracker now, and a draft edited after it would say otherwise.
raise refused(409, "issue_already_sent")
debugsession.save_draft(session, body.model_dump())
db.commit()
return {"ok": True, "session": _view(db, session)}
@router.get("/{sid}/export")
def export_session(
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> Response:
"""The issue as a zip — the text, the two logs and the screenshots — for an instance without an
issue tracker, or for sending it by hand. Built from the SAVED draft: what the composer shows is
what it saved."""
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
if not _exports.allow(str(user.id)):
raise refused(429, "export_rate_limited")
data = bundle.zipped(bundle.files(db, session))
name = f"siftlode-debug-{session.started_at:%Y%m%d-%H%M}-{session.id[:6]}.zip"
return Response(
content=data,
media_type="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{name}"',
"Cache-Control": "no-store",
},
)
# --- the issue tracker (S4) -----------------------------------------------------------------
# Issues a user may open per hour — counted once the tracker is ASKED to open one (an answer that
# goes missing may still have created it, so an attempt cannot be given back); finishing a half-sent
# one, or an attempt refused before the tracker was asked, is not counted. The composer sends one at
# a time; this is room for a bad afternoon, not a pace anything legitimate keeps up.
_new_issues = RateLimiter(max_events=5, window_seconds=3600)
# Attempts on the tracker per hour, creating or finishing. Deliberately NOT the budget above: a
# half-sent issue must always be finishable, so the pace that guards creating one must never be what
# stands between a user and their own report. This one only bounds a loop — every attempt re-reads
# the whole bundle into memory and re-offers the pending file to the tracker, and a retry that is
# refused for good (the tracker's own limits) never leaves `pending`. No hand reaches it.
_sends = RateLimiter(max_events=60, window_seconds=3600)
# The composer's live preview, rendered BY the tracker: one authenticated call each, with our token.
# The composer's toggle fires it once per switch INTO preview — the editor is the other branch, so
# no keystroke can change the query key while the preview is up — and react-query's cache makes an
# unchanged draft free. (It does NOT debounce, whatever this comment used to say: a key that changes
# per keystroke is one fetch per keystroke, measured. Showing the preview BESIDE the fields would
# need a real debounce first.) This bounds what a loop can aim at the tracker.
_previews = RateLimiter(max_events=60, window_seconds=60)
def _issues_rate_limited() -> HTTPException:
return refused(429, "issues_rate_limited", limit=_new_issues.max_events)
@router.post("/{sid}/issue")
def send_issue(
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
"""Send the session's issue to the tracker — from the SAVED draft, as the export is built — or
finish a send that stopped half-way (the issue exists; only what is missing is sent)."""
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
# The session is CLAIMED for the send rather than locked through it: the tracker is slow (a
# dozen calls, 20 s each), and a row lock held across them would queue this user's Stop, their
# autosave and their screenshots behind it. They see the claim instead and are refused at once.
debugsession.lock(db, session)
if debugsession.sending(session):
raise refused(409, "issue_sending")
new = session.issue_url is None
if not new and not debugsession.unfinished(session):
return {"ok": True, "session": _view(db, session)}
key = str(user.id)
if new:
if not ((session.draft or {}).get("title") or "").strip():
raise refused(422, "issue_title_required")
if _new_issues.exhausted(key):
raise _issues_rate_limited()
if not _sends.allow(key):
raise refused(429, "issue_rate_limited", limit=_sends.max_events)
token = debugsession.claim_send(session)
db.commit() # the claim is what everything else sees; the lock ends here
def count_new_issue() -> None:
if not _new_issues.allow(key):
raise _issues_rate_limited()
try:
gitea.deliver(
db,
session,
token=token,
checkpoint=db.commit,
before_create=count_new_issue,
on_created=lambda: _record_created(db, user, session),
)
except debugsession.SendClaimLost:
# This send outlived its lease and another one took the session over while it was still
# talking to the tracker. It stopped rather than writing over that one's progress — and a
# send IS under way, just not ours, which is what the caller needs to hear.
raise refused(409, "issue_sending") from None
except gitea.GiteaError as exc:
# Keep what did happen: an issue created before an upload failed stays linked, and the
# retry does only what is still missing.
raise issue_refused(exc) from None
except HTTPException:
raise # the pace, refused before the tracker was asked
finally:
_end_send(db, session, token)
return {"ok": True, "session": _view(db, session)}
def _end_send(db: Session, session: DebugSession, token: str) -> None:
"""Give the claim back, whatever else happened. A failure here must not replace the failure that
brought us: a dead transaction (the database went away mid-send) is rolled back, and the claim
then simply ages out."""
try:
debugsession.lock(db, session)
debugsession.release_send(session, token)
db.commit()
except Exception: # noqa: BLE001 — whatever went wrong here, the send's own failure is the news
db.rollback()
log.warning(
"a send could not give its claim back",
extra=logs.event("issues.claim.stuck", session_id=session.id),
)
def _record_created(db: Session, user: User, session: DebugSession) -> None:
"""The trail for the one act that takes a user's log off this instance. Staged inside the send,
so the checkpoint that first writes `issue_url` commits BOTH — recorded later it could be lost
on its own (the process dies mid-send), and no retry could write it afterwards: the next send
sees the link and knows the issue is not new."""
audit.record(
db,
user.id,
AuditAction.ISSUE_CREATE,
target_type="debug_session",
target_id=session.id,
summary=session.issue_url or "",
)
class PreviewIn(BaseModel):
text: str = Field(max_length=50_000)
@router.post("/{sid}/preview")
def preview_issue(
sid: str,
body: PreviewIn,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""The body as the tracker will render it. Gitea sanitises its own output; the composer still
shows it in a sandboxed frame, never in the app's own document."""
owned_or_404(db, DebugSession, sid, user, what="debug_session")
if not _previews.allow(str(user.id)):
raise refused(429, "preview_rate_limited")
try:
return {"ok": True, "html": gitea.preview(db, body.text)}
except gitea.GiteaError as exc:
raise issue_refused(exc) from None
# --- screenshots ---------------------------------------------------------------------------
def _shot_view(shot: DebugScreenshot) -> dict:
return {"id": shot.id, "mime": shot.mime, "size": shot.size, "created_at": iso(shot.created_at)}
@router.get("/{sid}/screenshots")
def list_screenshots(
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
return {"items": [_shot_view(s) for s in debugsession.screenshots(db, session)]}
def _upload_target(
sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> DebugSession:
return owned_or_404(db, DebugSession, sid, user, what="debug_session")
async def _image_body(request: Request, db: Session = Depends(get_db)) -> bytes:
# The owner check above has already run, and it was a SELECT — so this session is holding a
# pooled connection `idle in transaction` and would hold it for the whole 10 MiB upload, from a
# client that may be on a phone. Hand it back first; the handler's own work re-takes one. The
# `session` object the handler holds is expired by this and reloads on its next attribute read,
# which is the correct moment to touch the database again.
release_read_connection(db)
return await capped_body(request, debugsession.SCREENSHOT_MAX_BYTES)
@router.post("/{sid}/screenshots")
def add_screenshot(
# The owner check FIRST: dependencies resolve in order, so a stranger's upload is refused
# before a byte of it is read.
session: DebugSession = Depends(_upload_target),
data: bytes = Depends(_image_body),
db: Session = Depends(get_db),
) -> dict:
try:
shot = debugsession.add_screenshot(db, session, data)
except debugsession.ScreenshotRefused as exc:
raise _screenshot_refused(exc.code) from None
db.commit()
return {"ok": True, "screenshot": _shot_view(shot)}
def _screenshot_refused(code: str) -> HTTPException:
# Literal reasons, one per case: the frontend's contract test reads them to require a sentence
# for each in both languages.
if code == "already_sent":
return refused(409, "screenshot_already_sent")
if code == "too_many":
return refused(422, "screenshot_too_many", limit=debugsession.SCREENSHOTS_MAX)
if code == "sending":
return refused(409, "issue_sending")
if code == "storage_full":
mb = debugsession.SCREENSHOT_USER_MAX_BYTES // (1024 * 1024)
return refused(422, "screenshot_storage_full", limit=mb)
if code == "empty":
return refused(422, "screenshot_empty")
return refused(422, "screenshot_not_an_image")
def _session_screenshot(db: Session, sid: str, shot_id: int, user: User) -> DebugScreenshot:
return _screenshot_of(
db, owned_or_404(db, DebugSession, sid, user, what="debug_session"), shot_id
)
def _screenshot_of(db: Session, session: DebugSession, shot_id: int) -> DebugScreenshot:
shot = db.get(DebugScreenshot, shot_id)
if shot is None or shot.session_id != session.id:
raise not_found("screenshot")
return shot
@router.get("/{sid}/screenshots/{shot_id}")
def get_screenshot(
sid: str, shot_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> Response:
shot = _session_screenshot(db, sid, shot_id, user)
# `nosniff`: the type was checked against the bytes at upload, and the browser must not guess.
return Response(
content=shot.data,
media_type=shot.mime,
headers={"Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff"},
)
@router.delete("/{sid}/screenshots/{shot_id}")
def delete_screenshot(
sid: str, shot_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict:
session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
shot = _screenshot_of(db, session, shot_id)
try:
debugsession.remove_screenshot(db, session, shot)
except debugsession.ScreenshotRefused as exc:
raise _screenshot_refused(exc.code) from None
db.commit()
return {"ok": True, "deleted": shot_id}
+190
View File
@@ -0,0 +1,190 @@
"""The browser's log, sent while its user has a raised diagnostics level (E22 S5).
The client keeps its log in memory (frontend `lib/log.ts`). Only after the user consented to Részletes
or Teljes in Settings → Diagnosztika does it stream that log here, in batches, and each batch lands in
`log_events` beside the server's own rows: `source="web"`, the account from the SESSION (never from
the body), and the request id each event carries, which is what joins a browser line to the server
line of the same request.
Nothing from the body is trusted as-is:
* only a JSON Content-Type is read, and the whole body is capped before it is parsed, and so are
the number of events and each event's attributes;
* the pace is capped per account, and so is what an account may store per hour;
* everything is redacted AGAIN with the server's own rules — the client redacts at capture, but a
modified client would not;
* an event below the consented level is dropped (a Részletes consent does not store what only
Teljes may carry), and so is the whole batch once the level has lapsed — where an open debug
session the request names counts as consent to ITS level (E16), and only its own events keep
its tag;
* the timestamp is clamped to the consent window, so a row cannot dodge the retention job by
claiming to come from the future.
"""
import json
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import insert
from sqlalchemy.orm import Session
from app import debugsession, diagnostics, logs, logsink
from app.auth import current_user
from app.db import get_db, release_read_connection
from app.models import DebugSession, LogEvent, User
from app.ratelimit import RateLimiter
from app.routes._common import capped_body
router = APIRouter(prefix="/api/diagnostics", tags=["diagnostics"])
BODY_MAX_BYTES = 512 * 1024
EVENTS_MAX = 200
ATTRS_MAX_BYTES = 16 * 1024
# The client flushes every few seconds and when its buffer fills; this is room for a burst, not a
# pace anything legitimate keeps up for a minute.
_batches = RateLimiter(max_events=30, window_seconds=60)
# What one account may STORE per hour. The pace cap alone still lets a self-consented account write
# ~15 MiB a minute into the one shared database, kept for the whole retention window; a real session
# at Teljes stores a few hundred kB an hour. Past the budget a batch is accepted and not stored (a
# refusal would only make the client resend it), and one notice per hour says so.
STORED_BYTES_PER_HOUR = 5 * 1024 * 1024
_stored_bytes = RateLimiter(max_events=STORED_BYTES_PER_HOUR, window_seconds=3600)
_budget_notices = RateLimiter(max_events=1, window_seconds=3600)
ClientLevel = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
class ClientEvent(BaseModel):
ts: datetime
level: ClientLevel
event: str = Field(max_length=64, pattern=r"^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$")
msg: str = Field(max_length=8_000)
attrs: dict[str, Any] | None = None
request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9._-]{8,64}$")
tab_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{1,32}$")
# The debug session the event was captured in (E16); kept only when it is the one this request
# names in `X-Debug-Session` and the auth dependency found it the caller's own and open.
debug_session_id: str | None = Field(default=None, pattern=r"^[0-9a-f]{16}$")
version: str | None = Field(default=None, max_length=64)
class ClientEventsIn(BaseModel):
events: list[ClientEvent] = Field(max_length=EVENTS_MAX)
async def _parsed_events(request: Request, db: Session = Depends(get_db)) -> ClientEventsIn:
"""The body as the model — parsed only after the size cap, which is why this is a dependency and
not a plain body parameter (FastAPI reads a declared body in full before anything else runs).
JSON only, by its Content-Type, as a declared body would be. Parsing any body as JSON would let a
CORS-simple `text/plain` form post from a same-site page write events into a signed-in user's
log; the app's own client always sends `application/json`, the unload beacon included."""
media_type = request.headers.get("content-type", "").split(";")[0].strip().lower()
if media_type != "application/json" and not media_type.endswith("+json"):
raise HTTPException(status_code=415, detail={"code": "json_required"})
# `current_user` resolved above on a SELECT, so this session is holding a pooled connection.
# Give it back before awaiting the body — the sink is a background beacon and its sender may be
# on a link far slower than the 512 KiB suggests. See `db.release_read_connection`.
release_read_connection(db)
raw = await capped_body(request, BODY_MAX_BYTES)
try:
return ClientEventsIn.model_validate_json(raw)
except ValidationError as exc:
raise HTTPException(status_code=422, detail=json.loads(exc.json())) from exc
def _row(
item: ClientEvent,
user: User,
now: datetime,
window_start: datetime,
session: DebugSession | None,
) -> dict[str, Any]:
attrs = logs.redact(item.attrs) if item.attrs else None
if attrs is not None:
size = len(json.dumps(attrs, ensure_ascii=False, default=str).encode())
if size > ATTRS_MAX_BYTES:
attrs = {"attrs_dropped_bytes": size}
if item.tab_id:
attrs = {**(attrs or {}), "tab_id": item.tab_id}
ts = item.ts if item.ts.tzinfo else item.ts.replace(tzinfo=timezone.utc)
return logsink.to_row(
{
"ts": min(max(ts, window_start), now),
"level": item.level,
"sev": logs.SEVERITY[item.level],
"source": "web",
"logger": "web",
"event": item.event,
"msg": logs.redact_text(item.msg),
"attrs": attrs,
"request_id": item.request_id,
"user_id": user.id,
"debug_session_id": (
session.id if session and item.debug_session_id == session.id else None
),
"version": item.version,
}
)
@router.post("/events")
def ingest_events(
# The account first: dependencies resolve in order, so an anonymous caller is refused before a
# byte of its body is read.
request: Request,
user: User = Depends(current_user),
body: ClientEventsIn = Depends(_parsed_events),
db: Session = Depends(get_db),
) -> dict:
# A debug session the request names raises the level for its own events (E16): starting one is
# the consent to that level for its duration, whatever the user's own level is.
session = debugsession.from_header(db, user, request.headers.get(debugsession.HEADER))
level, _ = debugsession.level_for(user, session)
if level == diagnostics.BASE:
# Lapsed or withdrawn: the client stops streaming when it sees this.
raise HTTPException(status_code=409, detail={"code": "diagnostics_off"})
if not _batches.allow(str(user.id)):
raise HTTPException(status_code=429, detail={"code": "rate_limited"})
now = datetime.now(timezone.utc)
window_start = now - timedelta(hours=diagnostics.ELEVATION_HOURS)
floor = logs.SEVERITY[level]
rows = [
_row(item, user, now, window_start, session)
for item in body.events
if logs.SEVERITY[item.level] >= floor
]
if not rows:
return {"ok": True, "stored": 0}
size = sum(len(json.dumps(row, ensure_ascii=False, default=str).encode()) for row in rows)
stored = len(rows)
if not _stored_bytes.allow(str(user.id), size):
stored = 0
if not _budget_notices.allow(str(user.id)):
return {"ok": True, "stored": 0}
rows = [_budget_notice(user, now)]
# Under the erasure guard until the commit: an account erased meanwhile gets no rows back.
with logs.erasure_guard():
db.execute(insert(LogEvent), logsink.without_erased(rows))
db.commit()
return {"ok": True, "stored": stored}
def _budget_notice(user: User, now: datetime) -> dict[str, Any]:
return logsink.to_row(
{
"ts": now,
"level": "WARNING",
"sev": logs.SEVERITY["WARNING"],
"source": "web",
"logger": "web",
"event": "diagnostics.budget_spent",
# What the budget does, exactly: it is a SLIDING hour, and a refused batch is not
# charged to it, so smaller batches keep landing while there is room for them. Saying
# "the rest of the hour is not kept" would be a row contradicting the rows beneath it.
"msg": "The browser log passed its hourly storage budget; a batch bigger than what is left of it is not kept",
"attrs": {"budget_bytes": STORED_BYTES_PER_HOUR},
"user_id": user.id,
}
)
+27 -21
View File
@@ -11,9 +11,9 @@ thing it exists to remove.
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import select, update from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -23,7 +23,8 @@ from app.discover import hero as hero_mod
from app.discover import pins, rails from app.discover import pins, rails
from app.discover.budget import Budget, DEFAULT_BUDGET from app.discover.budget import Budget, DEFAULT_BUDGET
from app.models import ComfortPin, User from app.models import ComfortPin, User
from app.routes._common import owned_or_404 from app.plex.cards import norm_lang
from app.routes._common import owned_or_404, not_found
router = APIRouter(prefix="/api/discover", tags=["discover"]) router = APIRouter(prefix="/api/discover", tags=["discover"])
@@ -45,6 +46,7 @@ class ReorderIn(BaseModel):
def get_discover( def get_discover(
budget: Budget = DEFAULT_BUDGET, budget: Budget = DEFAULT_BUDGET,
seed: int = 0, seed: int = 0,
lang: str = "en",
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
@@ -52,11 +54,14 @@ def get_discover(
`resume` and `shelf` are built once and handed to the hero, so the two rails the hero draws from `resume` and `shelf` are built once and handed to the hero, so the two rails the hero draws from
are not queried twice for a single page load.""" are not queried twice for a single page load."""
resume = rails.continue_watching(db, user) lang = norm_lang(lang)
shelf = pins.shelf(db, user) resume = rails.continue_watching(db, user, lang=lang)
# Plex off → the shelf rail (and so the hero's comfort tier) goes YouTube-only like the rails
# beside it; the pins are kept and `GET /pins` still lists them (`pins.shelf`).
shelf = pins.shelf(db, user, include_plex=rails.plex_available(db), lang=lang)
return { return {
"budget": budget, "budget": budget,
"hero": hero_mod.pick(db, user, budget, seed, resume=resume, shelf=shelf), "hero": hero_mod.pick(db, user, budget, seed, resume=resume, shelf=shelf, lang=lang),
"continue_watching": resume, "continue_watching": resume,
"comfort": shelf, "comfort": shelf,
} }
@@ -78,6 +83,7 @@ def get_pick(
seed: int = 0, seed: int = 0,
exclude: str = "", exclude: str = "",
start: int = 0, start: int = 0,
lang: str = "en",
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
@@ -97,18 +103,25 @@ def get_pick(
return { return {
"budget": budget, "budget": budget,
"hero": hero_mod.pick( "hero": hero_mod.pick(
db, user, budget, seed, exclude=set(list(skip)[:MAX_EXCLUDE]), start=start db,
user,
budget,
seed,
exclude=set(list(skip)[:MAX_EXCLUDE]),
start=start,
lang=norm_lang(lang),
), ),
} }
@router.get("/pins") @router.get("/pins")
def list_pins( def list_pins(
lang: str = "en",
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""The shelf on its own — for the shelf editor, which needs it without the rest of the page.""" """The shelf on its own — for the shelf editor, which needs it without the rest of the page."""
return {"items": pins.shelf(db, user)} return {"items": pins.shelf(db, user, lang=norm_lang(lang))}
@router.post("/pins") @router.post("/pins")
@@ -125,7 +138,7 @@ def add_pin(
constraint is the mechanism, not the message.""" constraint is the mechanism, not the message."""
resolved = pins.resolve_ref(db, user, body.kind, body.ref) resolved = pins.resolve_ref(db, user, body.kind, body.ref)
if resolved is None: if resolved is None:
raise HTTPException(status_code=404, detail="Unknown item") raise not_found("item")
column, value = resolved column, value = resolved
existing = db.execute( existing = db.execute(
@@ -157,7 +170,7 @@ def add_pin(
# between `resolve_ref` confirming it and this INSERT (a Plex sync dropping a removed # between `resolve_ref` confirming it and this INSERT (a Plex sync dropping a removed
# item), which trips the FK. Semantically that is the same "no such thing" the pre-check # item), which trips the FK. Semantically that is the same "no such thing" the pre-check
# returns above — re-raising handed the user a 500 they could do nothing with. # returns above — re-raising handed the user a 500 they could do nothing with.
raise HTTPException(status_code=404, detail="Unknown item") raise not_found("item") from None
return {"ok": True, "id": winner.id, "created": False} return {"ok": True, "id": winner.id, "created": False}
return {"ok": True, "id": pin.id, "created": True} return {"ok": True, "id": pin.id, "created": True}
@@ -180,18 +193,11 @@ def reorder_pins(
user: User = Depends(require_human), user: User = Depends(require_human),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Rewrite the shelf order from a full list of pin ids. """Rewrite the shelf order from the list of pin ids the client shows.
Whole-list rather than a move-one delta: the shelf is short, and a full rewrite cannot leave two Whole-list rather than a move-one delta: the shelf is short, and a full rewrite cannot leave two
tiles claiming the same slot the way an interrupted sequence of single moves can. Ids that are not tiles claiming the same slot the way an interrupted sequence of single moves can. The list may be
this user's are ignored rather than 404'd — a stale tab replaying an order that includes a pin PARTIAL (hidden Plex pins, a stale tab) — `pins.reorder` keeps what it leaves out in place."""
deleted meanwhile should reorder what still exists, not fail wholesale.""" pins.reorder(db, user, body.ids)
owned = {p.id for p in pins.list_pins(db, user)}
for position, pin_id in enumerate(i for i in body.ids if i in owned):
db.execute(
update(ComfortPin)
.where(ComfortPin.id == pin_id, ComfortPin.user_id == user.id)
.values(position=position)
)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
+180 -99
View File
@@ -9,20 +9,24 @@ serves finished files (range-aware, with the user's custom display name), and sh
import os import os
import re import re
import tempfile import tempfile
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, unquote, urlparse
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.mailtext import lang_for_user
from app.collation import collated
from app import audit, sysconfig from app import audit, sysconfig
from app.auth import admin_user, require_human from app.auth import admin_user, require_human
from app.config import settings from app.config import settings
from app.db import get_db from app.db import get_db, release_read_connection
from app.downloads import credentials as dlcreds from app.downloads import credentials as dlcreds
from app.downloads import edit as editmod from app.downloads import edit as editmod
from app.downloads import links as linksmod from app.downloads import links as linksmod
@@ -37,11 +41,13 @@ from app.models import (
DownloadShare, DownloadShare,
MediaAsset, MediaAsset,
User, User,
display_text,
) )
from app.routes._common import get_or_404, not_found from app.routes._common import capped_stream_to, get_or_404, not_found, owned_or_404
from app.security import hash_password from app.security import hash_password
from app.userscope import is_messageable_user, messageable_clauses from app.userscope import is_messageable_user, messageable_clauses
from app.utils import iso from app.utils import iso
from app.refusal import refused
router = APIRouter(prefix="/api/downloads", tags=["downloads"]) router = APIRouter(prefix="/api/downloads", tags=["downloads"])
admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-downloads"]) admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-downloads"])
@@ -63,7 +69,7 @@ def resolve_source(raw: str) -> tuple[str, str]:
as a raw URL for yt-dlp's generic extractor.""" as a raw URL for yt-dlp's generic extractor."""
raw = (raw or "").strip() raw = (raw or "").strip()
if not raw: if not raw:
raise HTTPException(status_code=400, detail="A video link or id is required.") raise refused(400, "link_required")
if _VIDEO_ID.match(raw): if _VIDEO_ID.match(raw):
return "youtube", raw return "youtube", raw
if raw.startswith(("http://", "https://")): if raw.startswith(("http://", "https://")):
@@ -93,7 +99,7 @@ def resolve_source(raw: str) -> tuple[str, str]:
if _VIDEO_ID.match(vid): if _VIDEO_ID.match(vid):
return "youtube", vid return "youtube", vid
return "url", raw return "url", raw
raise HTTPException(status_code=400, detail="That doesn't look like a valid video link.") raise refused(400, "invalid_link")
def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict: def _serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
@@ -166,15 +172,8 @@ def _serialize_job(db: Session, job: DownloadJob) -> dict:
return _serialize(job, asset) return _serialize(job, asset)
def _own_job(db: Session, user: User, job_id: int) -> DownloadJob:
job = db.get(DownloadJob, job_id)
if job is None or job.user_id != user.id:
raise HTTPException(status_code=404, detail="Unknown download")
return job
def _own_job_for_update(db: Session, user: User, job_id: int) -> DownloadJob: def _own_job_for_update(db: Session, user: User, job_id: int) -> DownloadJob:
"""`_own_job` for a transition that moves its asset's ref count (resume, cancel, delete, redownload) """The ownership check (`owned_or_404`) for a transition that moves its asset's ref count (resume, cancel, delete, redownload)
or writes the status those decide on (pause). The job is re-read under a row lock, so two or writes the status those decide on (pause). The job is re-read under a row lock, so two
overlapping requests for one job run one after the other and the second decides on the first's overlapping requests for one job run one after the other and the second decides on the first's
committed result — never both releasing the same hold, and never a pause turning a job a cancel committed result — never both releasing the same hold, and never a pause turning a job a cancel
@@ -183,7 +182,7 @@ def _own_job_for_update(db: Session, user: User, job_id: int) -> DownloadJob:
The asset is locked BEFORE the job: the order a finishing worker child takes them in The asset is locked BEFORE the job: the order a finishing worker child takes them in
(`service.finalize_if_running`), so the two can never deadlock.""" (`service.finalize_if_running`), so the two can never deadlock."""
while True: while True:
asset_id = _own_job(db, user, job_id).asset_id asset_id = owned_or_404(db, DownloadJob, job_id, user, what="download").asset_id
if asset_id is not None: if asset_id is not None:
db.execute(select(MediaAsset.id).where(MediaAsset.id == asset_id).with_for_update()) db.execute(select(MediaAsset.id).where(MediaAsset.id == asset_id).with_for_update())
job = db.execute( job = db.execute(
@@ -193,7 +192,7 @@ def _own_job_for_update(db: Session, user: User, job_id: int) -> DownloadJob:
.execution_options(populate_existing=True) .execution_options(populate_existing=True)
).scalar_one_or_none() ).scalar_one_or_none()
if job is None: if job is None:
raise HTTPException(status_code=404, detail="Unknown download") raise not_found("download")
if job.asset_id == asset_id: if job.asset_id == asset_id:
return job return job
db.rollback() # another request moved the job to another asset meanwhile: lock that one db.rollback() # another request moved the job to another asset meanwhile: lock that one
@@ -240,7 +239,7 @@ def create_profile(
name = body.name.strip() name = body.name.strip()
spec = body.spec spec = body.spec
if not name: if not name:
raise HTTPException(status_code=400, detail="A profile name is required.") raise refused(400, "profile_name_required")
maxpos = db.execute( maxpos = db.execute(
select(func.coalesce(func.max(DownloadProfile.sort_order), 100)).where( select(func.coalesce(func.max(DownloadProfile.sort_order), 100)).where(
DownloadProfile.user_id == user.id DownloadProfile.user_id == user.id
@@ -268,16 +267,16 @@ def update_profile(
) -> dict: ) -> dict:
p = db.get(DownloadProfile, profile_id) p = db.get(DownloadProfile, profile_id)
if p is None or p.is_builtin or p.user_id != user.id: if p is None or p.is_builtin or p.user_id != user.id:
raise HTTPException(status_code=404, detail="Unknown profile") raise not_found("profile")
sent = body.model_fields_set sent = body.model_fields_set
if "name" in sent: if "name" in sent:
name = (body.name or "").strip() name = (body.name or "").strip()
if not name: if not name:
raise HTTPException(status_code=400, detail="A profile name is required.") raise refused(400, "profile_name_required")
p.name = name[:80] p.name = name[:80]
if "spec" in sent: if "spec" in sent:
if body.spec is None: if body.spec is None:
raise HTTPException(status_code=400, detail="spec must be an object") raise refused(400, "spec_not_object")
p.spec = body.spec p.spec = body.spec
db.commit() db.commit()
return _serialize_profile(p) return _serialize_profile(p)
@@ -289,7 +288,7 @@ def delete_profile(
) -> dict: ) -> dict:
p = db.get(DownloadProfile, profile_id) p = db.get(DownloadProfile, profile_id)
if p is None or p.is_builtin or p.user_id != user.id: if p is None or p.is_builtin or p.user_id != user.id:
raise HTTPException(status_code=404, detail="Unknown profile") raise not_found("profile")
db.delete(p) db.delete(p)
db.commit() db.commit()
return {"deleted": profile_id} return {"deleted": profile_id}
@@ -319,11 +318,11 @@ def set_credential(
"""Store (encrypted) this user's cookies.txt for one supported host. Write-only: the value is """Store (encrypted) this user's cookies.txt for one supported host. Write-only: the value is
never echoed back, logged, or audited — only the fact that it changed.""" never echoed back, logged, or audited — only the fact that it changed."""
if not dlcreds.is_supported_host(host_key): if not dlcreds.is_supported_host(host_key):
raise HTTPException(status_code=404, detail="Unknown host") raise not_found("host")
try: try:
cleaned = dlcreds.validate_cookies(body.cookies) cleaned = dlcreds.validate_cookies(body.cookies)
except dlcreds.InvalidCookies as e: except dlcreds.InvalidCookies as e:
raise HTTPException(status_code=400, detail={"code": "cookies", "reason": str(e)}) raise HTTPException(status_code=400, detail={"code": "cookies", "reason": str(e)}) from e
dlcreds.upsert_credential(db, user.id, host_key, cleaned) dlcreds.upsert_credential(db, user.id, host_key, cleaned)
audit.record( audit.record(
db, db,
@@ -341,7 +340,7 @@ def delete_credential(
host_key: str, user: User = Depends(require_human), db: Session = Depends(get_db) host_key: str, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> dict: ) -> dict:
if not dlcreds.is_supported_host(host_key): if not dlcreds.is_supported_host(host_key):
raise HTTPException(status_code=404, detail="Unknown host") raise not_found("host")
if dlcreds.delete_credential(db, user.id, host_key): if dlcreds.delete_credential(db, user.id, host_key):
audit.record( audit.record(
db, db,
@@ -362,7 +361,7 @@ def _resolve_spec(
if profile_id is not None: if profile_id is not None:
p = db.get(DownloadProfile, profile_id) p = db.get(DownloadProfile, profile_id)
if p is None or (not p.is_builtin and p.user_id != user.id): if p is None or (not p.is_builtin and p.user_id != user.id):
raise HTTPException(status_code=404, detail="Unknown profile") raise not_found("profile")
return p.spec, p.id return p.spec, p.id
if spec is not None: if spec is not None:
return spec, None return spec, None
@@ -373,7 +372,7 @@ def _resolve_spec(
.limit(1) .limit(1)
).scalar_one_or_none() ).scalar_one_or_none()
if fallback is None: if fallback is None:
raise HTTPException(status_code=400, detail="No download profile available.") raise refused(400, "no_profile")
return fallback.spec, fallback.id return fallback.spec, fallback.id
@@ -404,69 +403,127 @@ def enqueue_download(
status_code=422, detail={"code": "url_blocked", "reason": e.reason} status_code=422, detail={"code": "url_blocked", "reason": e.reason}
) from e ) from e
spec, profile_id = _resolve_spec(db, user, body.profile_id, body.spec) spec, profile_id = _resolve_spec(db, user, body.profile_id, body.spec)
display_name = (body.display_name or "").strip() or None display_name = display_text(body.display_name)
try: try:
job = service.enqueue(db, user.id, source_kind, source_ref, spec, profile_id, display_name) job = service.enqueue(db, user.id, source_kind, source_ref, spec, profile_id, display_name)
except quota.QuotaExceeded as e: except quota.QuotaExceeded as e:
raise HTTPException(status_code=422, detail=_quota_detail(e)) raise HTTPException(status_code=422, detail=_quota_detail(e)) from e
return _serialize_job(db, job) return _serialize_job(db, job)
# Read the upload in 1 MiB chunks so a large file never sits fully in memory, and the size cap is # The upload's filename and display name travel as HEADERS, not as multipart parts. That is not a
# enforced on the ACTUAL bytes read (a client-set Content-Length can't be trusted to gate it). # style choice: a DECLARED body parameter (`file: UploadFile = File(...)`) is read and spooled by
_UPLOAD_CHUNK = 1024 * 1024 # FastAPI *before the first dependency runs*, so `require_human` could not gate it and an anonymous
# caller could make the process write the whole per-file allowance to temp, per request (P1; SEC-20
# bounded the number but could not stop the writing). Reading the body ourselves, from a dependency
# declared AFTER the auth one, is the only shape that closes it — the same one
# `POST /api/debug-sessions/{sid}/screenshots` uses, and `test_body_limit.py` now enforces it for
# every route with a raised ceiling rather than leaving it to a comment like this one.
#
# Headers are latin-1 on the wire, so both are percent-encoded by the client and unquoted here; a
# Hungarian filename would otherwise be unsendable.
UPLOAD_FILENAME_HEADER = "x-upload-filename"
UPLOAD_DISPLAY_NAME_HEADER = "x-upload-display-name"
@router.post("/upload") @dataclass(frozen=True)
def upload_download( class SpooledUpload:
file: UploadFile = File(...), """What the body dependency hands the handler: the bytes already on disk, plus the two names
display_name: str | None = Form(None), that came alongside them."""
path: Path
size: int
user_id: int
filename: str
display_name: str | None
def _header_text(request: Request, name: str) -> str:
"""One percent-encoded header, decoded. A header that is not valid UTF-8 once unquoted is the
caller's doing, not a server fault — it degrades to empty rather than 500ing, and the route's
own fallback names the file."""
raw = request.headers.get(name, "")
try:
return unquote(raw, errors="strict").strip()
except (UnicodeDecodeError, ValueError):
# silent-ok: a mis-encoded header is the caller's own malformed request, and this one is
# cosmetic — it names the file. Refusing the upload over it would fail a 500 MiB transfer on
# a label; logging it would hand any client a free log line per request.
return ""
async def spool_upload(
request: Request,
user: User = Depends(require_human), user: User = Depends(require_human),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> AsyncIterator[SpooledUpload]:
"""Ingest a local file into the caller's library (E7 S5). Any file type is accepted; media """Stream the request body to a staging temp file, enforcing the per-file cap as it arrives.
(video/audio) gets the full function set, a generic file is download-only. The file is streamed
to a temp path under the download volume (enforcing the per-file cap as it goes), then handed to
the non-yt-dlp ingest which probes + places it and returns an already-`done` job.
A sync (threadpool) handler on purpose: the ingest does blocking work — an ffprobe of up to 30s `user` is taken but unused: it is the whole point. Dependencies resolve in declaration order, so
plus sync DB writes — so it must NOT run on the event loop. `file.file` is the sync file object naming `require_human` here makes the auth check a precondition of reading the body at all — a
Starlette spools the multipart body into.""" stranger is refused before a byte moves. The `yield` shape owns the temp file's life: ingest
MOVES it into place on success (the unlink is then a no-op), and on any other exit — cap hit,
empty, quota, probe or move error, a client that vanished mid-upload — it is removed."""
cap = sysconfig.get_int(db, "download_upload_max_bytes") cap = sysconfig.get_int(db, "download_upload_max_bytes")
user_id = user.id
# Hand the pooled connection back BEFORE the transfer starts — see `db.release_read_connection`
# for why, and for the precondition it enforces. Everything this dependency still needs is
# already an int or a header in hand: touching an ORM attribute below would reload `user` and
# check a connection straight back out.
release_read_connection(db)
staging = Path(settings.download_root) / ".staging" / "uploads" staging = Path(settings.download_root) / ".staging" / "uploads"
staging.mkdir(parents=True, exist_ok=True) staging.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(staging)) fd, tmp = tempfile.mkstemp(dir=str(staging))
tmp_path = Path(tmp) tmp_path = Path(tmp)
try: try:
size = 0
with os.fdopen(fd, "wb") as out: with os.fdopen(fd, "wb") as out:
while chunk := file.file.read(_UPLOAD_CHUNK): size = await capped_stream_to(
size += len(chunk) request,
if cap and size > cap: out,
raise HTTPException( cap,
status_code=422, detail={"code": "upload_too_large", "limit": cap} HTTPException(status_code=422, detail={"code": "upload_too_large", "limit": cap}),
)
out.write(chunk)
if size == 0:
raise HTTPException(status_code=400, detail={"code": "upload_empty"})
try:
job = uploadmod.ingest_upload(
db,
user.id,
filename=file.filename or "upload",
src_path=tmp_path,
size=size,
display_name=(display_name or "").strip() or None,
) )
except quota.QuotaExceeded as e: yield SpooledUpload(
raise HTTPException(status_code=422, detail=_quota_detail(e)) path=tmp_path,
return _serialize_job(db, job) size=size,
user_id=user_id,
filename=_header_text(request, UPLOAD_FILENAME_HEADER) or "upload",
display_name=display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER)),
)
finally: finally:
# Ingest MOVES the temp file into place on success (unlink is then a no-op); on any early exit
# — cap hit, empty, quota, probe/move error — this removes the abandoned staging file.
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
@router.post("/upload")
def upload_download(
upload: SpooledUpload = Depends(spool_upload),
db: Session = Depends(get_db),
) -> dict:
"""Ingest a local file into the caller's library (E7 S5). Any file type is accepted; media
(video/audio) gets the full function set, a generic file is download-only. The body IS the file
(see the header note above); `spool_upload` has already authenticated the caller and streamed it
to a temp path under the download volume, so what is left here is the non-yt-dlp ingest which
probes + places it and returns an already-`done` job.
A sync (threadpool) handler on purpose: the ingest does blocking work — an ffprobe of up to 30s
plus sync DB writes — so it must NOT run on the event loop. The streaming half is async because
reading the body is; keeping them in separate callables is what lets each run where it belongs."""
if upload.size == 0:
raise HTTPException(status_code=400, detail={"code": "upload_empty"})
try:
job = uploadmod.ingest_upload(
db,
upload.user_id,
filename=upload.filename,
src_path=upload.path,
size=upload.size,
display_name=upload.display_name,
)
except quota.QuotaExceeded as e:
raise HTTPException(status_code=422, detail=_quota_detail(e)) from e
return _serialize_job(db, job)
class EnqueueEditIn(BaseModel): class EnqueueEditIn(BaseModel):
source_job_id: int = 0 source_job_id: int = 0
edit_spec: dict edit_spec: dict
@@ -484,15 +541,15 @@ def enqueue_edit(
source file is only read, never modified, so editing a shared video is safe.""" source file is only read, never modified, so editing a shared video is safe."""
source_job = _accessible_job(db, user, body.source_job_id) source_job = _accessible_job(db, user, body.source_job_id)
if source_job.status != "done" or source_job.asset_id is None: if source_job.status != "done" or source_job.asset_id is None:
raise HTTPException(status_code=409, detail="You can only edit a finished download.") raise refused(409, "edit_unfinished")
spec = body.edit_spec spec = body.edit_spec
display_name = (body.display_name or "").strip() or None display_name = display_text(body.display_name)
try: try:
job = service.enqueue_edit(db, user.id, source_job, spec, display_name) job = service.enqueue_edit(db, user.id, source_job, spec, display_name)
except quota.QuotaExceeded as e: except quota.QuotaExceeded as e:
raise HTTPException(status_code=422, detail=_quota_detail(e)) raise HTTPException(status_code=422, detail=_quota_detail(e)) from e
except service.EditError as e: except service.EditError as e:
raise HTTPException(status_code=400, detail={"code": "edit", "reason": e.reason}) raise HTTPException(status_code=400, detail={"code": "edit", "reason": e.reason}) from e
return _serialize_job(db, job) return _serialize_job(db, job)
@@ -522,7 +579,20 @@ def list_downloads(
@router.get("/usage") @router.get("/usage")
def my_usage(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict: def my_usage(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
return quota.usage(db, user.id) """The caller's download quota, plus the per-file upload cap.
The cap rides here rather than in `quota.usage` because it is not a quota value — it is a
`sysconfig` knob about one route — and this is the route layer, where composing the two is the
job. The upload dialog needs it to refuse an over-cap file BEFORE sending it: both server-side
caps answer too late to be pleasant (the edge ceiling refuses the request outright, see
`bodylimit.ROUTE_MAX_BODY_BYTES`), and making the user push 600 MB up the wire to be told the
limit is 500 is a bad answer even when it is a correct one. It is a convenience, not the
boundary: the caps stay where they are and still refuse a client that ignores this.
"""
return {
**quota.usage(db, user.id),
"upload_max_bytes": sysconfig.get_int(db, "download_upload_max_bytes"),
}
# Priority when a source has several jobs (e.g. a done one plus a fresh queued one). # Priority when a source has several jobs (e.g. a done one plus a fresh queued one).
@@ -587,6 +657,8 @@ def _clean_url(raw) -> str | None:
# authority. This one already has a "not a plausible URL" answer, so the typo belongs in it. # authority. This one already has a "not a plausible URL" answer, so the typo belongs in it.
try: try:
parsed = urlparse(url) parsed = urlparse(url)
# silent-ok: a typo in a link someone pasted. None is this function's "not a plausible URL"
# answer, the caller already has one, and any line here would carry the URL itself.
except ValueError: except ValueError:
return None return None
if not parsed.netloc: if not parsed.netloc:
@@ -612,12 +684,12 @@ def update_download_meta(
) -> dict: ) -> dict:
"""Update a download's display metadata: title, channel name + link, and extra reference URLs. """Update a download's display metadata: title, channel name + link, and extra reference URLs.
All are display-only overrides — the on-disk file keeps its system-decided, id-bound name.""" All are display-only overrides — the on-disk file keeps its system-decided, id-bound name."""
job = _own_job(db, user, job_id) job = owned_or_404(db, DownloadJob, job_id, user, what="download")
sent = body.model_fields_set sent = body.model_fields_set
if "display_name" in sent: if "display_name" in sent:
job.display_name = ((body.display_name or "").strip())[:255] or None job.display_name = display_text(body.display_name)
if "display_uploader" in sent: if "display_uploader" in sent:
job.display_uploader = ((body.display_uploader or "").strip())[:255] or None job.display_uploader = display_text(body.display_uploader)
if "display_uploader_url" in sent: if "display_uploader_url" in sent:
job.display_uploader_url = _clean_url(body.display_uploader_url) job.display_uploader_url = _clean_url(body.display_uploader_url)
if "extra_links" in sent: if "extra_links" in sent:
@@ -666,9 +738,9 @@ def redownload_download(
if job.job_kind in ("edit", "upload"): if job.job_kind in ("edit", "upload"):
# Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the # Neither has a re-fetchable source: an editor clip is derived locally, an upload IS the
# source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail. # source. Re-running yt-dlp against source_kind='upload'/'edit' would just fail.
raise HTTPException(status_code=409, detail="This item can't be re-downloaded.") raise refused(409, "not_redownloadable")
if job.status in ("queued", "running", "paused"): if job.status in ("queued", "running", "paused"):
raise HTTPException(status_code=409, detail="This download is still in progress.") raise refused(409, "still_downloading")
service.redownload(db, job) service.redownload(db, job)
return _serialize_job(db, job) return _serialize_job(db, job)
@@ -697,7 +769,7 @@ def delete_download(
def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob: def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
job = db.get(DownloadJob, job_id) job = db.get(DownloadJob, job_id)
if job is None: if job is None:
raise HTTPException(status_code=404, detail="Unknown download") raise not_found("download")
if job.user_id == user.id: if job.user_id == user.id:
return job return job
shared = db.execute( shared = db.execute(
@@ -706,7 +778,7 @@ def _accessible_job(db: Session, user: User, job_id: int) -> DownloadJob:
) )
).first() ).first()
if shared is None: if shared is None:
raise HTTPException(status_code=404, detail="Unknown download") raise not_found("download")
return job return job
@@ -720,10 +792,10 @@ def download_file(
job = _accessible_job(db, user, job_id) job = _accessible_job(db, user, job_id)
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
if asset is None or asset.status != "ready" or not asset.rel_path: if asset is None or asset.status != "ready" or not asset.rel_path:
raise HTTPException(status_code=409, detail="This download isn't ready.") raise refused(409, "download_not_ready")
path = storage.safe_abs_path(settings.download_root, asset.rel_path) path = storage.safe_abs_path(settings.download_root, asset.rel_path)
if path is None: if path is None:
raise HTTPException(status_code=404, detail="File is no longer available.") raise not_found("file")
filename = storage.download_filename( filename = storage.download_filename(
job.display_name or asset.title or asset.source_ref, asset.container, path job.display_name or asset.title or asset.source_ref, asset.container, path
@@ -762,7 +834,7 @@ def storyboard_meta(
job = _accessible_job(db, user, job_id) job = _accessible_job(db, user, job_id)
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
if asset is None or asset.status != "ready" or not asset.rel_path: if asset is None or asset.status != "ready" or not asset.rel_path:
raise HTTPException(status_code=409, detail="This download isn't ready.") raise refused(409, "download_not_ready")
meta = editmod.ensure_storyboard(asset, settings.download_root) meta = editmod.ensure_storyboard(asset, settings.download_root)
if meta is None: if meta is None:
return {"available": False} return {"available": False}
@@ -783,10 +855,10 @@ def poster_image(job_id: int, user: User = Depends(require_human), db: Session =
job = _accessible_job(db, user, job_id) job = _accessible_job(db, user, job_id)
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
if asset is None or not asset.poster_path: if asset is None or not asset.poster_path:
raise HTTPException(status_code=404, detail="No poster.") raise not_found("poster")
path = storage.safe_abs_path(settings.download_root, asset.poster_path) path = storage.safe_abs_path(settings.download_root, asset.poster_path)
if path is None: if path is None:
raise HTTPException(status_code=404, detail="No poster.") raise not_found("poster")
return FileResponse(path, media_type="image/jpeg") return FileResponse(path, media_type="image/jpeg")
@@ -797,10 +869,10 @@ def storyboard_image(
job = _accessible_job(db, user, job_id) job = _accessible_job(db, user, job_id)
asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None asset = db.get(MediaAsset, job.asset_id) if job.asset_id else None
if asset is None or not asset.storyboard_path: if asset is None or not asset.storyboard_path:
raise HTTPException(status_code=404, detail="No filmstrip.") raise not_found("filmstrip")
path = storage.safe_abs_path(settings.download_root, asset.storyboard_path) path = storage.safe_abs_path(settings.download_root, asset.storyboard_path)
if path is None: if path is None:
raise HTTPException(status_code=404, detail="No filmstrip.") raise not_found("filmstrip")
return FileResponse(path, media_type="image/jpeg") return FileResponse(path, media_type="image/jpeg")
@@ -818,12 +890,12 @@ def share_download(
user: User = Depends(require_human), user: User = Depends(require_human),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
job = _own_job(db, user, job_id) job = owned_or_404(db, DownloadJob, job_id, user, what="download")
if job.status != "done" or job.asset_id is None: if job.status != "done" or job.asset_id is None:
raise HTTPException(status_code=409, detail="Only a finished download can be shared.") raise refused(409, "share_unfinished")
target = body.email.strip().lower() target = body.email.strip().lower()
if not target: if not target:
raise HTTPException(status_code=400, detail="A recipient email is required.") raise refused(400, "recipient_required")
recipient = db.execute( recipient = db.execute(
select(User).where(func.lower(User.email) == target) select(User).where(func.lower(User.email) == target)
).scalar_one_or_none() ).scalar_one_or_none()
@@ -832,7 +904,7 @@ def share_download(
if not is_messageable_user(recipient): if not is_messageable_user(recipient):
raise not_found("recipient") raise not_found("recipient")
if recipient.id == user.id: if recipient.id == user.id:
raise HTTPException(status_code=400, detail="That's already your download.") raise refused(400, "share_own")
exists = db.execute( exists = db.execute(
select(DownloadShare.id).where( select(DownloadShare.id).where(
DownloadShare.job_id == job_id, DownloadShare.to_user_id == recipient.id DownloadShare.job_id == job_id, DownloadShare.to_user_id == recipient.id
@@ -847,11 +919,18 @@ def share_download(
user_id=recipient.id, user_id=recipient.id,
type="download_shared", type="download_shared",
title=(job.display_name or "A download") + " was shared with you", title=(job.display_name or "A download") + " was shared with you",
data={"kind": "download_shared", "from": user.email, "job_id": job_id}, # The client names the notice in the reader's language from `data` (E30); the title is the
# English fallback only.
data={
"kind": "download_shared",
"from": user.email,
"job_id": job_id,
"title": job.display_name,
},
commit=False, commit=False,
) )
db.commit() db.commit()
return {"shared_with": recipient.email} return {"ok": True, "shared_with": recipient.email}
@router.delete("/{job_id}/share/{email}") @router.delete("/{job_id}/share/{email}")
@@ -861,7 +940,9 @@ def unshare_download(
user: User = Depends(require_human), user: User = Depends(require_human),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
_own_job(db, user, job_id) # ownership guard (404s if not the caller's job) owned_or_404(
db, DownloadJob, job_id, user, what="download"
) # ownership guard (404s if not the caller's job)
recipient = db.execute( recipient = db.execute(
select(User).where(func.lower(User.email) == email.strip().lower()) select(User).where(func.lower(User.email) == email.strip().lower())
).scalar_one_or_none() ).scalar_one_or_none()
@@ -893,7 +974,7 @@ def remove_shared_with_me(
if row is not None: if row is not None:
db.delete(row) db.delete(row)
db.commit() db.commit()
return {"removed": job_id} return {"ok": True, "removed": job_id}
# --- recipients (for the internal "share with a user" picker) ------------------------------ # --- recipients (for the internal "share with a user" picker) ------------------------------
@@ -909,7 +990,7 @@ def share_recipients(
db.execute( db.execute(
select(User) select(User)
.where(User.id != user.id, *messageable_clauses()) .where(User.id != user.id, *messageable_clauses())
.order_by(func.lower(User.email)) .order_by(collated(User.email, lang_for_user(user)))
) )
.scalars() .scalars()
.all() .all()
@@ -923,10 +1004,10 @@ def share_recipients(
def _own_link(db: Session, user: User, link_id: int) -> DownloadLink: def _own_link(db: Session, user: User, link_id: int) -> DownloadLink:
link = db.get(DownloadLink, link_id) link = db.get(DownloadLink, link_id)
if link is None: if link is None:
raise HTTPException(status_code=404, detail="Unknown link") raise not_found("link")
job = db.get(DownloadJob, link.job_id) job = db.get(DownloadJob, link.job_id)
if job is None or job.user_id != user.id: if job is None or job.user_id != user.id:
raise HTTPException(status_code=404, detail="Unknown link") raise not_found("link")
return link return link
@@ -942,7 +1023,7 @@ def _link_expiry(days: int | None) -> datetime | None:
def list_links( def list_links(
job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) job_id: int, user: User = Depends(require_human), db: Session = Depends(get_db)
) -> list[dict]: ) -> list[dict]:
_own_job(db, user, job_id) owned_or_404(db, DownloadJob, job_id, user, what="download")
rows = ( rows = (
db.execute( db.execute(
select(DownloadLink) select(DownloadLink)
@@ -973,9 +1054,9 @@ def create_link(
user: User = Depends(require_human), user: User = Depends(require_human),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
job = _own_job(db, user, job_id) job = owned_or_404(db, DownloadJob, job_id, user, what="download")
if job.status != "done" or job.asset_id is None: if job.status != "done" or job.asset_id is None:
raise HTTPException(status_code=409, detail="Only a finished download can be shared.") raise refused(409, "share_unfinished")
pw = body.password.strip() pw = body.password.strip()
link = DownloadLink( link = DownloadLink(
job_id=job_id, job_id=job_id,
+47 -23
View File
@@ -1,6 +1,6 @@
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, func, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -10,6 +10,7 @@ from sqlalchemy.orm.exc import StaleDataError
from app import quota from app import quota
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.mailtext import lang_for_user
from app.feedquery import ( from app.feedquery import (
decode_cursor, decode_cursor,
encode_cursor, encode_cursor,
@@ -18,8 +19,11 @@ from app.feedquery import (
filtered_query, filtered_query,
keyset_predicate, keyset_predicate,
order_by, order_by,
rank_window_start,
serialize, serialize,
sort_keys, sort_keys,
windowed_rank,
windowed_relevance_rows,
) )
from app.models import ( from app.models import (
POSITION_MAX_S, POSITION_MAX_S,
@@ -29,10 +33,11 @@ from app.models import (
Video, Video,
VideoState, VideoState,
) )
from app.routes._common import get_or_404 from app.routes._common import get_or_404, youtube_refusal, not_found
from app.sync.videos import parse_iso8601_duration from app.sync.videos import parse_iso8601_duration
from app.utils import iso from app.utils import iso
from app.youtube.client import YouTubeClient, YouTubeError from app.youtube.client import YouTubeClient, YouTubeError
from app.refusal import refused
router = APIRouter(prefix="/api", tags=["feed"]) router = APIRouter(prefix="/api", tags=["feed"])
@@ -107,30 +112,45 @@ def get_feed(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
query, rank_expr = filtered_query(db, user, **params) query, rank_expr = filtered_query(db, user, **params)
keys = sort_keys(sort, seed, rank_expr) lang = lang_for_user(user)
keys = sort_keys(sort, seed, rank_expr, lang)
# Expose each key column on the row so we can build the next cursor from the last item.
query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)])
query = query.order_by(*order_by(keys))
vals, cursor_id, window = None, None, None
if cursor: if cursor:
# The read-model raises a plain ValueError on a malformed cursor; translate it to the # The read-model raises a plain ValueError on a malformed cursor; translate it to the
# HTTP 400 here so feedquery stays web-framework-free. # HTTP 400 here so feedquery stays web-framework-free.
try: try:
vals, cursor_id = decode_cursor(keys, cursor) vals, cursor_id, window = decode_cursor(keys, cursor)
except ValueError: except ValueError:
# `from None`: the ValueError is an expected bad-input signal, not an error to chain. # `from None`: the ValueError is an expected bad-input signal, not an error to chain.
raise HTTPException(status_code=400, detail="Invalid feed cursor") from None raise refused(400, "invalid_cursor") from None
for k, v in zip(keys, vals): ranked = sort == "relevance" and rank_expr is not None
k["value"] = v if ranked and not cursor:
query = query.where(keyset_predicate(keys, cursor_id)) window = rank_window_start(db, query)
if ranked and window is not None:
keys = sort_keys(sort, seed, windowed_rank(rank_expr, window), lang)
rows = db.execute(query.limit(limit + 1)).all() # Expose each key column on the row so we can build the next cursor from the last item.
query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)])
if vals is not None:
for k, v in zip(keys, vals, strict=True):
k["value"] = v
if ranked and window is not None:
rows = windowed_relevance_rows(db, query, keys, window, cursor_id, limit + 1)
else:
query = query.order_by(*order_by(keys))
if cursor_id is not None:
query = query.where(keyset_predicate(keys, cursor_id))
rows = db.execute(query.limit(limit + 1)).all()
has_more = len(rows) > limit has_more = len(rows) > limit
page = rows[:limit] page = rows[:limit]
return { return {
"items": [serialize(r) for r in page], "items": [serialize(r) for r in page],
"next_cursor": encode_cursor(keys, page[-1]) if (has_more and page) else None, "next_cursor": (
encode_cursor(keys, page[-1], window if ranked else None)
if (has_more and page)
else None
),
"has_more": has_more, "has_more": has_more,
"limit": limit, "limit": limit,
} }
@@ -235,7 +255,7 @@ def set_video_state(
) -> dict: ) -> dict:
status = body.status status = body.status
if status not in VALID_STATES: if status not in VALID_STATES:
raise HTTPException(status_code=400, detail=f"status must be one of {VALID_STATES}") raise refused(400, "invalid_status")
get_or_404(db, Video, video_id, what="video") get_or_404(db, Video, video_id, what="video")
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -256,9 +276,11 @@ def set_video_state(
db.delete(row) db.delete(row)
try: try:
db.commit() db.commit()
# silent-ok: the documented lost race — a concurrent player checkpoint already removed
# or moved this row. The state the caller asked for is the state that holds either way.
except StaleDataError: except StaleDataError:
db.rollback() db.rollback()
return {"video_id": video_id, "status": "new"} return {"ok": True, "video_id": video_id, "status": "new"}
# watched / hidden: atomic upsert. The player marks "watched" at end-of-playback at # watched / hidden: atomic upsert. The player marks "watched" at end-of-playback at
# the same instant a progress checkpoint may DELETE the in-progress row; a plain # the same instant a progress checkpoint may DELETE the in-progress row; a plain
@@ -279,7 +301,7 @@ def set_video_state(
) )
db.execute(stmt) db.execute(stmt)
db.commit() db.commit()
return {"video_id": video_id, "status": status} return {"ok": True, "video_id": video_id, "status": status}
@router.delete("/videos/{video_id}/state") @router.delete("/videos/{video_id}/state")
@@ -298,9 +320,10 @@ def clear_video_state(
db.delete(row) db.delete(row)
try: try:
db.commit() db.commit()
# silent-ok: the row went under the delete, which is the outcome the delete wanted.
except StaleDataError: except StaleDataError:
db.rollback() db.rollback()
return {"video_id": video_id, "status": "new", "position_seconds": 0} return {"ok": True, "video_id": video_id, "status": "new", "position_seconds": 0}
class VideoProgressIn(BaseModel): class VideoProgressIn(BaseModel):
@@ -355,9 +378,10 @@ def set_video_progress(
# have removed/updated this row already — tolerate the lost race. # have removed/updated this row already — tolerate the lost race.
try: try:
db.commit() db.commit()
# silent-ok: exactly the race the comment above describes; the response stays true.
except StaleDataError: except StaleDataError:
db.rollback() db.rollback()
return {"video_id": video_id, "position_seconds": 0} return {"ok": True, "video_id": video_id, "position_seconds": 0}
if row is None: if row is None:
row = VideoState(user_id=user.id, video_id=video_id) row = VideoState(user_id=user.id, video_id=video_id)
@@ -365,7 +389,7 @@ def set_video_progress(
row.position_seconds = position row.position_seconds = position
row.progress_updated_at = datetime.now(timezone.utc) row.progress_updated_at = datetime.now(timezone.utc)
db.commit() db.commit()
return {"video_id": video_id, "position_seconds": position} return {"ok": True, "video_id": video_id, "position_seconds": position}
@router.get("/videos/{video_id}") @router.get("/videos/{video_id}")
@@ -398,7 +422,7 @@ def get_video_detail(
# The DB path above is free; the off-catalog YouTube lookup below spends a quota unit, so the # The DB path above is free; the off-catalog YouTube lookup below spends a quota unit, so the
# demo account can't reach it — it just sees "unknown" for a video we don't already store. # demo account can't reach it — it just sees "unknown" for a video we don't already store.
if user.is_demo: if user.is_demo:
raise HTTPException(status_code=404, detail="Unknown video") raise not_found("video")
try: try:
with ( with (
@@ -407,9 +431,9 @@ def get_video_detail(
): ):
items = yt.get_videos([video_id]) items = yt.get_videos([video_id])
except YouTubeError as exc: except YouTubeError as exc:
raise HTTPException(status_code=422, detail=f"YouTube lookup failed: {exc}") raise youtube_refusal(exc) from exc
if not items: if not items:
raise HTTPException(status_code=404, detail="Unknown video") raise not_found("video")
snippet = items[0].get("snippet", {}) snippet = items[0].get("snippet", {})
stats = items[0].get("statistics", {}) stats = items[0].get("statistics", {})
+66 -16
View File
@@ -1,5 +1,7 @@
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel, RootModel
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -15,10 +17,16 @@ from app.auth import (
optional_current_user, optional_current_user,
purge_user, purge_user,
) )
from app import debugsession, diagnostics
from app.issues import gitea
from app import email as email_mod from app import email as email_mod
from app import logs
from app import sysconfig from app import sysconfig
from app.db import get_db from app.db import get_db
from app.models import Invite, User from app.models import Invite, User
from app.utils import iso
from app.refusal import refused
from app.routes._common import not_found
router = APIRouter(prefix="/api/me", tags=["me"]) router = APIRouter(prefix="/api/me", tags=["me"])
@@ -65,7 +73,7 @@ def switch_account(
here) are allowed, and access is re-checked in case the invite was revoked since.""" here) are allowed, and access is re-checked in case the invite was revoked since."""
target = body.user_id target = body.user_id
if target not in (request.session.get("account_ids") or []): if target not in (request.session.get("account_ids") or []):
raise HTTPException(status_code=403, detail="Not an account you've signed into here.") raise refused(403, "not_your_account")
# An account `_session_user` would refuse — gone, deactivated, suspended, or its session revoked — is # An account `_session_user` would refuse — gone, deactivated, suspended, or its session revoked — is
# refused here too, and dropped from the switch list the way `_session_user` drops it. Made the session # refused here too, and dropped from the switch list the way `_session_user` drops it. Made the session
# default instead, the next request's current_user would refuse it as the DEFAULT and clear the whole # default instead, the next request's current_user would refuse it as the DEFAULT and clear the whole
@@ -73,22 +81,20 @@ def switch_account(
u = db.get(User, target) u = db.get(User, target)
if u is None: if u is None:
drop_from_wallet(request.session, target) drop_from_wallet(request.session, target)
raise HTTPException(status_code=404, detail="That account no longer exists.") raise not_found("account")
if not is_allowed(db, u.email): if not is_allowed(db, u.email):
raise HTTPException(status_code=403, detail="That account no longer has access.") raise refused(403, "account_no_access")
if not u.is_active: if not u.is_active:
drop_from_wallet(request.session, target) drop_from_wallet(request.session, target)
raise HTTPException(status_code=403, detail="That account no longer has access.") raise refused(403, "account_no_access")
if u.is_suspended: if u.is_suspended:
drop_from_wallet(request.session, target) drop_from_wallet(request.session, target)
raise HTTPException(status_code=403, detail="That account is suspended.") raise refused(403, "account_suspended")
# Revoked: a password reset/change or "log out everywhere" bumped the account's epoch. # Revoked: a password reset/change or "log out everywhere" bumped the account's epoch.
epochs = request.session.get("epochs") or {} epochs = request.session.get("epochs") or {}
if int(epochs.get(str(target), 0)) != (u.session_epoch or 0): if int(epochs.get(str(target), 0)) != (u.session_epoch or 0):
drop_from_wallet(request.session, target) drop_from_wallet(request.session, target)
raise HTTPException( raise refused(403, "account_signed_out")
status_code=403, detail="That account was signed out elsewhere — sign in again."
)
request.session["user_id"] = target request.session["user_id"] = target
return {"ok": True, "user_id": target} return {"ok": True, "user_id": target}
@@ -134,11 +140,17 @@ def get_me(
user.google_email if user.google_email and user.google_email != user.email else None user.google_email if user.google_email and user.google_email != user.email else None
), ),
"has_password": user.password_hash is not None, "has_password": user.password_hash is not None,
# The account's latest sign-in on any device — shown in Settings → Account, so a sign-in the
# user does not recognise is something they can see (null: none since 0.88, or the demo).
"last_login_at": iso(user.last_login_at),
# Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides # Instance-wide: whether Google sign-in / YouTube connect is available at all (the UI hides
# YouTube-access affordances and the onboarding nudge when it isn't configured). # YouTube-access affordances and the onboarding nudge when it isn't configured).
"google_enabled": google_enabled(db), "google_enabled": google_enabled(db),
# Whether the optional Plex module is enabled (UI shows the Plex feed source when so). # Whether the optional Plex module is enabled (UI shows the Plex feed source when so).
"plex_enabled": sysconfig.get_bool(db, "plex_enabled"), "plex_enabled": sysconfig.get_bool(db, "plex_enabled"),
# Which live-search source runs, so the search button can say what a search costs: "scrape"
# spends no API quota, "api" spends 100 units a page.
"search_source": sysconfig.get_str(db, "search_source"),
"can_read": has_read_scope(user), "can_read": has_read_scope(user),
"can_write": has_write_scope(user), "can_write": has_write_scope(user),
"pending_invites": pending_invites, "pending_invites": pending_invites,
@@ -147,9 +159,21 @@ def get_me(
"pending_activations": pending_activations, "pending_activations": pending_activations,
"email_configured": email_configured, "email_configured": email_configured,
"preferences": user.preferences or {}, "preferences": user.preferences or {},
# The consented diagnostics level in force and when it lapses (E22 S5). The client applies
# it to its own log and streams that log while it is raised.
"diagnostics": diagnostics.state(db, user),
# The live debug session, if one is recording (E16) — a reload resumes it.
"debug_session": _live_session(db, user),
# Where a bug report goes (E16 S4): the tracker's repository, or None — download only.
"issues": gitea.target(db),
} }
def _live_session(db: Session, user: User) -> dict | None:
session = debugsession.open_session(db, user)
return None if session is None else debugsession.view(db, session)
@router.delete("/account") @router.delete("/account")
def delete_my_account( def delete_my_account(
request: Request, request: Request,
@@ -170,10 +194,7 @@ def delete_my_account(
if user.is_demo: if user.is_demo:
raise HTTPException(status_code=403, detail={"code": "demo", "reason": "delete_account"}) raise HTTPException(status_code=403, detail={"code": "demo", "reason": "delete_account"})
if user.role == "admin" and count_admins(db) <= 1: if user.role == "admin" and count_admins(db) <= 1:
raise HTTPException( raise refused(400, "only_admin_delete")
status_code=400,
detail="You're the only admin — promote another admin before deleting your account.",
)
user_id = user.id user_id = user.id
# Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the # Full erasure (cascades) + access-request cleanup + Google-grant revocation; shared with the
# admin delete path. Capture the id first — `user` is detached once purge_user deletes the row. # admin delete path. Capture the id first — `user` is detached once purge_user deletes the row.
@@ -188,16 +209,45 @@ def delete_my_account(
return {"deleted": True} return {"deleted": True}
class PreferencesIn(RootModel[dict[str, Any]]):
"""Free-form UI preferences — a JSON object of any keys (theme, filters, …), merged into the
stored ones. A model rather than a bare `dict` parameter so a non-object body is a 422 at the
boundary like every other request body."""
@router.put("/preferences") @router.put("/preferences")
def update_preferences( def update_preferences(
preferences: dict, preferences: PreferencesIn,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
# Merge so partial updates don't wipe other keys. # Merge so partial updates don't wipe other keys.
merged = dict(user.preferences or {}) merged = dict(user.preferences or {})
merged.update(preferences) merged.update(preferences.root)
user.preferences = merged user.preferences = merged
db.add(user) db.add(user)
db.commit() db.commit()
return {"preferences": merged} return {"ok": True, "preferences": merged}
class DiagnosticsIn(BaseModel):
level: diagnostics.DiagnosticsLevel
@router.put("/diagnostics")
def update_diagnostics(
body: DiagnosticsIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Raise the diagnostics level (the user consented in Settings → Diagnosztika) or return to the
base. The expiry is the server's: a raised level always lapses after
`diagnostics.ELEVATION_HOURS`, whatever the client asks. The shared demo account cannot raise
it — its log would interleave every visitor's session under one account."""
if user.is_demo and body.level != diagnostics.BASE:
raise HTTPException(status_code=403, detail={"code": "demo", "reason": "diagnostics"})
diagnostics.set_level(user, body.level)
db.commit()
# The rest of THIS request, and the process's elevation registry, follow the new level now.
logs.set_user(user.id, diagnostics.effective_level(user), user.diagnostics_until)
return {"ok": True, "diagnostics": diagnostics.state(db, user)}
+45 -21
View File
@@ -16,11 +16,13 @@ routing and unread counts.
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import and_, case, delete, func, or_, select from sqlalchemy import and_, case, delete, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.mailtext import lang_for_user
from app.collation import collated
from app.auth import require_human, resolved_user_id from app.auth import require_human, resolved_user_id
from app.db import SessionLocal, get_db from app.db import SessionLocal, get_db
from app.models import Message, MessageKey, User from app.models import Message, MessageKey, User
@@ -29,6 +31,8 @@ from app.realtime import manager
from app.security import verify_password from app.security import verify_password
from app.userscope import is_messageable_user, messageable_clauses from app.userscope import is_messageable_user, messageable_clauses
from app.utils import iso from app.utils import iso
from app.refusal import refused
from app.routes._common import not_found
router = APIRouter(prefix="/api/messages", tags=["messages"]) router = APIRouter(prefix="/api/messages", tags=["messages"])
@@ -90,13 +94,31 @@ def _system_partner() -> dict:
return {"id": SYSTEM_PARTNER_ID, "name": SYSTEM_NAME, "avatar_url": None} return {"id": SYSTEM_PARTNER_ID, "name": SYSTEM_NAME, "avatar_url": None}
def _serialize_msg(m: Message) -> dict: # The welcome's opening line in every language — how a stored copy is recognised. It was rendered
# once, in the language of the day it was created, so an account that later switched language kept
# reading it in the old one (E31 S1: the owner's, in English). Recognised here, it is served in the
# reader's language instead. Derived from WELCOME itself, so a change to an OPENING line, or a
# language taken out of WELCOME, orphans the copies already stored: they are served as stored. The
# one retired language (German, 2026-07) left none on prod (checked 2026-10-02).
_WELCOME_OPENINGS = {text.partition("\n")[0] for text in WELCOME.values()}
def _system_body(body: str | None, lang: str | None) -> str | None:
"""A system message's text for a reader of `lang`: the welcome in their language, any other as
stored."""
if body is None or lang is None or body.partition("\n")[0] not in _WELCOME_OPENINGS:
return body
return WELCOME.get(lang, WELCOME["en"])
def _serialize_msg(m: Message, lang: str | None = None) -> dict:
"""`lang`: the reader's language, for a system message's text (a user message is ciphertext)."""
return { return {
"id": m.id, "id": m.id,
"kind": m.kind, "kind": m.kind,
"sender_id": m.sender_id, "sender_id": m.sender_id,
"recipient_id": m.recipient_id, "recipient_id": m.recipient_id,
"body": m.body, # only set for system messages "body": _system_body(m.body, lang) if m.kind == "system" else m.body,
"ciphertext": m.ciphertext, "ciphertext": m.ciphertext,
"iv": m.iv, "iv": m.iv,
"read_at": iso(m.read_at), "read_at": iso(m.read_at),
@@ -120,7 +142,7 @@ def ensure_welcome(db: Session, user: User) -> None:
db.add(m) db.add(m)
db.commit() db.commit()
db.refresh(m) db.refresh(m)
manager.push(user.id, {"type": "message", "message": _serialize_msg(m)}) manager.push(user.id, {"type": "message", "message": _serialize_msg(m, lang_for_user(user))})
# --- E2EE key directory / blob store ----------------------------------------- # --- E2EE key directory / blob store -----------------------------------------
@@ -152,7 +174,7 @@ def setup_keys(
"""Store this user's public key + passphrase-wrapped private key. Set-once: changing the """Store this user's public key + passphrase-wrapped private key. Set-once: changing the
key would orphan every existing conversation key, so re-setup is rejected here.""" key would orphan every existing conversation key, so re-setup is rejected here."""
if db.get(MessageKey, user.id) is not None: if db.get(MessageKey, user.id) is not None:
raise HTTPException(status_code=409, detail="Secure messaging is already set up.") raise refused(409, "messaging_set_up")
db.add( db.add(
MessageKey( MessageKey(
user_id=user.id, user_id=user.id,
@@ -164,7 +186,7 @@ def setup_keys(
) )
) )
db.commit() db.commit()
return {"configured": True} return {"ok": True, "configured": True}
@router.post("/keys/reset") @router.post("/keys/reset")
@@ -185,11 +207,11 @@ def reset_keys(
less (Google-only) account has nothing to re-verify, so it relies on its live session plus the less (Google-only) account has nothing to re-verify, so it relies on its live session plus the
client-side danger confirmation.""" client-side danger confirmation."""
if not _reset_key_limiter.allow(str(user.id)): if not _reset_key_limiter.allow(str(user.id)):
raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.") raise refused(429, "too_many_attempts")
if user.password_hash and not verify_password( if user.password_hash and not verify_password(
payload.current_password or "", user.password_hash payload.current_password or "", user.password_hash
): ):
raise HTTPException(status_code=403, detail="Current password is incorrect.") raise refused(403, "wrong_password")
# Delete the now-dead ciphertext first (both directions), then the wrapped keypair itself so a # Delete the now-dead ciphertext first (both directions), then the wrapped keypair itself so a
# fresh setup is allowed. Order doesn't matter (no FK between them), but do it in one commit. # fresh setup is allowed. Order doesn't matter (no FK between them), but do it in one commit.
db.execute( db.execute(
@@ -202,7 +224,7 @@ def reset_keys(
if key is not None: if key is not None:
db.delete(key) db.delete(key)
db.commit() db.commit()
return {"configured": False} return {"ok": True, "configured": False}
@router.get("/keys/{user_id}") @router.get("/keys/{user_id}")
@@ -223,10 +245,10 @@ def public_key(
p = db.get(User, user_id) p = db.get(User, user_id)
has_history = db.scalar(select(Message.id).where(cond).limit(1)) has_history = db.scalar(select(Message.id).where(cond).limit(1))
if p is None or (not is_messageable_user(p) and not has_history): if p is None or (not is_messageable_user(p) and not has_history):
raise HTTPException(status_code=404, detail="User not found") raise not_found("user")
k = db.get(MessageKey, user_id) k = db.get(MessageKey, user_id)
if k is None: if k is None:
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.") raise refused(404, "no_messaging")
return {"user_id": user_id, "public_key": k.public_key} return {"user_id": user_id, "public_key": k.public_key}
@@ -244,7 +266,7 @@ def list_recipients(
select(User) select(User)
.join(MessageKey, MessageKey.user_id == User.id) .join(MessageKey, MessageKey.user_id == User.id)
.where(User.id != user.id, *messageable_clauses()) .where(User.id != user.id, *messageable_clauses())
.order_by(func.lower(func.coalesce(User.display_name, User.email))) .order_by(collated(func.coalesce(User.display_name, User.email), lang_for_user(user)))
) )
.scalars() .scalars()
.all() .all()
@@ -309,7 +331,7 @@ def list_conversations(user: User = Depends(require_human), db: Session = Depend
items.append( items.append(
{ {
"partner": partner, "partner": partner,
"last_message": _serialize_msg(m), "last_message": _serialize_msg(m, lang_for_user(user)),
"unread": int(unread.get(pid, 0)), "unread": int(unread.get(pid, 0)),
} }
) )
@@ -344,7 +366,7 @@ def get_thread(
# still opens). Otherwise the same 404 as a non-existent id → no enumeration oracle. # still opens). Otherwise the same 404 as a non-existent id → no enumeration oracle.
has_history = db.scalar(select(Message.id).where(cond).limit(1)) has_history = db.scalar(select(Message.id).where(cond).limit(1))
if p is None or (not is_messageable_user(p) and not has_history): if p is None or (not is_messageable_user(p) and not has_history):
raise HTTPException(status_code=404, detail="User not found") raise not_found("user")
partner = _serialize_user(p) partner = _serialize_user(p)
msgs = ( msgs = (
db.execute(select(Message).where(cond).order_by(Message.created_at.asc())).scalars().all() db.execute(select(Message).where(cond).order_by(Message.created_at.asc())).scalars().all()
@@ -362,7 +384,7 @@ def get_thread(
# refreshes live instead of waiting for the next 20s poll. The client re-fetches the # refreshes live instead of waiting for the next 20s poll. The client re-fetches the
# count on this signal, so no number is carried here. # count on this signal, so no number is carried here.
manager.push(user.id, {"type": "unread"}) manager.push(user.id, {"type": "unread"})
return {"partner": partner, "items": [_serialize_msg(m) for m in msgs]} return {"partner": partner, "items": [_serialize_msg(m, lang_for_user(user)) for m in msgs]}
@router.post("") @router.post("")
@@ -374,20 +396,20 @@ def send_message(
"""Send an end-to-end-encrypted message. The server stores only the ciphertext the client """Send an end-to-end-encrypted message. The server stores only the ciphertext the client
produced, then pushes it live to the recipient (and the sender's other devices).""" produced, then pushes it live to the recipient (and the sender's other devices)."""
if not _send_limiter.allow(f"msg:{user.id}"): if not _send_limiter.allow(f"msg:{user.id}"):
raise HTTPException(status_code=429, detail="Too many messages — slow down a moment.") raise refused(429, "message_rate")
ct = (payload.ciphertext or "").strip() ct = (payload.ciphertext or "").strip()
iv = (payload.iv or "").strip() iv = (payload.iv or "").strip()
if not ct or not iv: if not ct or not iv:
raise HTTPException(status_code=400, detail="Message is empty.") raise refused(400, "message_empty")
if len(ct) > MAX_CIPHERTEXT: if len(ct) > MAX_CIPHERTEXT:
raise HTTPException(status_code=400, detail="Message is too long.") raise refused(400, "message_too_long")
if payload.recipient_id == user.id: if payload.recipient_id == user.id:
raise HTTPException(status_code=400, detail="You can't message yourself.") raise refused(400, "message_self")
recipient = db.get(User, payload.recipient_id) recipient = db.get(User, payload.recipient_id)
if not is_messageable_user(recipient): if not is_messageable_user(recipient):
raise HTTPException(status_code=404, detail="Recipient not available.") raise not_found("recipient")
if db.get(MessageKey, recipient.id) is None: if db.get(MessageKey, recipient.id) is None:
raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.") raise refused(404, "no_messaging")
m = Message(kind="user", sender_id=user.id, recipient_id=recipient.id, ciphertext=ct, iv=iv) m = Message(kind="user", sender_id=user.id, recipient_id=recipient.id, ciphertext=ct, iv=iv)
db.add(m) db.add(m)
db.commit() db.commit()
@@ -449,6 +471,8 @@ async def messages_ws(ws: WebSocket) -> None:
try: try:
while True: while True:
await ws.receive_text() await ws.receive_text()
# silent-ok: the ordinary way a chat socket ends — a closed tab. The `finally` below is the
# whole handling, and nothing failed.
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
finally: finally:
+4 -10
View File
@@ -5,7 +5,7 @@ All endpoints use `current_user` (not `require_human`): notifications and their
state are personal UI state with no quota or YouTube involvement, so the shared demo account state are personal UI state with no quota or YouTube involvement, so the shared demo account
manages its own inbox normally.""" manages its own inbox normally."""
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -14,6 +14,7 @@ from app.db import get_db
from app.models import Notification, User from app.models import Notification, User
from app.notifications import trim_read from app.notifications import trim_read
from app.utils import iso from app.utils import iso
from app.routes._common import owned_or_404
router = APIRouter(prefix="/api/me/notifications", tags=["notifications"]) router = APIRouter(prefix="/api/me/notifications", tags=["notifications"])
@@ -72,20 +73,13 @@ def unread_count(user: User = Depends(current_user), db: Session = Depends(get_d
return {"count": count or 0} return {"count": count or 0}
def _owned(db: Session, notification_id: int, user: User) -> Notification:
n = db.get(Notification, notification_id)
if n is None or n.user_id != user.id:
raise HTTPException(status_code=404, detail="Notification not found")
return n
@router.post("/{notification_id}/read") @router.post("/{notification_id}/read")
def mark_read( def mark_read(
notification_id: int, notification_id: int,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
n = _owned(db, notification_id, user) n = owned_or_404(db, Notification, notification_id, user, what="notification")
n.read = True n.read = True
db.commit() db.commit()
trim_read(db, user.id) trim_read(db, user.id)
@@ -110,7 +104,7 @@ def dismiss(
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
n = _owned(db, notification_id, user) n = owned_or_404(db, Notification, notification_id, user, what="notification")
# Dismiss implies read (it no longer counts toward the unread badge). # Dismiss implies read (it no longer counts toward the unread badge).
n.dismissed = True n.dismissed = True
n.read = True n.read = True
+162 -85
View File
@@ -3,10 +3,8 @@ CRUD (create / rename / delete, add / remove / reorder items) plus YouTube two-w
mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert local edits back
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope.""" out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel, Field
from sqlalchemy import func, select, and_ from sqlalchemy import func, select, and_
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
@@ -16,14 +14,18 @@ from app.db import get_db
from app.feedquery import feed_columns, serialize from app.feedquery import feed_columns, serialize
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
from app.sync.playlists import ( from app.sync.playlists import (
PlaylistGone,
lock_playlists,
merge_playlist, merge_playlist,
plan_merge, plan_merge,
recompute_dirty, recompute_dirty,
pull_user_playlists,
repull_playlist, repull_playlist,
sync_user_playlists, sync_claim,
user_sync_claim,
) )
from app.ratelimit import RateLimiter from app.ratelimit import RateLimiter
from app.routes._common import get_or_404, owned_or_404 from app.routes._common import get_or_404, not_found, owned_or_404, youtube_refusal
from app.youtube.client import YouTubeClient, YouTubeError from app.youtube.client import YouTubeClient, YouTubeError
router = APIRouter(prefix="/api/playlists", tags=["playlists"]) router = APIRouter(prefix="/api/playlists", tags=["playlists"])
@@ -48,10 +50,6 @@ def _check_pull_cooldown(user: User) -> None:
raise HTTPException(status_code=429, detail={"code": "sync_cooldown"}) raise HTTPException(status_code=429, detail={"code": "sync_cooldown"})
def _own_playlist(db: Session, user: User, playlist_id: int) -> Playlist:
return owned_or_404(db, Playlist, playlist_id, user, what="playlist")
def _refused(status: int, reason: str) -> HTTPException: def _refused(status: int, reason: str) -> HTTPException:
"""A playlist action the server refuses, as a reason the UI translates """A playlist action the server refuses, as a reason the UI translates
(`playlists.errors.<reason>`) — a bare English detail reached the Hungarian UI verbatim.""" (`playlists.errors.<reason>`) — a bare English detail reached the Hungarian UI verbatim."""
@@ -64,8 +62,11 @@ def _touch(pl: Playlist) -> None:
`UpdatedAtMixin`'s `onupdate` fires on an UPDATE of the PLAYLIST row, and add / remove / `UpdatedAtMixin`'s `onupdate` fires on an UPDATE of the PLAYLIST row, and add / remove /
reorder / move write the item rows instead — so without this the rail's "recently changed" reorder / move write the item rows instead — so without this the rail's "recently changed"
sort would only ever see renames. The assignment is what makes the column mean "last time sort would only ever see renames. The assignment is what makes the column mean "last time
this list changed", which is what the rail promises the reader.""" this list changed", which is what the rail promises the reader.
pl.updated_at = datetime.now(timezone.utc)
The DATABASE's clock, like the mixin's own `onupdate`: one sort key written from two clocks
(this process's and Postgres's) could order a rename and an item edit the wrong way round."""
pl.updated_at = func.now()
def _next_playlist_position(db: Session, user_id: int) -> int: def _next_playlist_position(db: Session, user_id: int) -> int:
@@ -81,7 +82,10 @@ def _next_playlist_position(db: Session, user_id: int) -> int:
def _next_item_position(db: Session, playlist_id: int) -> int: def _next_item_position(db: Session, playlist_id: int) -> int:
"""Append position for a new item at the end of a playlist.""" """Append position for a new item at the end of a playlist. Locks the playlist row first: two
adds (two tabs, the rail and the page) otherwise both read the same max and land on one
position, and the list's order between them is then arbitrary."""
lock_playlists(db, playlist_id)
return ( return (
db.scalar( db.scalar(
select(func.coalesce(func.max(PlaylistItem.position), -1)).where( select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
@@ -94,7 +98,10 @@ def _next_item_position(db: Session, playlist_id: int) -> int:
def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool: def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
"""Append a video to a playlist if not already present (idempotent). Returns whether it """Append a video to a playlist if not already present (idempotent). Returns whether it
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later).""" was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later).
Locked BEFORE the presence check: two adds of one video both saw "absent" and the second one's
insert hit uq_playlist_video as a 500."""
lock_playlists(db, pl.id)
exists = db.scalar( exists = db.scalar(
select(PlaylistItem).where( select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
@@ -108,13 +115,14 @@ def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) ->
if mark_dirty: if mark_dirty:
recompute_dirty(db, pl) recompute_dirty(db, pl)
_touch(pl) _touch(pl)
db.commit() db.flush() # the handler commits (CLAUDE.md: helpers flush)
return True return True
def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool: def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
"""Remove a video from a playlist if present (idempotent). Returns whether it was removed. """Remove a video from a playlist if present (idempotent). Returns whether it was removed.
`mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later).""" `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
lock_playlists(db, pl.id)
item = db.scalar( item = db.scalar(
select(PlaylistItem).where( select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
@@ -126,7 +134,7 @@ def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool)
if mark_dirty: if mark_dirty:
recompute_dirty(db, pl) recompute_dirty(db, pl)
_touch(pl) _touch(pl)
db.commit() db.flush() # the handler commits (CLAUDE.md: helpers flush)
return True return True
@@ -280,7 +288,7 @@ def _watch_later(db: Session, user: User) -> Playlist:
position=_next_playlist_position(db, user.id), position=_next_playlist_position(db, user.id),
) )
db.add(pl) db.add(pl)
db.commit() db.flush() # the id, for the item the caller adds next; the handler commits
return pl return pl
@@ -295,7 +303,8 @@ def add_watch_later(
get_or_404(db, Video, video_id, what="video") get_or_404(db, Video, video_id, what="video")
pl = _watch_later(db, user) pl = _watch_later(db, user)
_add_item(db, pl, video_id, mark_dirty=False) _add_item(db, pl, video_id, mark_dirty=False)
return {"saved": True, "playlist_id": pl.id} db.commit()
return {"ok": True, "saved": True, "playlist_id": pl.id}
@router.delete("/watch-later/{video_id}") @router.delete("/watch-later/{video_id}")
@@ -309,7 +318,8 @@ def remove_watch_later(
) )
if pl is not None: if pl is not None:
_remove_item(db, pl, video_id, mark_dirty=False) _remove_item(db, pl, video_id, mark_dirty=False)
return {"saved": False} db.commit()
return {"ok": True, "saved": False}
@router.post("/sync-youtube") @router.post("/sync-youtube")
@@ -321,9 +331,18 @@ def sync_youtube(
Also runs automatically on the scheduler; this is the manual 'sync now'.""" Also runs automatically on the scheduler; this is the manual 'sync now'."""
if not has_read_scope(user): if not has_read_scope(user):
raise _refused(403, "youtube_read_scope") raise _refused(403, "youtube_read_scope")
_check_pull_cooldown(user) with user_sync_claim(db, user.id) as free:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL): if not free:
return sync_user_playlists(db, user) # Another run (the scheduler, another tab) is pulling right now: "0 synced" would lie.
raise _refused(409, "sync_all_busy")
# After the claim, like the re-pull's: a refused attempt must not spend one of the pulls.
_check_pull_cooldown(user)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return {"ok": True, **pull_user_playlists(db, user)}
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc) from exc
@router.post("/{playlist_id}/revert-youtube") @router.post("/{playlist_id}/revert-youtube")
@@ -335,24 +354,31 @@ def revert_youtube(
"""Reset one YouTube-linked playlist to its current YouTube state, discarding local """Reset one YouTube-linked playlist to its current YouTube state, discarding local
edits (and clearing the dirty flag). The user-facing 'undo all my changes' for a list edits (and clearing the dirty flag). The user-facing 'undo all my changes' for a list
once the in-session undo history is gone.""" once the in-session undo history is gone."""
pl = _own_playlist(db, user, playlist_id) pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
if not pl.yt_playlist_id: if not pl.yt_playlist_id:
raise _refused(400, "not_on_youtube") raise _refused(400, "not_on_youtube")
if not has_read_scope(user): if not has_read_scope(user):
raise _refused(403, "youtube_read_scope") raise _refused(403, "youtube_read_scope")
_check_pull_cooldown(user) with sync_claim(db, pl.id) as free:
try: if not free:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL): raise _refused(409, "sync_busy")
return repull_playlist(db, user, pl) # The cooldown is spent only by a pull that runs: checked after the claim, so clicking
except YouTubeError: # Revert while a push is in flight does not use up the three pulls per ten minutes.
db.rollback() _check_pull_cooldown(user)
raise _refused(422, "youtube_failed") try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return {"ok": True, **repull_playlist(db, user, pl)}
except PlaylistGone:
raise not_found("playlist") from None
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc, playlist=True) from exc
def _pushable(db: Session, user: User, playlist_id: int) -> Playlist: def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
"""A local playlist the user may push to YouTube (create or update). The built-in """A local playlist the user may push to YouTube (create or update). The built-in
Watch later and read-only YouTube mirrors are never pushable.""" Watch later and read-only YouTube mirrors are never pushable."""
pl = _own_playlist(db, user, playlist_id) pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
if pl.kind == "watch_later": if pl.kind == "watch_later":
raise _refused(400, "watch_later_not_pushable") raise _refused(400, "watch_later_not_pushable")
return pl return pl
@@ -371,8 +397,8 @@ def push_plan(
try: try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH): with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
plan = plan_merge(db, user, pl) plan = plan_merge(db, user, pl)
except YouTubeError: except YouTubeError as exc:
raise _refused(422, "youtube_failed") raise youtube_refusal(exc, playlist=True) from exc
plan["remaining_today"] = quota.remaining_today(db) plan["remaining_today"] = quota.remaining_today(db)
plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"] plan["affordable"] = plan["units_estimate"] <= plan["remaining_today"]
return plan return plan
@@ -388,25 +414,32 @@ def push(
neither side loses an item). Refuses if the estimated quota exceeds what's left today, so a neither side loses an item). Refuses if the estimated quota exceeds what's left today, so a
big sync can't strand half-applied. Write scope required (require_write_scope).""" big sync can't strand half-applied. Write scope required (require_write_scope)."""
pl = _pushable(db, user, playlist_id) pl = _pushable(db, user, playlist_id)
try: # One sync of a list at a time, across tabs, devices and the scheduler (`sync_claim`): the
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH): # client's own in-flight flag only covers the menus of one page.
# Read the live playlist ONCE (linked playlists only) and share it with both the with sync_claim(db, pl.id) as free:
# plan and the merge, halving read-quota and closing the second read→write TOCTOU if not free:
# window. A fresh export (no yt_playlist_id) reads nothing. raise _refused(409, "sync_busy")
live = None try:
if pl.yt_playlist_id: with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
with YouTubeClient(db, user) as yt: # Read the live playlist ONCE (linked playlists only) and share it with both the
live = ( # plan and the merge, halving read-quota and closing the second read→write TOCTOU
list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)), # window. A fresh export (no yt_playlist_id) reads nothing.
yt.get_playlist_snippet(pl.yt_playlist_id), live = None
) if pl.yt_playlist_id:
plan = plan_merge(db, user, pl, live=live) with YouTubeClient(db, user) as yt:
if plan["units_estimate"] > quota.remaining_today(db): live = (
raise _refused(429, "quota_exceeded") list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)),
return merge_playlist(db, user, pl, live=live) yt.get_playlist_snippet(pl.yt_playlist_id),
except YouTubeError: )
db.rollback() plan = plan_merge(db, user, pl, live=live)
raise _refused(422, "youtube_failed") if plan["units_estimate"] > quota.remaining_today(db):
raise _refused(429, "quota_exceeded")
return {"ok": True, **merge_playlist(db, user, pl, live=live)}
except PlaylistGone:
raise not_found("playlist") from None
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc, playlist=True) from exc
@router.get("/{playlist_id}") @router.get("/{playlist_id}")
@@ -415,7 +448,7 @@ def get_playlist(
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
pl = _own_playlist(db, user, playlist_id) pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
state = aliased(VideoState) state = aliased(VideoState)
rows = db.execute( rows = db.execute(
select(*feed_columns(user, state)) select(*feed_columns(user, state))
@@ -423,7 +456,8 @@ def get_playlist(
.join(Channel, Channel.id == Video.channel_id) .join(Channel, Channel.id == Video.channel_id)
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id)) .outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
.where(PlaylistItem.playlist_id == pl.id) .where(PlaylistItem.playlist_id == pl.id)
.order_by(PlaylistItem.position) # `id` breaks a position tie the same way on every read (the renumbering add does too).
.order_by(PlaylistItem.position, PlaylistItem.id)
).all() ).all()
return { return {
"id": pl.id, "id": pl.id,
@@ -447,7 +481,8 @@ def rename_playlist(
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
pl = _own_playlist(db, user, playlist_id) # Locked: the new name moves `dirty`, which the scheduled mirror refresh reads under this lock.
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist", lock=True)
if "name" in body.model_fields_set: if "name" in body.model_fields_set:
name = (body.name or "").strip() name = (body.name or "").strip()
if not name: if not name:
@@ -484,37 +519,70 @@ def delete_playlist(
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
pl = _own_playlist(db, user, playlist_id) pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
if pl.kind == "watch_later": if pl.kind == "watch_later":
raise _refused(400, "watch_later_undeletable") raise _refused(400, "watch_later_undeletable")
if on_youtube: # Not in the middle of a sync of it (`sync_claim`): a push that read the list before the delete
if not pl.yt_playlist_id: # and wrote YouTube after it planned from no items at all, so it emptied the YouTube playlist a
raise _refused(400, "not_on_youtube") # local-only delete means to keep. The push checks for a vanished list too (`PlaylistGone`);
if not has_write_scope(user): # this refuses the delete while a sync runs rather than race it.
raise _refused(403, "youtube_editing_off") with sync_claim(db, pl.id) as free:
try: if not free:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE): raise _refused(409, "sync_busy")
with YouTubeClient(db, user) as yt: if on_youtube:
yt.delete_playlist(pl.yt_playlist_id) if not pl.yt_playlist_id:
except YouTubeError: raise _refused(400, "not_on_youtube")
raise _refused(422, "youtube_failed") if not has_write_scope(user):
db.delete(pl) # items cascade raise _refused(403, "youtube_editing_off")
db.commit() try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
with YouTubeClient(db, user) as yt:
yt.delete_playlist(pl.yt_playlist_id)
except YouTubeError as exc:
raise youtube_refusal(exc, playlist=True) from exc
# The playlist first, as every item write: the cascade deletes the items before the list row.
lock_playlists(db, pl.id)
db.delete(pl) # items cascade
db.commit()
return {"deleted": playlist_id} return {"deleted": playlist_id}
class AddItemIn(VideoRefIn):
# Where the video lands among the list's items, in their stored order (0 = first). Omitted, it
# is appended. The list page's "Undo" of a removal puts the row back where it was with this —
# ONE request, because append-then-reorder leaves it at the end whenever the second call fails.
position: int | None = Field(default=None, ge=0)
@router.post("/{playlist_id}/items") @router.post("/{playlist_id}/items")
def add_item( def add_item(
playlist_id: int, playlist_id: int,
body: VideoRefIn, body: AddItemIn,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
pl = _own_playlist(db, user, playlist_id) pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
video_id = body.video_id video_id = body.video_id
get_or_404(db, Video, video_id, what="video") get_or_404(db, Video, video_id, what="video")
_add_item(db, pl, video_id, mark_dirty=True) added = _add_item(db, pl, video_id, mark_dirty=False)
return {"playlist_id": pl.id, "video_id": video_id} if added and body.position is not None:
# Renumber the whole list with the new row at `position` (clamped to the end): positions
# can have gaps after removals, so "index N" is only meaningful over the ordered rows.
rows = list(
db.execute(
select(PlaylistItem)
.where(PlaylistItem.playlist_id == pl.id)
.order_by(PlaylistItem.position, PlaylistItem.id)
).scalars()
)
new = next(r for r in rows if r.video_id == video_id)
rows.remove(new)
rows.insert(min(body.position, len(rows)), new)
for pos, row in enumerate(rows):
row.position = pos
recompute_dirty(db, pl)
db.commit()
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
@router.delete("/{playlist_id}/items/{video_id}") @router.delete("/{playlist_id}/items/{video_id}")
@@ -524,9 +592,10 @@ def remove_item(
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
pl = _own_playlist(db, user, playlist_id) pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
_remove_item(db, pl, video_id, mark_dirty=True) _remove_item(db, pl, video_id, mark_dirty=True)
return {"playlist_id": pl.id, "video_id": video_id} db.commit()
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
class MoveItemIn(BaseModel): class MoveItemIn(BaseModel):
@@ -548,7 +617,7 @@ def move_item(
exactly the kind of half-applied state this codebase treats as a bug rather than an edge exactly the kind of half-applied state this codebase treats as a bug rather than an edge
case. Idempotent: if the video is already in the target, the source row is simply dropped case. Idempotent: if the video is already in the target, the source row is simply dropped
(moving into a list that already has it is a removal, not a duplicate).""" (moving into a list that already has it is a removal, not a duplicate)."""
src = _own_playlist(db, user, playlist_id) src = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
# Its own reason: "this playlist doesn't exist any more" about a TARGET that is gone was read as # Its own reason: "this playlist doesn't exist any more" about a TARGET that is gone was read as
# the list on screen — which is still there. # the list on screen — which is still there.
dst = owned_or_404(db, Playlist, body.target_playlist_id, user, what="move_target") dst = owned_or_404(db, Playlist, body.target_playlist_id, user, what="move_target")
@@ -556,6 +625,8 @@ def move_item(
raise HTTPException( raise HTTPException(
status_code=400, detail={"code": "playlist_move", "reason": "same_playlist"} status_code=400, detail={"code": "playlist_move", "reason": "same_playlist"}
) )
# Both lists, in id order: a move A→B beside a move B→A would otherwise lock crosswise.
lock_playlists(db, src.id, dst.id)
item = db.scalar( item = db.scalar(
select(PlaylistItem).where( select(PlaylistItem).where(
PlaylistItem.playlist_id == src.id, PlaylistItem.video_id == video_id PlaylistItem.playlist_id == src.id, PlaylistItem.video_id == video_id
@@ -571,19 +642,24 @@ def move_item(
PlaylistItem.playlist_id == dst.id, PlaylistItem.video_id == video_id PlaylistItem.playlist_id == dst.id, PlaylistItem.video_id == video_id
) )
) )
if already is not None: dst_changed = False
db.delete(item) # the uq_playlist_video constraint would reject a second row anyway if already is None:
else:
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this") # Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
# survives the move, and there is no window where the video belongs to neither list. # survives the move, and there is no window where the video belongs to neither list. The
# check above holds: both lists are locked, and an insert into the target (another tab's
# add, a YouTube merge) waits for the lock — its foreign key needs the target row.
item.playlist_id = dst.id item.playlist_id = dst.id
item.position = _next_item_position(db, dst.id) item.position = _next_item_position(db, dst.id)
# Both sides changed, so both get the dirty recompute and the timestamp — the asymmetric dst_changed = True
# version of this (touching only the source) is the recurring smell CLAUDE.md warns about. if not dst_changed:
db.delete(item)
# The source always changed; the target only when the row landed in it. `_touch` on a target
# that already had the video promoted an untouched list in the rail's "recently changed" sort.
recompute_dirty(db, src) recompute_dirty(db, src)
recompute_dirty(db, dst)
_touch(src) _touch(src)
_touch(dst) if dst_changed:
recompute_dirty(db, dst)
_touch(dst)
db.commit() db.commit()
return {"ok": True, "from_playlist_id": src.id, "to_playlist_id": dst.id, "video_id": video_id} return {"ok": True, "from_playlist_id": src.id, "to_playlist_id": dst.id, "video_id": video_id}
@@ -599,7 +675,8 @@ def reorder_items(
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
pl = _own_playlist(db, user, playlist_id) # Locked: a concurrent add between reading the items and writing the order would collide.
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist", lock=True)
order = body.video_ids order = body.video_ids
items = { items = {
it.video_id: it it.video_id: it
@@ -625,4 +702,4 @@ def reorder_items(
recompute_dirty(db, pl) recompute_dirty(db, pl)
_touch(pl) _touch(pl)
db.commit() db.commit()
return {"playlist_id": pl.id, "count": pos} return {"ok": True, "playlist_id": pl.id, "count": pos}

Some files were not shown because too many files have changed in this diff Show More