chore(comments): drop epic, sprint and review-round ids from the code

The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
This commit is contained in:
2026-10-05 00:19:39 +02:00
parent c73d8be094
commit 4aaaaa7860
669 changed files with 1831 additions and 1904 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ 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 # Logging. 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 # 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 # `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 # any letter case; any other value stops the boot with an error. LOG_FORMAT is "json" (one object per
+1 -1
View File
@@ -2,7 +2,7 @@
# whole-repo reformatting does not bury the real authorship of each line. # whole-repo reformatting does not bury the real authorship of each line.
# Configure once per clone: git config blame.ignoreRevsFile .git-blame-ignore-revs # Configure once per clone: git config blame.ignoreRevsFile .git-blame-ignore-revs
# #
# Prettier one-time repo-wide format pass (R2 S1b, 2026-07-21) # Prettier one-time repo-wide format pass (2026-07-21)
51c36c2071728d2cd7ae580b7bfdf21f6867a347 51c36c2071728d2cd7ae580b7bfdf21f6867a347
# ruff format one-time backend + scripts pass (line-length 100, 2026-08-23) # ruff format one-time backend + scripts pass (line-length 100, 2026-08-23)
+1 -1
View File
@@ -1,4 +1,4 @@
# The CVE gate's allowlist (E32). The publish gate scans the built image and the committed tree with # The CVE gate's allowlist. 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 # 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: # 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) # statement — why it is acceptable for now (what is exposed, what is not)
+3 -3
View File
@@ -47,13 +47,13 @@ RUN adduser --disabled-password --gecos "" appuser \
&& mkdir -p /downloads \ && mkdir -p /downloads \
&& chown appuser /app /downloads && chown appuser /app /downloads
# ffmpeg: merge separate video+audio streams + postprocessing, and the Plex VAAPI transcode (E6 S1). # ffmpeg: merge separate video+audio streams + postprocessing, and the Plex VAAPI transcode.
# intel-media-va-driver (iHD): the VA driver ffmpeg's h264_vaapi needs — libva2 ships in the base but # intel-media-va-driver (iHD): the VA driver ffmpeg's h264_vaapi needs — libva2 ships in the base but
# no driver, so without this `vainfo`/`h264_vaapi` fail and transcode silently falls back to libx264. # no driver, so without this `vainfo`/`h264_vaapi` fail and transcode silently falls back to libx264.
# 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 # `apt-get upgrade`: 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. # 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 \
@@ -81,7 +81,7 @@ RUN printf 'Types: deb\nURIs: http://deb.debian.org/debian\nSuites: bookworm\nCo
# 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 the gate's pip-audit scanned — not whatever # byte-reproducible and the exact versions are the ones the gate's pip-audit scanned — not whatever
# floats 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 # pip itself is removed once it has installed the lock: 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 — # 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. # an attack surface and a CVE stream for a tool the running app never calls.
COPY backend/requirements.lock . COPY backend/requirements.lock .
+3 -3
View File
@@ -27,7 +27,7 @@ class AuditAction:
USER_SUSPEND = "user_suspend" USER_SUSPEND = "user_suspend"
USER_UNSUSPEND = "user_unsuspend" USER_UNSUSPEND = "user_unsuspend"
USER_DELETE = "user_delete" USER_DELETE = "user_delete"
# E9.5 S5: an admin let a stuck registrant in, or re-sent their confirmation link. Both are # An admin let a stuck registrant in, or re-sent their confirmation link. Both are
# recorded because both are the admin acting ON someone's access, not merely reading a list. # recorded because both are the admin acting ON someone's access, not merely reading a list.
USER_ACTIVATE = "user_activate" USER_ACTIVATE = "user_activate"
USER_VERIFY_RESEND = "user_verify_resend" USER_VERIFY_RESEND = "user_verify_resend"
@@ -36,7 +36,7 @@ class AuditAction:
INVITE_DENY = "invite_deny" INVITE_DENY = "invite_deny"
INVITE_ADD = "invite_add" INVITE_ADD = "invite_add"
INVITE_DELETE = "invite_delete" INVITE_DELETE = "invite_delete"
# E9.5 S6: the admin chose to tell someone their address is whitelisted. Adding the address is # The admin chose to tell someone their address is whitelisted. Adding the address is
# silent, so this is the act that actually reaches a person — and the one worth a record. # silent, so this is the act that actually reaches a person — and the one worth a record.
INVITE_SEND = "invite_send" INVITE_SEND = "invite_send"
DISCOVERY_PURGE = "discovery_purge" DISCOVERY_PURGE = "discovery_purge"
@@ -55,7 +55,7 @@ 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 # 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). # off this instance, so it is recorded (target = the session, summary = the issue's URL).
ISSUE_CREATE = "issue_create" ISSUE_CREATE = "issue_create"
+4 -4
View File
@@ -67,7 +67,7 @@ def has_write_scope(user: User) -> bool:
def env_allowed_canonicals() -> set[str]: def env_allowed_canonicals() -> set[str]:
"""The env bootstrap allow-list, in CANONICAL form — the only shape it may be compared in. """The env bootstrap allow-list, in CANONICAL form — the only shape it may be compared in.
S3 canonicalised the Invite branch of `is_allowed` and left this one matching the literal string, Canonicalising only the Invite branch of `is_allowed` left this one matching the literal string,
which is the 2026-09-03 lockout with a different source of truth: an operator who writes which is the 2026-09-03 lockout with a different source of truth: an operator who writes
`first.last@gmail.com` into ALLOWED_EMAILS is refused when Google answers with the undotted `first.last@gmail.com` into ALLOWED_EMAILS is refused when Google answers with the undotted
primary, and a `+tag`ged entry can never match at all, because a Google account cannot contain a primary, and a `+tag`ged entry can never match at all, because a Google account cannot contain a
@@ -340,12 +340,12 @@ 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: # Attribute this request's log lines to the account. Here, not in the request middleware:
# this is the one place a session becomes an account, and the middleware runs outside both the # 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 # 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 # (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). # diagnostics level rides along: their requests are logged — and stored — in that detail.
# So does a debug session the request names (E16): only the caller's own open one counts, and a # So does a debug session the request names: 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. # 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)) session = debugsession.from_header(db, user, request.headers.get(debugsession.HEADER))
level, until = debugsession.level_for(user, session) level, until = debugsession.level_for(user, session)
+3 -3
View File
@@ -59,7 +59,7 @@ DEFAULT_MAX_BODY_BYTES = 1024 * 1024
# a say. # a say.
# #
# Both listed routes now satisfy that. The screenshot route always did (`capped_body` streams it, # 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 # and the owner check resolves first). The upload route did NOT at first: it declared
# `file: UploadFile = File(...)`, so an anonymous caller could make the process write the whole # `file: UploadFile = File(...)`, so an anonymous caller could make the process write the whole
# per-file allowance to temp before the auth dependency refused them — this middleware bounded that # per-file allowance to temp before the auth dependency refused them — this middleware bounded that
# number but could not stop the writing. It now reads its own body from a dependency declared after # number but could not stop the writing. It now reads its own body from a dependency declared after
@@ -73,12 +73,12 @@ DEFAULT_MAX_BODY_BYTES = 1024 * 1024
# `POST /api/diagnostics/events` is absent: its `capped_body` limit is 512 KiB, already below the # `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. # default, so listing it could only widen what it accepts.
ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = { 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 — # Ingesting a local file. The body IS the file now — no multipart envelope to allow for —
# so this ceiling and the route's own cap count exactly the same bytes. `sysconfig`'s # 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 # `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. # 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): # WHICH LAYER ANSWERS, measured rather than assumed:
# * knob LOWERED (an override below the default) — the route's cap is the smaller number, so # * 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 # `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. # figure, after draining the rest of the body so the client can read it.
+1 -1
View File
@@ -1,4 +1,4 @@
"""Alphabetical order in the reader's language (E30 S3). """Alphabetical order in the reader's language.
Why not the database default: the Postgres image is Alpine, and musl's `en_US.utf8` collation is BYTE 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 order — every accented initial sorted after "Z" ("Zorró < Életrajzi < Ötödik"), in every A→Z list the
+8 -8
View File
@@ -242,7 +242,7 @@ class Settings(BaseSettings):
download_default_max_bytes: int = 5_368_709_120 # 5 GiB download_default_max_bytes: int = 5_368_709_120 # 5 GiB
download_default_max_concurrent: int = 2 download_default_max_concurrent: int = 2
download_default_max_jobs: int = 50 download_default_max_jobs: int = 50
# Per-file cap for local uploads (E7). Unlike a download (whose size is unknown until the worker # Per-file cap for local uploads. 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.
# ge=1: this number is also the EDGE ceiling (`bodylimit.ROUTE_MAX_BODY_BYTES`), where 0 is a # ge=1: this number is also the EDGE ceiling (`bodylimit.ROUTE_MAX_BODY_BYTES`), where 0 is a
@@ -266,7 +266,7 @@ class Settings(BaseSettings):
# 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) --- # --- Diagnostics / logging ---
# BOOTSTRAP level only: the running level is the `log_level` admin knob (sysconfig), applied at # 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 # 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. # the database — and the only knob the worker/runner children read, since they are short-lived.
@@ -299,7 +299,7 @@ class Settings(BaseSettings):
plex_path_map: str = "" plex_path_map: str = ""
# Which Plex library section keys are exposed (comma-separated). Empty = all enabled sections. # Which Plex library section keys are exposed (comma-separated). Empty = all enabled sections.
plex_libraries: str = "" plex_libraries: str = ""
# HU-overlay mapping (E5 S5): comma-separated `huSectionKey:enSectionKey` pairs. Each named HU # HU-overlay mapping: comma-separated `huSectionKey:enSectionKey` pairs. Each named HU
# section is synced as a non-browsable overlay whose Hungarian metadata is written onto the EN # section is synced as a non-browsable overlay whose Hungarian metadata is written onto the EN
# twin section's rows (matched by guid). Empty = no overlays. E.g. "3:4,5:1". # twin section's rows (matched by guid). Empty = no overlays. E.g. "3:4,5:1".
plex_overlay_map: str = "" plex_overlay_map: str = ""
@@ -309,7 +309,7 @@ class Settings(BaseSettings):
plex_sync_interval_min: int = 360 plex_sync_interval_min: int = 360
# How often the incremental Plex→Siftlode watch-state reconcile runs, in minutes (Phase C): # How often the incremental Plex→Siftlode watch-state reconcile runs, in minutes (Phase C):
# a cheap history + on-deck pull plus a re-push of any states that failed their immediate push. # a cheap history + on-deck pull plus a re-push of any states that failed their immediate push.
# 15 min (S2): with Plex now the source of truth (prompt write-back on the ~10s checkpoint + # 15 min: with Plex now the source of truth (prompt write-back on the ~10s checkpoint +
# Plex-authoritative pull), a faster incremental pull keeps Siftlode close to Plex-side changes. # Plex-authoritative pull), a faster incremental pull keeps Siftlode close to Plex-side changes.
plex_watch_sync_interval_min: int = 15 plex_watch_sync_interval_min: int = 15
# How often the FULL watch-state reconcile runs, in minutes (Phase C). A whole-section rescan # How often the FULL watch-state reconcile runs, in minutes (Phase C). A whole-section rescan
@@ -324,7 +324,7 @@ class Settings(BaseSettings):
# each one is a whole segmented copy of the film on the scratch volume, written faster than # 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`). # realtime. One account holds at most two regardless (plex/stream.py `_MAX_SESSIONS_PER_USER`).
plex_max_sessions: int = 8 plex_max_sessions: int = 8
# --- Transcode (playable="transcode") re-encode tuning (E6 S1) --- # --- Transcode (playable="transcode") re-encode tuning ---
# 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`.
@@ -347,7 +347,7 @@ class Settings(BaseSettings):
# local disk, so the default is fine there. # local disk, so the default is fine there.
plex_hls_dir: str = "" plex_hls_dir: str = ""
# --- TMDB enrichment (E5 S2; optional — enriches the Plex catalog from TMDB) --- # --- TMDB enrichment (optional — enriches the Plex catalog from TMDB) ---
# TMDB API key (v3 api_key, 32 hex chars). Server-side only; stored encrypted when set via the # TMDB API key (v3 api_key, 32 hex chars). Server-side only; stored encrypted when set via the
# admin Config page. Empty → the TMDB enrichment job no-ops. Feeds keywords / crew / budget / # admin Config page. Empty → the TMDB enrichment job no-ops. Feeds keywords / crew / budget /
# revenue / TMDb score / recommendations / backdrops onto the mirrored Plex rows, matched by the # revenue / TMDb score / recommendations / backdrops onto the mirrored Plex rows, matched by the
@@ -357,7 +357,7 @@ class Settings(BaseSettings):
# 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 # --- Bug reports: 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 # 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). --- # log — and app/issues/gitea.py refuses a public one. Runtime knobs (sysconfig, admin UI). ---
issues_gitea_url: str = "" issues_gitea_url: str = ""
@@ -367,7 +367,7 @@ class Settings(BaseSettings):
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, # How often the people job 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 # 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. # once, then only the newcomers.
people_enrich_interval_min: int = 10 people_enrich_interval_min: int = 10
+2 -2
View File
@@ -1,4 +1,4 @@
"""Debug sessions (E16): Start → reproduce → Stop, and the evidence a bug report is built from. """Debug sessions: 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 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 dependency accepts the header only for the caller's OWN open session (anything else is ignored, never
@@ -269,7 +269,7 @@ def set_delivery(db: Session, session: DebugSession, state: dict, token: str) ->
every step. It is stamped from `token` here instead, and only after the ROW confirms the claim 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 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 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 own dead token back on an idle session. `release_send` has always made
the same check; this is the other half of it.""" the same check; this is the other half of it."""
if claim_owner(db, session) != token: if claim_owner(db, session) != token:
raise SendClaimLost(session.id) raise SendClaimLost(session.id)
+2 -2
View File
@@ -1,4 +1,4 @@
"""A user's diagnostics level (E22 S5): what they consented to, and for how long. """A user's diagnostics level: what they consented to, and for how long.
Three levels, named in the UI Alap / Részletes / Teljes: Three levels, named in the UI Alap / Részletes / Teljes:
@@ -26,7 +26,7 @@ DiagnosticsLevel = Literal["INFO", "DEBUG", "TRACE"]
LEVELS: tuple[str, ...] = get_args(DiagnosticsLevel) LEVELS: tuple[str, ...] = get_args(DiagnosticsLevel)
BASE: DiagnosticsLevel = "INFO" BASE: DiagnosticsLevel = "INFO"
ELEVATION_HOURS = 24 ELEVATION_HOURS = 24
# How long a debug session records at most (E16, app/debugsession.py). Here, beside the other # How long a debug session records at most (app/debugsession.py). Here, beside the other
# consent window, because the start dialog's consent text states it and reads it from `state`. # consent window, because the start dialog's consent text states it and reads it from `state`.
SESSION_MINUTES = 60 SESSION_MINUTES = 60
+1 -1
View File
@@ -1,4 +1,4 @@
"""E24 — the read-model behind the Home surface ("Mit nézzek most?"). """The read-model behind the Home surface ("Mit nézzek most?").
The anti-paralysis answer layer: one hero suggestion, a cross-source resume rail, and the user's own The anti-paralysis answer layer: one hero suggestion, a cross-source resume rail, and the user's own
comfort shelf, all served from our own Postgres with no upstream call at serve time (the same rule comfort shelf, all served from our own Postgres with no upstream call at serve time (the same rule
+1 -1
View File
@@ -1,4 +1,4 @@
"""The time budget — E24's replacement for "what mood are you in?". """The time budget — the replacement for "what mood are you in?".
`random-gondolatok` 15/D asked for a mood questionnaire before the recommendations. The same file's `random-gondolatok` 15/D asked for a mood questionnaire before the recommendations. The same file's
later BA-interview note (#23) says the mood half is not what makes this work: the friction is later BA-interview note (#23) says the mood half is not what makes this work: the friction is
+2 -2
View File
@@ -46,8 +46,8 @@ def resolve_ref(db: Session, user: User, kind: str, ref: str) -> tuple[str, obje
translation belongs here rather than in five call sites. translation belongs here rather than in five call sites.
The COLUMN half of every answer comes from `PIN_COLUMNS`; only the LOOKUP differs per kind The COLUMN half of every answer comes from `PIN_COLUMNS`; only the LOOKUP differs per kind
(a direct `db.get`, a rating-key translation, an int parse plus an ownership check). Before the (a direct `db.get`, a rating-key translation, an int parse plus an ownership check). Before a
E24 review the branches spelled the column names out again and `PIN_COLUMNS` had no readers at review the branches spelled the column names out again and `PIN_COLUMNS` had no readers at
all — a constant that called itself the one vocabulary while nothing spoke it.""" all — a constant that called itself the one vocabulary while nothing spoke it."""
col = PIN_COLUMNS.get(kind) col = PIN_COLUMNS.get(kind)
if col is None: if col is None:
+3 -3
View File
@@ -156,7 +156,7 @@ class YouTubeAdapter(SourceAdapter):
class FacebookAdapter(SourceAdapter): class FacebookAdapter(SourceAdapter):
key = "facebook" key = "facebook"
# Measured (E11.5, yt-dlp 2026.07.04): with the user's cookie, some reels answer a plain request # Measured (yt-dlp 2026.07.04): with the user's cookie, some reels answer a plain request
# with "Cannot parse data" and parse only when the request looks like Chrome. Impersonation is a # with "Cannot parse data" and parse only when the request looks like Chrome. Impersonation is a
# SECOND attempt behind that one error, not the default: the links that already download with the # SECOND attempt behind that one error, not the default: the links that already download with the
# cookie keep their proven path, and only the ones stuck at the wall go through curl_cffi. Without # cookie keep their proven path, and only the ones stuck at the wall go through curl_cffi. Without
@@ -166,7 +166,7 @@ class FacebookAdapter(SourceAdapter):
_POST_PAGE_MAX_BYTES = 16 * 1024 * 1024 _POST_PAGE_MAX_BYTES = 16 * 1024 * 1024
def resolve_url(self, url: str, ydl_opts: dict) -> str: def resolve_url(self, url: str, ydl_opts: dict) -> str:
# Measured (E11.5 S4): given a group post's link, yt-dlp either finds no formats or — worse — # Measured: given a group post's link, yt-dlp either finds no formats or — worse —
# downloads ANOTHER video from the page's feed data. The post's own video is the one Video node # downloads ANOTHER video from the page's feed data. The post's own video is the one Video node
# in the page scripts that carry the post id; its watch link downloads (plain first, then the # in the page scripts that carry the post id; its watch link downloads (plain first, then the
# Chrome retry above). Needs the cookie: signed out, the page is the login wall. # Chrome retry above). Needs the cookie: signed out, the page is the login wall.
@@ -176,7 +176,7 @@ class FacebookAdapter(SourceAdapter):
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
raw = ydl.urlopen(url).read(self._POST_PAGE_MAX_BYTES) raw = ydl.urlopen(url).read(self._POST_PAGE_MAX_BYTES)
page = raw.decode("utf-8", "replace") page = raw.decode("utf-8", "replace")
# Measured (E11.5 review): a refused or expired cookie gets the login page (~450 KB, a login # Measured: 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 StatedFailure(POST_NOT_SHOWN_MESSAGE) raise StatedFailure(POST_NOT_SHOWN_MESSAGE)
+2 -2
View File
@@ -249,8 +249,8 @@ def usable_cookie(
If (2) fails the asset is the SHARED (unsalted) one — attaching the cookie would poison the If (2) fails the asset is the SHARED (unsalted) one — attaching the cookie would poison the
global cache with a private-group video, so we attach nothing. An unreadable blob (rotated key → global cache with a private-group video, so we attach nothing. An unreadable blob (rotated key →
InvalidToken) also yields None; the download then fails honestly on the login wall (S3's InvalidToken) also yields None; the download then fails honestly on the login wall (the
`login_required`).""" `login_required` category)."""
host_key = host_key_for_url(url) host_key = host_key_for_url(url)
if host_key is None: if host_key is None:
return None return None
+2 -2
View File
@@ -8,7 +8,7 @@ explanation (and decides whether a retry makes sense). The raw text stays availa
"details" disclosure. This is the same string-matching approach the worker already trusts for "details" disclosure. This is the same string-matching approach the worker already trusts for
`_RETRIABLE_MARKERS` — kept here as the single source of truth for "what kind of failure is this". `_RETRIABLE_MARKERS` — kept here as the single source of truth for "what kind of failure is this".
**The failure catalog** (`_CATALOG`, E11.5). A category is generic: "Sign-in required" cannot tell a **The failure catalog** (`_CATALOG`). A category is generic: "Sign-in required" cannot tell a
Facebook user that the cookie they already gave was refused, and yt-dlp words several different Facebook user that the cookie they already gave was refused, and yt-dlp words several different
Facebook walls identically (`Cannot parse data`). A catalog entry is scoped to ONE host and may also Facebook walls identically (`Cannot parse data`). A catalog entry is scoped to ONE host and may also
depend on whether a cookie was attached and on the kind of link, and names a `detail` code the UI has depend on whether a cookie was attached and on the kind of link, and names a `detail` code the UI has
@@ -49,7 +49,7 @@ from app.downloads.credentials import host_key_for_url
# "produced no output file" have markers here; the urlguard refusal deliberately has none and # "produced no output file" have markers here; the urlguard refusal deliberately has none and
# classifies as `unknown` — it is a refusal, not a diagnosis, and no category would say more. # classifies as `unknown` — it is a refusal, not a diagnosis, and no category would say more.
# #
# A source adapter's verdicts on a link (`SourceAdapter.resolve_url`, E11.5 S4) are adapters.py's # A source adapter's verdicts on a link (`SourceAdapter.resolve_url`) are adapters.py's
# constants, imported above; each has a rule below that gives its category back, and a catalog entry # constants, imported above; each has a rule below that gives its category back, and a catalog entry
# that gives the site's sentence. # that gives the site's sentence.
QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again." QUOTA_FULL_MESSAGE = "Your download storage is full — free up space, then try again."
+4 -4
View File
@@ -13,7 +13,7 @@ spec shape (all keys optional; defaults + browser-playability clamp applied by `
vcodec always coerced to null → H.264 selected (see `normalize`); no opt-out vcodec always coerced to null → H.264 selected (see `normalize`); no opt-out
embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool embed_subs bool embed_chapters bool embed_thumbnail bool sponsorblock bool
`normalize` clamps container/vcodec/audio_format so EVERY download plays in-browser (E7); the `normalize` clamps container/vcodec/audio_format so EVERY download plays in-browser; the
`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.
""" """
@@ -217,7 +217,7 @@ def normalize(spec: dict) -> dict:
# Audio-only files can't embed subtitles/thumbnails as video; keep the flags meaningful. # Audio-only files can't embed subtitles/thumbnails as video; keep the flags meaningful.
if s["mode"] == "a": if s["mode"] == "a":
s["embed_subs"] = False s["embed_subs"] = False
# Browser-playability guarantee (E7): the Download module exists to download → share → PLAY # Browser-playability guarantee: the Download module exists to download → share → PLAY
# in-browser, so every output must play in EVERY browser, iOS/Safari/WebKit included. The only # in-browser, so every output must play in EVERY browser, iOS/Safari/WebKit included. The only
# universally-safe combo is mp4 + H.264 + AAC/MP3, so we CLAMP the spec to it HERE — the single # universally-safe combo is mp4 + H.264 + AAC/MP3, so we CLAMP the spec to it HERE — the single
# point every path (picker, stored profile, raw API) flows through — instead of only hiding # point every path (picker, stored profile, raw API) flows through — instead of only hiding
@@ -274,7 +274,7 @@ def build_ydl_opts(
`extractor_args` with the player-client set + bgutil PO-token base_url); it is merged onto the `extractor_args` with the player-client set + bgutil PO-token base_url); it is merged onto the
generic options below. Everything here is source-agnostic. generic options below. Everything here is source-agnostic.
`cookiefile` is the acting user's per-host cookies.txt path (S6), attached ONLY by the worker `cookiefile` is the acting user's per-host cookies.txt path, attached ONLY by the worker
after the host-scope + private-asset gate in `downloads.credentials`. It is set AFTER the `extra` after the host-scope + private-asset gate in `downloads.credentials`. It is set AFTER the `extra`
merge so a source adapter can never clobber it. merge so a source adapter can never clobber it.
@@ -329,7 +329,7 @@ def build_ydl_opts(
if max_bytes and max_bytes > 0: if max_bytes and max_bytes > 0:
opts["max_filesize"] = max_bytes opts["max_filesize"] = max_bytes
# The acting user's per-host cookie (S6). AFTER the adapter merge on purpose: an adapter must not # The acting user's per-host cookie. AFTER the adapter merge on purpose: an adapter must not
# be able to override the authenticated session the worker resolved for this specific user. # be able to override the authenticated session the worker resolved for this specific user.
if cookiefile: if cookiefile:
opts["cookiefile"] = cookiefile opts["cookiefile"] = cookiefile
+4 -4
View File
@@ -73,7 +73,7 @@ _YTDLP_TEMP_RE = re.compile(
def _notify_download_done(db, user_id: int | None, title: str | None, job_id, *, kind: str) -> None: def _notify_download_done(db, user_id: int | None, title: str | None, job_id, *, kind: str) -> None:
"""Post a best-effort 'ready' notice to the owner's inbox — downloads/edits used to finish """Post a best-effort 'ready' notice to the owner's inbox — downloads/edits used to finish
silently while scheduler jobs and playlist-push already notified (R11 feedback symmetry). A silently while scheduler jobs and playlist-push already notified (feedback symmetry). A
notification failure must never mask the completed job, so it is isolated + rolled back (mirrors notification failure must never mask the completed job, so it is isolated + rolled back (mirrors
scheduler._notify_done). `kind` ("download"/"edit") lets the inbox pick accurate copy; the scheduler._notify_done). `kind` ("download"/"edit") lets the inbox pick accurate copy; the
English title here is only a fallback for an unknown type. `user_id`/`title` are captured by the English title here is only a fallback for an unknown type. `user_id`/`title` are captured by the
@@ -219,7 +219,7 @@ def _ffmpeg_progress_writer(job_id: int, phase: str, *, now=time.monotonic):
when the whole-percent value changed (the bar shows integers), plus a separate once-per-interval when the whole-percent value changed (the bar shows integers), plus a separate once-per-interval
heartbeat while the percent is stuck (so a slow re-encode isn't mistaken for a stall).""" heartbeat while the percent is stuck (so a slow re-encode isn't mistaken for a stall)."""
# Progress writes have their OWN 1/interval budget so a heartbeat can never delay a real write (the # Progress writes have their OWN 1/interval budget so a heartbeat can never delay a real write (the
# round-1 concern). The heartbeat, in turn, is gated on `last_touch` — the last DB write OR beat — # original concern). The heartbeat, in turn, is gated on `last_touch` — the last DB write OR beat —
# so a progress write (which already refreshes updated_at) suppresses a redundant heartbeat that # so a progress write (which already refreshes updated_at) suppresses a redundant heartbeat that
# same interval, keeping active downloads at ~1 write/interval, not two. # same interval, keeping active downloads at ~1 write/interval, not two.
allow_write = _rate_limiter(_DB_WRITE_INTERVAL_S, now=now) allow_write = _rate_limiter(_DB_WRITE_INTERVAL_S, now=now)
@@ -341,7 +341,7 @@ def _extract_with_fallback(
(adapters.POST_*_MESSAGE) instead of letting yt-dlp guess, and a failed page read fails the (adapters.POST_*_MESSAGE) instead of letting yt-dlp guess, and a failed page read fails the
download as itself. download as itself.
`cookiefile` (S6) is the acting user's per-host cookies.txt, already gated by `cookiefile` is the acting user's per-host cookies.txt, already gated by
`credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated. `credentials.resolve_cookiefile`; passed to every attempt so the fallback stays authenticated.
`max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt.""" `max_bytes` (SEC-13) is the owner's remaining storage budget, applied to every attempt."""
@@ -483,7 +483,7 @@ def _download(
urlguard.check_url(url) urlguard.check_url(url)
except urlguard.BlockedUrl as e: except urlguard.BlockedUrl as e:
raise StatedFailure(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 # 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
with SessionLocal() as cdb: with SessionLocal() as cdb:
+1 -1
View File
@@ -1,4 +1,4 @@
"""Local file ingest for the download center (E7 S5) — the non-yt-dlp path into the media library. """Local file ingest for the download center — the non-yt-dlp path into the media library.
A user-uploaded file joins the SAME library as a downloaded one, so it inherits the whole function A user-uploaded file joins the SAME library as a downloaded one, so it inherits the whole function
set already built (metadata chips, share links, and — for media — in-app play/edit). Unlike a set already built (metadata chips, share links, and — for media — in-app play/edit). Unlike a
+2 -2
View File
@@ -197,7 +197,7 @@ def _next_step(verify_link: str | None, has_account: bool, hours: int, lang: str
Three states, because a mail that guesses wrong sends someone looking for a link that isn't Three states, because a mail that guesses wrong sends someone looking for a link that isn't
coming — or worse, tells them to sign in when their address is still unconfirmed and sign-in will coming — or worse, tells them to sign in when their address is still unconfirmed and sign-in will
refuse them (E9.5 S4 named that state on screen; the mail must not contradict it). refuse them (the sign-in screen names that state; the mail must not contradict it).
The link branch is the point of the C option: in the common ordering — approve first, the person The link branch is the point of the C option: in the common ordering — approve first, the person
signs up later, or signs up and waits — they would otherwise have to find and open TWO separate signs up later, or signs up and waits — they would otherwise have to find and open TWO separate
@@ -253,7 +253,7 @@ def send_access_invitation(
the closing line offers no deadline — nudging is the one thing this mail must not do. the closing line offers no deadline — nudging is the one thing this mail must not do.
Its language is the one place the app has no signal to read: the admin typed this address in, so Its language is the one place the app has no signal to read: the admin typed this address in, so
nobody has ever told us how to address its owner. That is why the send button asks (E9.5 S7). nobody has ever told us how to address its owner. That is why the send button asks.
""" """
admin, contact = _reply_contact(lang) admin, contact = _reply_contact(lang)
body = mailtext.text( body = mailtext.text(
+5 -5
View File
@@ -109,7 +109,7 @@ def _tag_visible(user: User):
# The pseudo-category of a selected tag id no Tag row answers (a saved view or the feed state outlives # 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 # 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 # 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). # the view's old name. No real category is called this (language/topic/other).
MISSING_TAG_CATEGORY = "missing" MISSING_TAG_CATEGORY = "missing"
@@ -118,7 +118,7 @@ def tags_by_category(db: Session, user: User, tag_ids: list[int]) -> dict[str, l
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. An unknown id lands in 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 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).""" told the caller which foreign ids exist."""
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]] = {}
@@ -463,7 +463,7 @@ def feed_summary(db: Session, user: User, **params) -> dict:
def feed_years(db: Session, user: User, **params) -> list[dict]: def feed_years(db: Session, user: User, **params) -> list[dict]:
"""Per-year video counts for the current filter context (item 8 / E8·S2): powers the year control's """Per-year video counts for the current filter context: powers the year control's
quick chips + the "more…" per-year-counts dialog. Every OTHER filter (tags, channels, content type, quick chips + the "more…" per-year-counts dialog. Every OTHER filter (tags, channels, content type,
search, watch state, scope) still applies, but the publish-date narrowing itself is DROPPED — search, watch state, scope) still applies, but the publish-date narrowing itself is DROPPED —
published_after/before and max_age_days — so the year list is the full set of years available and published_after/before and max_age_days — so the year list is the full set of years available and
@@ -559,7 +559,7 @@ def sort_keys(sort: str, seed: int, rank_expr=None, lang: str | None = None) ->
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 # Relevance ranks only the newest RANK_WINDOW matches: 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 # 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 # 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. # that broad has no meaningful relevance order anyway, and a specific one fits the window whole.
@@ -671,7 +671,7 @@ def encode_cursor(keys: list[dict], row, window: datetime | None = None) -> str:
def _cursor_dt(v): def _cursor_dt(v):
"""A cursor's timestamp: aware or refused. The server writes only aware ones (timestamptz), and """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 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).""" 500 for a hand-made cursor instead of the 400."""
if v is None: if v is None:
return None return None
dt = datetime.fromisoformat(v) dt = datetime.fromisoformat(v)
+2 -2
View File
@@ -1,3 +1,3 @@
"""Bug reports built from debug sessions (E16): the files an issue carries, and where they go — """Bug reports built from debug sessions: 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 downloaded as a zip, or sent to a Gitea issue tracker. The session itself — what is recorded
and tagged — is the core module `app/debugsession.py`.""" and tagged — is the core module `app/debugsession.py`."""
+1 -1
View File
@@ -5,7 +5,7 @@ Gitea upload, so the two cannot drift apart.
in their language; the server does not rewrite it); in their language; the server does not rewrite it);
* `client.log` / `server.log` — the session's evidence (`debugsession.bundle`) minus the rows the * `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 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); `.jsonl`: Gitea refuses that extension (measured);
* the screenshots, in the order they were taken. * 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 Every row is already redacted twice (at capture, and again when stored), and nothing here adds a
+5 -5
View File
@@ -1,5 +1,5 @@
"""The Gitea issue tracker a debug session's issue goes to (E16 S4) — measured against Gitea 1.27.1 """The Gitea issue tracker a debug session's issue goes to — measured against Gitea 1.27.1
before a line of this was written (plans/e16-debug-issue.md, S0). before a line of this was written.
Configured by an admin (sysconfig `issues_gitea_url` / `_repo` / `_token`, the token encrypted at 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 rest). The repository must be PRIVATE: an issue carries its reporter's log — page titles, filter
@@ -79,7 +79,7 @@ def target(db: Session) -> dict | None:
@contextmanager @contextmanager
def _client(settings: tuple[str, str, str]) -> Iterator[tuple[httpx.Client, str]]: 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 # 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). # line after `deliver` had ended one, and held it across the calls below.
url, repo, token = settings url, repo, token = settings
if not (url and repo and token): if not (url and repo and token):
raise GiteaError("not_configured") raise GiteaError("not_configured")
@@ -203,7 +203,7 @@ def deliver(
# call, so nothing holds a snapshot (and an idle connection) across minutes of tracker I/O. # 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 # 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, # 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 # with its snapshot pinned — across the repository check and the create (a review
# measured it in pg_stat_activity). A comment saying otherwise sat right here. # measured it in pg_stat_activity). A comment saying otherwise sat right here.
checkpoint() checkpoint()
with _client(settings) as (client, repo): with _client(settings) as (client, repo):
@@ -223,7 +223,7 @@ def deliver(
) )
# Built ON the state read at the start so nothing already in it is dropped. The claim # 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 # 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 # any `sending` passed to it and stamps the row's own from `token` (removing
# the spread left the whole suite green, because the claim never travelled here). # the spread left the whole suite green, because the claim never travelled here).
state = { state = {
**state, **state,
+11 -11
View File
@@ -10,7 +10,7 @@ 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: Event shape — the OpenTelemetry log data model, without the SDK; one JSON object per line:
ts · level · sev · source · logger · event · msg · attrs ts · level · sev · source · logger · event · msg · attrs
plus whatever correlation the context carries (`request_id`, `user_id`, `debug_session_id`, 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 `job_id`) and the app version. That shape is what the admin log console filters on and what a Gitea
issue attachment will carry, so it is deliberately flat and stable. issue attachment will carry, so it is deliberately flat and stable.
Call sites stay ordinary stdlib logging: Call sites stay ordinary stdlib logging:
@@ -106,7 +106,7 @@ class _RequestScope:
self.user_id: int | None = None self.user_id: int | None = None
# The user's consented diagnostics level as a logging number; None at the base level. # The user's consented diagnostics level as a logging number; None at the base level.
self.level: int | None = None self.level: int | None = None
# The user's own open debug session this request named (E16), else None. # The user's own open debug session this request named, else None.
self.debug_session_id: str | None = None self.debug_session_id: str | None = None
@@ -122,11 +122,11 @@ def set_user(
debug_session: str | None = None, debug_session: str | None = None,
) -> None: ) -> None:
"""Attribute the rest of this request's log lines to `user_id`, and log them at the user's """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 consented diagnostics `level` until `until` ("INFO" = no elevation). Called from the
auth dependency — the one place the session is actually resolved into an account — rather than 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. 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 `debug_session` tags every line with the user's open debug session. The caller has
already checked it is theirs and open; a session's level arrives here as `level`/`until`. 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 The root logger's floor is tracked per elevation: a request without the session's header (an
@@ -334,7 +334,7 @@ _RECORD_STANDARD = frozenset(
class ContextFilter(logging.Filter): class ContextFilter(logging.Filter):
"""Copies the correlation contextvars onto the record. A Filter (not the formatter) so the """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 values are captured at emit time, before a QueueHandler can move the record to another
thread — the DB sink in S3 depends on that.""" thread — the DB sink depends on that."""
def filter(self, record: logging.LogRecord) -> bool: def filter(self, record: logging.LogRecord) -> bool:
for name, var in _CONTEXT_VARS.items(): for name, var in _CONTEXT_VARS.items():
@@ -355,7 +355,7 @@ class ContextFilter(logging.Filter):
class LevelGate(logging.Filter): class LevelGate(logging.Filter):
"""The configured level, enforced at the HANDLER, with one exception: the lines of a request """The configured level, enforced at the HANDLER, with one exception: the lines of a request
whose user raised their diagnostics level (E22 S5). whose user raised their diagnostics level.
The root logger's level cannot do this alone: it is one number for the whole process, and the 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 point is to log ONE user's requests in detail while everyone else's stay at the base level. So
@@ -457,7 +457,7 @@ class TextFormatter(logging.Formatter):
# --- configuration ------------------------------------------------------------------------- # --- configuration -------------------------------------------------------------------------
# Third-party loggers that are noisy at our own level. Pinned explicitly so the diagnostics knob can # 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 # 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). # — raising THESE is a deliberate, separate act (the admin log console).
_THIRD_PARTY_LEVELS = { _THIRD_PARTY_LEVELS = {
"sqlalchemy": "WARNING", "sqlalchemy": "WARNING",
"sqlalchemy.engine": "WARNING", "sqlalchemy.engine": "WARNING",
@@ -481,7 +481,7 @@ def get_logger(name: str) -> logging.Logger:
`siftlode.plex.sync`. `siftlode.plex.sync`.
The names were literals before, and 25 of them covered 34 modules — six different Plex modules 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 all called themselves `siftlode.plex`, so neither a level pinned per logger nor the console's
filter could tell a sync line from a stream line. Derived from `__name__` there is exactly one 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 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. module hand-rolls one again.
@@ -562,12 +562,12 @@ def current_level() -> str:
return logging.getLevelName(_base_level or logging.getLogger().getEffectiveLevel()) return logging.getLevelName(_base_level or logging.getLogger().getEffectiveLevel())
# --- per-user elevation (E22 S5) ----------------------------------------------------------- # --- per-user elevation -----------------------------------------------------------
# The base level (`LOG_LEVEL`, then the admin knob). NOTSET until configure_logging runs — a unit # 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. # test, a script — so the gate lets everything through there, as it did before the gate existed.
_base_level = logging.NOTSET _base_level = logging.NOTSET
# → (level number, expiry) for every elevation this process has seen and not yet expired: a user's # → (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 # own, by user id, and a debug session's, by (user id, session id) — its requests alone carry
# its level, so it is tracked apart from the user's own requests. # its level, so it is tracked apart from the user's own requests.
_ElevationKey = int | tuple[int, str] _ElevationKey = int | tuple[int, str]
_elevations: dict[_ElevationKey, tuple[int, datetime]] = {} _elevations: dict[_ElevationKey, tuple[int, datetime]] = {}
@@ -667,7 +667,7 @@ def current_source() -> str:
# --- request logging ----------------------------------------------------------------------- # --- request logging -----------------------------------------------------------------------
# A client may supply its own request id so a browser log line and a server line join up (E22 S4). # A client may supply its own request id so a browser log line and a server line join up.
# Accepted only in this shape: it ends up in a log field, and an unbounded header would be a way to # 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. # write arbitrary text into the log.
_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{8,64}$") _REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
+4 -4
View File
@@ -1,4 +1,4 @@
"""The `log_events` sink (E22 S3): the stored copy of the log the diagnostics console reads. """The `log_events` sink: 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 stdout stays the complete log (docker keeps 3×50 MB of it); this table keeps the part worth querying
for `log_retention_days`: for `log_retention_days`:
@@ -6,7 +6,7 @@ for `log_retention_days`:
* every WARNING and above, * every WARNING and above,
* the `http.request` line of every request at INFO or above (not the DEBUG-level health checks and * the `http.request` line of every request at INFO or above (not the DEBUG-level health checks and
static files), static files),
* every line of a request whose user raised their diagnostics level (E22 S5 — `logs.LevelGate` * every line of a request whose user raised their diagnostics level (`logs.LevelGate`
marks them), which is the detail they consented to have kept, 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 * nothing a caller marked `extra={logs.PERSIST: False}` — how the request log caps anonymous
traffic per client. traffic per client.
@@ -347,8 +347,8 @@ def gc(db: Session, retention_days: int) -> int:
def serialize(row: LogEvent) -> dict: 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 stored row as the API sends it — one shape for every reader: the admin console and
a debug session's evidence list (E16).""" a debug session's evidence list."""
return { return {
"id": row.id, "id": row.id,
"ts": iso(row.ts), "ts": iso(row.ts),
+3 -3
View File
@@ -1,5 +1,5 @@
"""Bilingual text for every outbound mail, and the one resolver that decides which language a """Bilingual text for every outbound mail, and the one resolver that decides which language a
recipient gets (E9.5 S7). recipient gets.
Why a catalog instead of literals in `email.py`: HU/EN parity is a house rule for every user-facing Why a catalog instead of literals in `email.py`: HU/EN parity is a house rule for every user-facing
string, and a mail is the one user-facing surface the reader CANNOT re-render by switching the UI string, and a mail is the one user-facing surface the reader CANNOT re-render by switching the UI
@@ -82,8 +82,8 @@ def lang_for_email(db: Session, email: str) -> str:
# a name, not a translated string, and giving them keys would invite someone to "translate" them. # a name, not a translated string, and giving them keys would invite someone to "translate" them.
MAIL_TEXT: dict[str, dict[str, str]] = { MAIL_TEXT: dict[str, dict[str, str]] = {
# ── How long a link lasts ─────────────────────────────────────────────────────────────── # ── How long a link lasts ───────────────────────────────────────────────────────────────
# Rendered, not interpolated as a raw hour count: the approval mail's token lives seven days # Rendered, not interpolated as a raw hour count: the approval mail's token lives seven days,
# (E9.5 S6), and "expires in 168 hours" is a unit no reader converts. # and "expires in 168 hours" is a unit no reader converts.
"duration.hours": {"en": "{n} hours", "hu": "{n} óra"}, "duration.hours": {"en": "{n} hours", "hu": "{n} óra"},
"duration.days": {"en": "{n} days", "hu": "{n} nap"}, "duration.days": {"en": "{n} days", "hu": "{n} nap"},
# ── Shared closing fragments ──────────────────────────────────────────────────────────────── # ── Shared closing fragments ────────────────────────────────────────────────────────────────
+3 -3
View File
@@ -131,7 +131,7 @@ async def _reap_hls_sessions() -> None:
A FINISHED session is reaped on the short rule here, not kept for the full idle window: an open A FINISHED session is reaped on the short rule here, not kept for the full idle window: an open
player checkpoints every 10 s and that route touches the session, so a paused viewer is no longer player checkpoints every 10 s and that route touches the session, so a paused viewer is no longer
invisible to this pass (see `reap_idle`). Keeping every finished session for ten minutes instead invisible to this pass (see `reap_idle`). Keeping every finished session for ten minutes instead
held one whole segmented copy of a film per item on the download volume (round 5).""" held one whole segmented copy of a film per item on the download volume."""
while True: while True:
await asyncio.sleep(_HLS_REAP_EVERY_S) await asyncio.sleep(_HLS_REAP_EVERY_S)
try: try:
@@ -187,8 +187,8 @@ async def _nul_in_text(request: Request, exc: DataError) -> JSONResponse:
NUL in text, so the driver refuses to even send the query — and a `%00` in a query parameter or NUL in text, so the driver refuses to even send the query — and a `%00` in a query parameter or
an escaped NUL in a JSON body (a playlist's `contains=`, a reorder's key list, a collection an escaped NUL in a JSON body (a playlist's `contains=`, a reorder's key list, a collection
search) was a 500 with a traceback wherever it reached a comparison. Guarding each site did not search) was a 500 with a traceback wherever it reached a comparison. Guarding each site did not
hold: round 3 guarded the by-rating_key lookup and missed the rest. Any other DataError is left hold: an earlier fix guarded the by-rating_key lookup and missed the rest. Any other DataError
alone — re-raised, it stays the 500 it always was.""" is left alone — re-raised, it stays the 500 it always was."""
if "NUL (0x00)" not in str(exc.orig): if "NUL (0x00)" not in str(exc.orig):
raise exc raise exc
return JSONResponse( return JSONResponse(
+29 -29
View File
@@ -101,7 +101,7 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
# Google sign-in that failed to find it), and a migration must not merge accounts on its own. # Google sign-in that failed to find it), and a migration must not merge accounts on its own.
email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="") email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="")
# The address Google asserted for this account, recorded at sign-in / link time. NOT the same # The address Google asserted for this account, recorded at sign-in / link time. NOT the same
# thing as `email`: since E9.5 S3 a Google sign-in no longer overwrites the address the user # thing as `email`: a Google sign-in no longer overwrites the address the user
# registered with, so a deliberate `me+siftlode@gmail.com` survives — and this is where the other # registered with, so a deliberate `me+siftlode@gmail.com` survives — and this is where the other
# half of the truth lives, for the UI to show when the two differ. NULL until the next Google # half of the truth lives, for the UI to show when the two differ. NULL until the next Google
# sign-in for accounts linked before this column existed. # sign-in for accounts linked before this column existed.
@@ -109,7 +109,7 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
# argon2id hash for email+password login; NULL for Google-only accounts. # argon2id hash for email+password login; NULL for Google-only accounts.
password_hash: Mapped[str | None] = mapped_column(String(255)) password_hash: Mapped[str | None] = mapped_column(String(255))
# When SEC-10 dropped a preset password on Google adoption (see the single caller of # When SEC-10 dropped a preset password on Google adoption (see the single caller of
# auth.revoke_preset_password_if_unverified; admin approval no longer revokes — E9.5 S1). Without # auth.revoke_preset_password_if_unverified; admin approval no longer revokes). Without
# this trace a revoked account was indistinguishable from a Google-only one, so it failed the # this trace a revoked account was indistinguishable from a Google-only one, so it failed the
# password-reset eligibility check and could never be recovered — it could neither sign in nor # password-reset eligibility check and could never be recovered — it could neither sign in nor
# reset. Set = "had a password, lost it to the guard" -> reset-eligible. Cleared whenever a # reset. Set = "had a password, lost it to the guard" -> reset-eligible. Cleared whenever a
@@ -134,7 +134,7 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
# routes/auth._establish_session. NULL = not since the column existed (0085; no backfill is # routes/auth._establish_session. NULL = not since the column existed (0085; no backfill is
# possible). # possible).
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 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 # The diagnostics level this user consented to and when it lapses back to the base
# level. Both NULL = base. Written only by `PUT /api/me/diagnostics`, which computes the expiry # 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 # itself; read through `app.diagnostics.effective_level`, never directly (an expired level is
# not cleared eagerly — it simply stops counting). # not cleared eagerly — it simply stops counting).
@@ -212,7 +212,7 @@ class Invite(Base, CanonicalEmailMixin):
# The language this person asked us in ("en"/"hu"), captured when the request was made: the page # The language this person asked us in ("en"/"hu"), captured when the request was made: the page
# their form was on, or the locale Google reported for a refused sign-in. NULL for a row the # their form was on, or the locale Google reported for a refused sign-in. NULL for a row the
# admin whitelisted by hand — nobody has told us anything about that mailbox, which is exactly # admin whitelisted by hand — nobody has told us anything about that mailbox, which is exactly
# why the invitation send asks (E9.5 S7). Captured rather than derived for the same reason # why the invitation send asks. Captured rather than derived for the same reason
# `preferences.language` and `google_email` are: the signal exists only in that one moment and # `preferences.language` and `google_email` are: the signal exists only in that one moment and
# cannot be backfilled afterwards. # cannot be backfilled afterwards.
lang: Mapped[str | None] = mapped_column(String(8)) lang: Mapped[str | None] = mapped_column(String(8))
@@ -567,7 +567,7 @@ class AuditLog(Base):
class LogEvent(Base): class LogEvent(Base):
"""One stored log event (E22 S3) — the `log_events` table the diagnostics console reads. Rows are """One stored log event — 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 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). redacted (the same `logs.record_fields` the stdout line is built from).
@@ -580,7 +580,7 @@ class LogEvent(Base):
__tablename__ = "log_events" __tablename__ = "log_events"
__table_args__ = ( __table_args__ = (
Index("ix_log_events_user_ts", "user_id", "ts"), Index("ix_log_events_user_ts", "user_id", "ts"),
# E16: an issue's bundle is `WHERE debug_session_id = …`; nearly every row has none. # An issue's bundle is `WHERE debug_session_id = …`; nearly every row has none.
Index( Index(
"ix_log_events_debug_session", "ix_log_events_debug_session",
"debug_session_id", "debug_session_id",
@@ -606,7 +606,7 @@ class LogEvent(Base):
class DebugSession(Base): class DebugSession(Base):
"""One recording: Start → reproduce → Stop (E16, app/debugsession.py). While it is open, every """One recording: Start → reproduce → Stop (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`, 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 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. server set at Start; an open session past it is over whether or not the sweep has closed it yet.
@@ -631,12 +631,12 @@ class DebugSession(Base):
# app/debugsession.py `EndReason`: stopped by the user, or past `until`. # app/debugsession.py `EndReason`: stopped by the user, or past `until`.
ended_reason: Mapped[str | None] = mapped_column(String(16)) ended_reason: Mapped[str | None] = mapped_column(String(16))
issue_url: Mapped[str | None] = mapped_column(String(512)) 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). # The issue being written from it (app/debugsession.py `save_draft` holds its shape).
draft: Mapped[dict | None] = mapped_column(JSONB) draft: Mapped[dict | None] = mapped_column(JSONB)
class DebugScreenshot(Base): class DebugScreenshot(Base):
"""A screenshot attached to a debug session (E16) — pasted, dropped, or captured from the pill. """A screenshot attached to a debug session — pasted, dropped, or captured from the pill.
Stored in the row: bounded per session (app/debugsession.py), and gone with it by cascade.""" Stored in the row: bounded per session (app/debugsession.py), and gone with it by cascade."""
__tablename__ = "debug_screenshots" __tablename__ = "debug_screenshots"
@@ -1057,7 +1057,7 @@ class PlexLibrary(Base, TimestampMixin, UpdatedAtMixin):
kind: Mapped[str] = mapped_column(String(16)) # "movie" | "show" kind: Mapped[str] = mapped_column(String(16)) # "movie" | "show"
enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# HU-overlay support (E5 S5). `metadata_lang` tags the section's metadata language. An OVERLAY # HU-overlay support. `metadata_lang` tags the section's metadata language. An OVERLAY
# library (overlay_of set) is the Hungarian-metadata twin of another section: it is never browsable # library (overlay_of set) is the Hungarian-metadata twin of another section: it is never browsable
# on its own — its titles' HU fields are written onto the matched EN rows (paired by `guid`). # on its own — its titles' HU fields are written onto the matched EN rows (paired by `guid`).
metadata_lang: Mapped[str] = mapped_column(String(8), default="en", server_default="en") metadata_lang: Mapped[str] = mapped_column(String(8), default="en", server_default="en")
@@ -1086,7 +1086,7 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
) )
# Canonical external id (e.g. "tmdb:12345") — the key that pairs this EN row with its HU-overlay # Canonical external id (e.g. "tmdb:12345") — the key that pairs this EN row with its HU-overlay
# twin during sync (E5 S5). Indexed for the guid->row overlay match. # twin during sync. Indexed for the guid->row overlay match.
guid: Mapped[str | None] = mapped_column(String(64), index=True) guid: Mapped[str | None] = mapped_column(String(64), index=True)
title: Mapped[str] = mapped_column(String(512)) title: Mapped[str] = mapped_column(String(512))
summary: Mapped[str | None] = mapped_column(Text) summary: Mapped[str | None] = mapped_column(Text)
@@ -1116,7 +1116,7 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
) # cast+directors, folded into search_vector ) # cast+directors, folded into search_vector
# Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3. # Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so # Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched. # the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
imdb_rating: Mapped[float | None] = mapped_column( imdb_rating: Mapped[float | None] = mapped_column(
Float Float
@@ -1126,7 +1126,7 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
full_cast: Mapped[list | None] = mapped_column( full_cast: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True) JSONB(none_as_null=True)
) # ordered [{name, role, thumb}] ) # ordered [{name, role, thumb}]
# --- TMDB enrichment (E5 S2): see the identical block on plex_items for the full rationale. Additive; # --- TMDB enrichment: see the identical block on plex_items for the full rationale. Additive;
# served from the row; NULL until enriched. (`budget`/`revenue` stay NULL for shows — TV has none.) --- # served from the row; NULL until enriched. (`budget`/`revenue` stay NULL for shows — TV has none.) ---
tmdb_id: Mapped[int | None] = mapped_column(Integer) tmdb_id: Mapped[int | None] = mapped_column(Integer)
tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
@@ -1144,10 +1144,10 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str]
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
# 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. 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) tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline
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(
@@ -1209,7 +1209,7 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
) )
kind: Mapped[str] = mapped_column(String(16), index=True) # "movie" | "episode" kind: Mapped[str] = mapped_column(String(16), index=True) # "movie" | "episode"
# Canonical external id (e.g. "tmdb:12345") — pairs a MOVIE row with its HU-overlay twin during # Canonical external id (e.g. "tmdb:12345") — pairs a MOVIE row with its HU-overlay twin during
# sync (E5 S5); indexed for the guid->row match. Episodes carry it too but it's unused for them. # sync; indexed for the guid->row match. Episodes carry it too but it's unused for them.
guid: Mapped[str | None] = mapped_column(String(64), index=True) guid: Mapped[str | None] = mapped_column(String(64), index=True)
show_id: Mapped[int | None] = mapped_column( show_id: Mapped[int | None] = mapped_column(
ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True
@@ -1236,7 +1236,7 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
# Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed). # Browser-playability class from ffprobe: "direct" | "remux" | "transcode" (None = unprobed).
playable: Mapped[str | None] = mapped_column(String(12), index=True) playable: Mapped[str | None] = mapped_column(String(12), index=True)
probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) probed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Transcode HW-capability facet (E7 playback-class filter). NULL for non-transcode items; for a # Transcode HW-capability facet (the playback-class filter). NULL for non-transcode items; for a
# `playable="transcode"` item: True = the prod iGPU can hardware-transcode it (codec is HW-decodable # `playable="transcode"` item: True = the prod iGPU can hardware-transcode it (codec is HW-decodable
# AND the source is 8-bit SDR), False = software libx264 only (AV1/XviD, or a 10-bit/HDR source that # AND the source is 8-bit SDR), False = software libx264 only (AV1/XviD, or a 10-bit/HDR source that
# breaks the VAAPI nv12 pipeline — see plex/stream.py). Derived by `plex.sync.transcode_hw_capable`: # breaks the VAAPI nv12 pipeline — see plex/stream.py). Derived by `plex.sync.transcode_hw_capable`:
@@ -1272,7 +1272,7 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
people_text: Mapped[str | None] = mapped_column(Text) people_text: Mapped[str | None] = mapped_column(Text)
# Set once the movie's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3. # Set once the movie's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so # Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast so
# the info page serves from this row instead of a live plex.metadata() call. NULL until enriched. # the info page serves from this row instead of a live plex.metadata() call. NULL until enriched.
imdb_rating: Mapped[float | None] = mapped_column( imdb_rating: Mapped[float | None] = mapped_column(
Float Float
@@ -1282,7 +1282,7 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
full_cast: Mapped[list | None] = mapped_column( full_cast: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True) JSONB(none_as_null=True)
) # ordered [{name, role, thumb}] ) # ordered [{name, role, thumb}]
# --- TMDB enrichment (E5 S2): matched by the canonical `guid` ("tmdb:12345"), filled by the TMDB # --- TMDB enrichment: matched by the canonical `guid` ("tmdb:12345"), filled by the TMDB
# backfill job, SERVED FROM THIS ROW (never a live TMDB call). Additive — imdb_rating/rating # backfill job, SERVED FROM THIS ROW (never a live TMDB call). Additive — imdb_rating/rating
# above stay Plex-sourced; `tmdb_rating` is the distinct TMDb community score. NULL until enriched; # above stay Plex-sourced; `tmdb_rating` is the distinct TMDb community score. NULL until enriched;
# `tmdb_enriched_at` is the "tried" watermark (stamped on a definitive no-match too, so an # `tmdb_enriched_at` is the "tried" watermark (stamped on a definitive no-match too, so an
@@ -1305,11 +1305,11 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True))
backdrops: Mapped[list | None] = mapped_column( backdrops: Mapped[list | None] = mapped_column(
JSONB(none_as_null=True) JSONB(none_as_null=True)
) # TMDB backdrop file_paths [str] (crossfade pool, S8) ) # TMDB backdrop file_paths [str] (crossfade pool)
# 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. 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) tagline_hu: Mapped[str | None] = mapped_column(Text) # the HU overlay's tagline
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(
@@ -1348,15 +1348,15 @@ class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True, index=True) name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
photo_thumb: Mapped[str | None] = mapped_column(Text) # proxied /person-image URL, or None photo_thumb: Mapped[str | None] = mapped_column(Text) # proxied /person-image URL, or None
# 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 (the /person
# biography fetch + person-page render land in S6). NULL until a TMDB-enriched title names this person. # biography fetch builds on it). 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} by the people_enrich job # --- TMDB person enrichment: fetched from /person/{tmdb_person_id} by the people_enrich job,
# (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 # 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 # TMDB's Hungarian biography, from the same request's `translations`; a Hungarian reader is
# served it, and the instance-language `biography` when TMDB has none. Birthplace, aliases and # 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. # keywords have no Hungarian on TMDB, so they stay in the instance language.
biography_hu: Mapped[str | None] = mapped_column(Text) biography_hu: Mapped[str | None] = mapped_column(Text)
@@ -1397,7 +1397,7 @@ class PlexCollection(Base, TimestampMixin, UpdatedAtMixin):
class PlexState(Base, UpdatedAtMixin): class PlexState(Base, UpdatedAtMixin):
"""Per-user watch state for a Plex item — mirrors VideoState. Lives in Siftlode's own DB so """Per-user watch state for a Plex item — mirrors VideoState. Lives in Siftlode's own DB so
it works for users WITHOUT a Plex account (the whole point of the access-layer design). it works for users WITHOUT a Plex account (the whole point of the access-layer design).
`synced_to_plex` marks whether this state was pushed back to a linked Plex account (P5).""" `synced_to_plex` marks whether this state was pushed back to a linked Plex account."""
__tablename__ = "plex_states" __tablename__ = "plex_states"
__table_args__ = (UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),) __table_args__ = (UniqueConstraint("user_id", "item_id", name="uq_plex_user_item"),)
@@ -1476,7 +1476,7 @@ class PlexPlaylistItem(Base):
class ComfortPin(Base, TimestampMixin): class ComfortPin(Base, TimestampMixin):
"""One entry on a user's comfort shelf — the anti-paralysis fallback: "just put on X" (E24). """One entry on a user's comfort shelf — the anti-paralysis fallback: "just put on X".
Deliberately NOT a boolean on `VideoState`/`PlexState`, and NOT a polymorphic `(kind, ref)` string Deliberately NOT a boolean on `VideoState`/`PlexState`, and NOT a polymorphic `(kind, ref)` string
pair. A flag cannot ORDER a shelf and cannot hold a channel or a show — and comfort media is pair. A flag cannot ORDER a shelf and cannot hold a channel or a show — and comfort media is
+1 -1
View File
@@ -25,7 +25,7 @@ def norm_lang(lang: str | None) -> str:
return "hu" if lang == "hu" else "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 # Every card speaks the reader's language: `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 # 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. # `title_col`/`summary_col`. These rails once served English to a Hungarian reader.
def display_title(row: PlexItem | PlexShow, lang: str) -> str: def display_title(row: PlexItem | PlexShow, lang: str) -> str:
+3 -3
View File
@@ -88,14 +88,14 @@ class PlexClient:
"type": item_type, "type": item_type,
# includeGuids=1 carries each item's Guid array (tmdb/imdb/tvdb ids) in the cheap # includeGuids=1 carries each item's Guid array (tmdb/imdb/tvdb ids) in the cheap
# listing, so the sync can capture the canonical guid without a per-item rich fetch — # listing, so the sync can capture the canonical guid without a per-item rich fetch —
# the pairing key for the HU overlay (E5 S5). # the pairing key for the HU overlay.
"includeGuids": 1, "includeGuids": 1,
"X-Plex-Container-Start": start, "X-Plex-Container-Start": start,
"X-Plex-Container-Size": size, "X-Plex-Container-Size": size,
}, },
) )
# `totalSize` only: `size` is THIS page's count, and read as the total it made a single # `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). # page look like the whole section — "complete" to a prune or a reconcile.
total = mc.get("totalSize") total = mc.get("totalSize")
return mc.get("Metadata", []) or [], None if total is None else int(total) return mc.get("Metadata", []) or [], None if total is None else int(total)
@@ -142,7 +142,7 @@ class PlexClient:
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; Without one the uri would read `server:///…`, a malformed write Plex answers in its own way;
it is refused here, by name (E32 S2).""" it is refused here, by name."""
if not getattr(self, "_machine_id", None): if not getattr(self, "_machine_id", None):
mid = self._get("/identity").get("machineIdentifier") mid = self._get("/identity").get("machineIdentifier")
if not mid: if not mid:
+1 -1
View File
@@ -1,5 +1,5 @@
"""Parse the "info page" rich metadata out of a full Plex metadata dict, and persist the subset that """Parse the "info page" rich metadata out of a full Plex metadata dict, and persist the subset that
the detail endpoints serve from the DB row (E5 S5b). the detail endpoints serve from the DB row.
Shared by the live info-page path (routes.plex.browse) and the background rich re-sync (plex.sync) so Shared by the live info-page path (routes.plex.browse) and the background rich re-sync (plex.sync) so
the two never diverge — the same one-parser rule that keeps `people.py`'s cast handling consistent. the two never diverge — the same one-parser rule that keeps `people.py`'s cast handling consistent.
+1 -1
View File
@@ -1,4 +1,4 @@
"""One Hungarian word per Plex genre (E30). """One Hungarian word per Plex genre.
Plex's Hungarian agent returns a MIXED vocabulary: some genres come back translated ("Dráma", 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 "Vígjáték"), others stay English ("Action", "Adventure", "Biography"), and a title with no HU twin has
+1 -1
View File
@@ -26,7 +26,7 @@ _CT_EXT = {ct: ext for ext, ct in _EXT_CT}
# Target box per variant — Plex resizes to these server-side. The full poster (400px) covers a detail # Target box per variant — Plex resizes to these server-side. The full poster (400px) covers a detail
# page's ≤224px poster at 2× DPR, the full art (1280px) a hero backdrop. A card is much smaller: a grid # page's ≤224px poster at 2× DPR, the full art (1280px) a hero backdrop. A card is much smaller: a grid
# poster is ~144px and a shelf's landscape art ~212px, so the `_sm` pair serves those at 1.33× DPR # 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 # (cards used to fetch 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 # picks between a pair itself (`srcset`, built by the frontend's `lib/plexImage` cardImage, whose
# widths plexImage.contract.test holds to these). # widths plexImage.contract.test holds to these).
SIZES = { SIZES = {
+4 -4
View File
@@ -1,5 +1,5 @@
"""The HU overlay: a Hungarian Plex section written onto its English twin's rows (E30), split out of """The HU overlay: a Hungarian Plex section written onto its English twin's rows, split out of
`plex/sync.py` (E32 S2), which runs it once per paired section during the full sync. `plex/sync.py`, 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 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. no browsable rows of its own; it only fills the `*_hu` columns of the EN rows it matches by guid.
@@ -41,7 +41,7 @@ def sync_overlay(
"""Write an overlay section's Hungarian metadata onto its EN twin's rows, matched by guid. Creates """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 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 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 episodes are overlaid too: the HU section lists every episode with its Hungarian title and
summary, and the episode rows live in the EN show library. 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 This is a full RECONCILE: after a COMPLETE HU listing every guid-bearing EN row's HU fields are
@@ -141,7 +141,7 @@ def _clear_hu(by_guid: dict, episodes: bool) -> None:
cheap. The genres are the exception: every row gets Hungarian ones, from its HU twin or, lacking 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". 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 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).""" it failed to reach, until the next full sync (the watch reconcile's twin)."""
for group in by_guid.values(): for group in by_guid.values():
for row in group: for row in group:
row.title_hu = row.summary_hu = None row.title_hu = row.summary_hu = None
+2 -2
View File
@@ -160,7 +160,7 @@ def people_by_credits(
return [r[0] for r in rows] return [r[0] for r in rows]
# --- Portraits (E30 S6) --------------------------------------------------------------------------- # --- Portraits ---------------------------------------------------------------------------
# A Plex CDN headshot is a full-size original (~170 KB on average, measured). Plex's photo transcoder # 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 # 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: # 6rem avatar) at 2x density and a raised text size. The box is part of the cache key, like a poster's:
@@ -226,7 +226,7 @@ _DEAD = frozenset({404, 410})
def warm_portraits(db: Session, limit: int) -> dict: 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 """Cache up to `limit` not-yet-cached headshots, the most-credited people first, so a
person's face comes from local disk. Needs Plex (the resize) and the Plex module on; without them 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 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.""" answers "not found" for is marked and left for PORTRAIT_RETRY; Plex failing in a row ends the run."""
+10 -10
View File
@@ -31,9 +31,9 @@ PLEX_SORT_KEYS: tuple[str, ...] = (
"title", "title",
) )
# --- Language-aware column pickers (E5 S6) ---------------------------------- # --- Language-aware column pickers ----------------------------------
# #
# The HU-overlay twin (E5 S5) stores Hungarian metadata in `*_hu` columns on the SAME EN row. When # The HU-overlay twin stores Hungarian metadata in `*_hu` columns on the SAME EN row. When
# the app language is `hu` we serve those, falling back to the EN column where a row has no HU twin. # the app language is `hu` we serve those, falling back to the EN column where a row has no HU twin.
# Two flavours of picker, on purpose: # Two flavours of picker, on purpose:
# * DISPLAY / FILTER (`title_col`/`genres_col`): `coalesce(*_hu, *)` — an EN fallback so a row with # * DISPLAY / FILTER (`title_col`/`genres_col`): `coalesce(*_hu, *)` — an EN fallback so a row with
@@ -41,8 +41,8 @@ PLEX_SORT_KEYS: tuple[str, ...] = (
# HU summary directly off the loaded row, so no `summary_col` SQL helper is needed.) # HU summary directly off the loaded row, so no `summary_col` SQL helper is needed.)
# * SEARCH (`vector_col` + `field_recheck`): the raw `*_hu` column coalesced to '' — MIRRORING the # * SEARCH (`vector_col` + `field_recheck`): the raw `*_hu` column coalesced to '' — MIRRORING the
# generated `search_vector_hu` definition (which uses `coalesce(title_hu, '')`, NOT an EN # generated `search_vector_hu` definition (which uses `coalesce(title_hu, '')`, NOT an EN
# fallback), so the field recheck stays a strict subset of the GIN vector prefilter (the S2 # fallback), so the field recheck stays a strict subset of the GIN vector prefilter (the
# invariant). `people_text` is language-neutral and shared by both vectors. # field-search invariant). `people_text` is language-neutral and shared by both vectors.
def title_col(model, lang: str): def title_col(model, lang: str):
@@ -216,7 +216,7 @@ def meta_filter_conds(model, p: dict, lang: str = "en") -> list:
stds = split_csv(p.get("studios")) stds = split_csv(p.get("studios"))
if stds: # OR if stds: # OR
conds.append(model.studio.in_(stds)) conds.append(model.studio.in_(stds))
for kw in split_csv(p.get("keywords")): # AND — TMDB keyword chips from the detail page (E5 S5) for kw in split_csv(p.get("keywords")): # AND — TMDB keyword chips from the detail page
conds.append(model.keywords.contains([kw])) conds.append(model.keywords.contains([kw]))
csel = split_csv(p.get("collections")) csel = split_csv(p.get("collections"))
if ( if (
@@ -232,7 +232,7 @@ def apply_meta_filters(q, model, p: dict, lang: str = "en"):
return q.where(*conds) if conds else q return q.where(*conds) if conds else q
# --- Playback-class filter (E7) — MOVIE-ONLY (a show is an aggregate with no single class) --------- # --- Playback-class filter — MOVIE-ONLY (a show is an aggregate with no single class) ---------
# The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`. # The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`.
# ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds` # ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds`
# filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software. # filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software.
@@ -278,7 +278,7 @@ class LibraryFilters:
actors: str | None = None actors: str | None = None
actor_mode: str = "any" # how multiple actors combine: any (OR, default) | all (AND) actor_mode: str = "any" # how multiple actors combine: any (OR, default) | all (AND)
studios: str | None = None studios: str | None = None
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral keywords: str | None = None # TMDB keyword chips, CSV; language-neutral
collections: str | None = None # CSV of collection rating_keys collections: str | None = None # CSV of collection rating_keys
collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND) collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND)
playable: str | None = None # CSV of playback classes: direct|remux|transcode_hw|transcode_sw playable: str | None = None # CSV of playback classes: direct|remux|transcode_hw|transcode_sw
@@ -311,7 +311,7 @@ class LibraryFilters:
# watch state. The SAME predicate has to be applied in two places — `unified_library` (the grid) and # watch state. The SAME predicate has to be applied in two places — `unified_library` (the grid) and
# `facets.wsq` (so the facet counts reflect exactly the visible set) — and they MUST stay identical: # `facets.wsq` (so the facet counts reflect exactly the visible set) — and they MUST stay identical:
# a hidden movie kept out of the grid must not inflate the facets either. These helpers are the one # a hidden movie kept out of the grid must not inflate the facets either. These helpers are the one
# home for that predicate so the two call sites can't drift (E3 S1; pinned by test_plex_facets_grid_ # home for that predicate so the two call sites can't drift (pinned by test_plex_facets_grid_
# parity). Callers pass the already-joined state expressions, so this module stays model-agnostic: # parity). Callers pass the already-joined state expressions, so this module stays model-agnostic:
# * movies — `st` is an aliased PlexState outer-joined on (item_id, user_id). # * movies — `st` is an aliased PlexState outer-joined on (item_id, user_id).
# * shows — `tot`/`wat`/`inp` are the coalesced totals from `rollup.agg_subquery`. # * shows — `tot`/`wat`/`inp` are the coalesced totals from `rollup.agg_subquery`.
@@ -367,9 +367,9 @@ def apply_show_state_filter(query, tot, wat, inp, show: str):
def content_libs(db: Session) -> tuple[list[int], list[int]]: def content_libs(db: Session) -> tuple[list[int], list[int]]:
"""The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single """The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single
source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never
browsed on their own, they only lend their Hungarian metadata to the EN twin's rows (E5 S5). browsed on their own, they only lend their Hungarian metadata to the EN twin's rows.
Lives here, in the query layer, rather than in `routes/plex/_shared.py` where it started: E24's Lives here, in the query layer, rather than in `routes/plex/_shared.py` where it started: the
Home surface needs the same scope, and a domain package importing from `routes/` would invert the Home surface needs the same scope, and a domain package importing from `routes/` would invert the
one-way dependency CLAUDE.md draws. It never touched request state, so nothing was lost by the one-way dependency CLAUDE.md draws. It never touched request state, so nothing was lost by the
move.""" move."""
+2 -2
View File
@@ -13,10 +13,10 @@ from app.models import PlexItem, PlexState
# Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the # Sort sentinel for a rail row whose timestamp is missing, so `key=lambda r: r[0] or NO_TS` reads the
# same at every rail that merges two type-homogeneous lists in Python. # same at every rail that merges two type-homogeneous lists in Python.
# #
# It is NOT a crash fix, whatever the E24 commit that introduced it says. The `(ts is not None, ts)` # It is NOT a crash fix, whatever the commit that introduced it says. The `(ts is not None, ts)`
# key it replaced cannot raise: tuple comparison finds the first UNEQUAL element via `==`, so two # key it replaced cannot raise: tuple comparison finds the first UNEQUAL element via `==`, so two
# `(False, None)` keys compare equal and `None < None` is never reached — verified by execution # `(False, None)` keys compare equal and `None < None` is never reached — verified by execution
# during the E24 epic-end review, over every permutation of null/non-null rows. What the sentinel # during a review, over every permutation of null/non-null rows. What the sentinel
# buys is one idiom instead of two, and a key that says "missing sorts last" outright rather than # buys is one idiom instead of two, and a key that says "missing sorts last" outright rather than
# encoding it in a boolean flag. A BARE `r[0]` would genuinely raise, which is the mutation the # encoding it in a boolean flag. A BARE `r[0]` would genuinely raise, which is the mutation the
# rails' tests do guard against. # rails' tests do guard against.
+13 -13
View File
@@ -15,7 +15,7 @@ HLS muxer does the segmentation (the only reliable way to cut a stream-copy at k
Sessions live in this (single) API process; a periodic reaper kills idle ones and frees the temp Sessions live in this (single) API process; a periodic reaper kills idle ones and frees the temp
segments. The remux is CPU-light (video copy + a tiny audio transcode), so it runs fine on the segments. The remux is CPU-light (video copy + a tiny audio transcode), so it runs fine on the
CPU-only prod host; only P3's full transcode is CPU-heavy. CPU-only prod host; only the full transcode is CPU-heavy.
""" """
import os import os
@@ -120,8 +120,8 @@ class HlsSession:
# Unguessable per-session id, carried in the URLs the client is handed. A seek-restart # Unguessable per-session id, carried in the URLs the client is handed. A seek-restart
# replaces the viewer's registered session for the item — and the old player's next request # replaces the viewer's registered session for the item — and the old player's next request
# 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 # 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
# 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, # 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 # the item's rating_key (what the logs name) self.key = key # the item's rating_key (what the logs name)
@@ -413,7 +413,7 @@ def _venc_opts(
def _render_node_present() -> bool: def _render_node_present() -> bool:
"""Whether the VAAPI render node is available — i.e. `/dev/dri` was passed into the container. """Whether the VAAPI render node is available — i.e. `/dev/dri` was passed into the container.
Present on prod after the iGPU enablement; absent on the CPU-only dev box and in the localdev Present on prod after the iGPU enablement; absent on the CPU-only dev box and in the localdev
Docker. Its own function so the selection is monkeypatchable in tests and one place for S1c's knob.""" Docker. Its own function so the selection is monkeypatchable in tests and the one place to change it."""
return Path(_VAAPI_DEVICE).exists() return Path(_VAAPI_DEVICE).exists()
@@ -576,7 +576,7 @@ def _ffmpeg_cmd(
entry += ",default:yes" entry += ",default:yes"
var.append(entry) var.append(entry)
# The name patterns come from the vocabulary functions, with ffmpeg's own placeholders passed # The name patterns come from the vocabulary functions, with ffmpeg's own placeholders passed
# through them — so the producer and the parser cannot spell the layout differently (round 7). # through them — so the producer and the parser cannot spell the layout differently.
args += _HLS_TAIL + [ args += _HLS_TAIL + [
"-master_pl_name", "-master_pl_name",
_MASTER_PL, _MASTER_PL,
@@ -887,7 +887,7 @@ def current_session(user_id: int, key: str) -> HlsSession | None:
# The names ffmpeg writes into a session directory, in ONE place — and "one place" means the argv # The names ffmpeg writes into a session directory, in ONE place — and "one place" means the argv
# BUILDS its patterns from these same functions (`segment_name("%d", "%v")`), not that a comment # BUILDS its patterns from these same functions (`segment_name("%d", "%v")`), not that a comment
# says so. Getting this wrong is invisible in both directions: a name we failed to anticipate used # says so. Getting this wrong is invisible in both directions: a name we failed to anticipate used
# to fall back to "wait and see" (round 6's HIGH), and now that the rule is positive it falls back # to fall back to "wait and see", and now that the rule is positive it falls back
# to a 404 instead — so the producer and the parser have to be literally the same spelling. # to a 404 instead — so the producer and the parser have to be literally the same spelling.
# #
# MEASURED (ffmpeg 7.1.5 in the API container — the binary that actually writes these files — and # MEASURED (ffmpeg 7.1.5 in the API container — the binary that actually writes these files — and
@@ -901,7 +901,7 @@ def current_session(user_id: int, key: str) -> HlsSession | None:
# SESSION and not to HLS in general: a single-audio session can never write `master.m3u8`, # SESSION and not to HLS in general: a single-audio session can never write `master.m3u8`,
# `stream_*.m3u8` or any `seg_<variant>_<n>.ts`, and a multi-audio one can never write `index.m3u8` # `stream_*.m3u8` or any `seg_<variant>_<n>.ts`, and a multi-audio one can never write `index.m3u8`
# or `seg_<n>.ts`. Accepting the union meant a hopeless name of the other shape was waited out for # or `seg_<n>.ts`. Accepting the union meant a hopeless name of the other shape was waited out for
# the full playlist/segment timeout — the threadpool parking this guard exists to end (round 7). # the full playlist/segment timeout — the threadpool parking this guard exists to end.
_MASTER_PL = "master.m3u8" _MASTER_PL = "master.m3u8"
_INDEX_PL = "index.m3u8" _INDEX_PL = "index.m3u8"
_SEG_NAME_RE = re.compile(r"^seg_(?:([0-9a-z]{1,4})_)?(\d{1,9})\.ts$") _SEG_NAME_RE = re.compile(r"^seg_(?:([0-9a-z]{1,4})_)?(\d{1,9})\.ts$")
@@ -953,12 +953,12 @@ def artifact_may_arrive(s: "HlsSession", filename: str) -> bool:
name belonging to the other shape is as hopeless here as a random string. Reading those as "an name belonging to the other shape is as hopeless here as a random string. Reading those as "an
HLS name, so wait and see" left the whole class reachable — measured through the real route HLS name, so wait and see" left the whole class reachable — measured through the real route
against a live single-audio session, `master.m3u8` and `stream_q7z.m3u8` were 404s after 30.0 s against a live single-audio session, `master.m3u8` and `stream_q7z.m3u8` were 404s after 30.0 s
and `seg_a0_0.ts` / `seg_zzzz_0.ts` after 20.1 s each (deep-review round 7). and `seg_a0_0.ts` / `seg_zzzz_0.ts` after 20.1 s each.
Saying no at once matters because the route that waits is a SYNC one: it holds a thread off the Saying no at once matters because the route that waits is a SYNC one: it holds a thread off the
~40-slot pool, and a pooled DB connection with it, for the whole timeout. An ordinary seek past ~40-slot pool, and a pooled DB connection with it, for the whole timeout. An ordinary seek past
the produced region was enough to park one for 20 s, and forty concurrent requests for a name the produced region was enough to park one for 20 s, and forty concurrent requests for a name
that will never exist parked the whole API (deep-review round 5). Round 5's guard read only that will never exist parked the whole API. An earlier guard read only
single-audio segment names, so both halves of that were still fully reachable: every multi-audio single-audio segment names, so both halves of that were still fully reachable: every multi-audio
name fell through, and so did any `.m3u8`/`.vtt` name at all.""" name fell through, and so did any `.m3u8`/`.vtt` name at all."""
if filename in playlist_names(s.variants): if filename in playlist_names(s.variants):
@@ -1093,15 +1093,15 @@ def reap_idle(finished_idle_s: float = _FINISHED_IDLE_S) -> int:
A finished session is also what a PAUSED viewer has: a remux runs far faster than realtime, so A finished session is also what a PAUSED viewer has: a remux runs far faster than realtime, so
ffmpeg is done a minute or two in and writes ENDLIST; hls.js then stops asking for the playlist, ffmpeg is done a minute or two in and writes ENDLIST; hls.js then stops asking for the playlist,
and with its forward buffer full a paused player fetches nothing at all. With the 30 s rule that and with its forward buffer full a paused player fetches nothing at all. With the 30 s rule that
killed a one-minute pause, and playback died ~30 s after resuming (deep-review round 4). killed a one-minute pause, and playback died ~30 s after resuming.
What makes the short rule safe again is that fetching is no longer the only signal: a player What makes the short rule safe again is that fetching is no longer the only signal: a player
parked MID-ITEM checkpoints its position every 10 s, and that route calls `touch_session`, so a parked MID-ITEM checkpoints its position every 10 s, and that route calls `touch_session`, so a
viewer who is merely paused says so far more often than this rule asks (round 5). The heartbeat viewer who is merely paused says so far more often than this rule asks. The heartbeat
stops once the item is finished or before a position is known (`usePlexProgress.flush`) — both stops once the item is finished or before a position is known (`usePlexProgress.flush`) — both
states where reaping is correct, and where the player's 404 → reclaim recovers a viewer who states where reaping is correct, and where the player's 404 → reclaim recovers a viewer who
scrubs back in anyway. Do not restate this as "every 10 s whether or not it is playing": round 6 scrubs back in anyway. Do not restate this as "every 10 s whether or not it is playing":
found that claim in two comments and not in the code. Round 4's blanket a review found that claim in two comments and not in the code. An earlier blanket
`reap_abandoned` bought the same safety by keeping every finished session for ten minutes — each `reap_abandoned` bought the same safety by keeping every finished session for ten minutes — each
one a whole segmented copy of the film on a volume shared with the download center.""" one a whole segmented copy of the film on a volume shared with the download center."""
now = time.time() now = time.time()
+6 -6
View File
@@ -12,7 +12,7 @@ on a NON-empty listing — a section that returns zero items is left untouched,
glitch that yields an empty page can't wipe a library (the metadata is cheap to re-sync; an accidental glitch that yields an empty page can't wipe a library (the metadata is cheap to re-sync; an accidental
mass-delete is the only real risk). Library-level reconcile (dropping a whole removed section, the mass-delete is the only real risk). Library-level reconcile (dropping a whole removed section, the
dead `enabled` column) is a separate follow-up. Cast + intro/credit markers are fetched lazily on the dead `enabled` column) is a separate follow-up. Cast + intro/credit markers are fetched lazily on the
item-detail/player path (P2), not here. item-detail/player path, not here.
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -150,7 +150,7 @@ _GUID_PREFERENCE = ("tmdb", "imdb", "tvdb")
def _canonical_guid(meta: dict) -> str | None: def _canonical_guid(meta: dict) -> str | None:
"""The canonical external id used to pair an EN title with its HU-overlay twin (E5 S5). Reads the """The canonical external id used to pair an EN title with its HU-overlay twin. Reads the
`Guid` array (present only with includeGuids=1 in the listing), preferring tmdb > imdb > tvdb, `Guid` array (present only with includeGuids=1 in the listing), preferring tmdb > imdb > tvdb,
normalised to '<provider>:<id>' (e.g. 'tmdb:12345'). None when no known provider id is present.""" normalised to '<provider>:<id>' (e.g. 'tmdb:12345'). None when no known provider id is present."""
found: dict[str, str] = {} found: dict[str, str] = {}
@@ -419,7 +419,7 @@ def _paginate(plex: PlexClient, key: str, item_type: int, progress: dict | 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 # `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). # watch reconcile); an empty mirror or overlay must not read it so.
progress["complete"] = bool(total) and start >= total progress["complete"] = bool(total) and start >= total
progress["total"] = total progress["total"] = total
@@ -447,7 +447,7 @@ def _apply_facet_fields(row, meta: dict) -> None:
# 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
# person-page cast filter for names past the top ~20. The watermark gate below (`people_enriched_at # person-page cast filter for names past the top ~20. The watermark gate below (`people_enriched_at
# is None`) normally protects this, but it is NOT enough on its own: a migration that resets the # is None`) normally protects this, but it is NOT enough on its own: a migration that resets the
# watermark re-opens the regression (0065 nulled it, which is exactly how the S5b fix got undone in # watermark re-opens the regression (0065 nulled it, which is exactly how a fix got undone in
# prod). So guard STRUCTURALLY too, by LENGTH against the listing cap: a stored cast LONGER than the # prod). So guard STRUCTURALLY too, by LENGTH against the listing cap: a stored cast LONGER than the
# cap can only be an enriched fuller list, so keep it (robust to any watermark reset). A stored cast # cap can only be an enriched fuller list, so keep it (robust to any watermark reset). A stored cast
# at or below the cap is itself listing-sourced, so take the fresh listing — which tracks Plex cast # at or below the cap is itself listing-sourced, so take the fresh listing — which tracks Plex cast
@@ -694,7 +694,7 @@ def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str)
def _believed_children(plex: PlexClient, rk: str, meta: dict) -> list[dict] | None: 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 """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 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 empty-answer rule as the collection listing's. Rebuilding on it showed the
collection empty until the next sync.""" collection empty until the next sync."""
children = plex.collection_children(rk) children = plex.collection_children(rk)
count = str(meta.get("childCount") or "0") count = str(meta.get("childCount") or "0")
@@ -745,7 +745,7 @@ def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: di
if not cols and existing: if not cols and existing:
# The catalog's own rule (see the module doc): an empty listing while we hold rows is a Plex # 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 # 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). # (its user-set `editable` flag with it) and every member's keys.
log.warning( log.warning(
"Plex listed no collections for a library that has some; keeping them this run", "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), extra=logs.event("plex.sync.collections_empty", library=lib.plex_key),
+11 -11
View File
@@ -1,4 +1,4 @@
"""TMDB enrichment for the mirrored Plex catalog (E5 S2). """TMDB enrichment for the mirrored Plex catalog.
A thin synchronous TMDB v3 client plus the parse/persist of the enrichment fields onto plex_items / A thin synchronous TMDB v3 client plus the parse/persist of the enrichment fields onto plex_items /
plex_shows, matched by the stored canonical `guid` ("tmdb:12345", or imdb/tvdb resolved via /find). plex_shows, matched by the stored canonical `guid` ("tmdb:12345", or imdb/tvdb resolved via /find).
@@ -134,7 +134,7 @@ class TmdbClient:
return cast("list[dict] | None", self._get("/configuration/countries")) 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) + the bio's translations (E30 S4), in a single """One person's bio + combined credits + the bio's translations, in a single
append_to_response request.""" append_to_response request."""
return self._get( return self._get(
f"/person/{person_id}", {"append_to_response": "combined_credits,translations"} f"/person/{person_id}", {"append_to_response": "combined_credits,translations"}
@@ -226,7 +226,7 @@ def parse_tmdb(payload: dict, media_type: str) -> dict:
crew.sort(key=lambda c: priority.get(c["job"], 99)) crew.sort(key=lambda c: priority.get(c["job"], 99))
crew = crew[:12] crew = crew[:12]
# name -> tmdb person id, for the plex_people foundation (S6 fetches bios off these ids). # name -> tmdb person id, for the plex_people foundation (bios are fetched off these ids).
people: list[dict] = [] people: list[dict] = []
for c in (credits.get("cast") or [])[:30]: for c in (credits.get("cast") or [])[:30]:
if c.get("name") and c.get("id"): if c.get("name") and c.get("id"):
@@ -266,7 +266,7 @@ 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 # The ISO code beside TMDB's English name: the client names the country in the reader's
# language from the code; the name is the fallback for a code it cannot name. # language from the code; the name is the fallback for a code it cannot name.
"production_countries": [ "production_countries": [
{"code": c.get("iso_3166_1") or None, "name": c["name"]} {"code": c.get("iso_3166_1") or None, "name": c["name"]}
@@ -373,14 +373,14 @@ def enrich_batch(db: Session, tmdb: TmdbClient, limit: int) -> int:
def _country(c: dict | str) -> dict: def _country(c: dict | str) -> dict:
"""One stored production country as served: `{code, name}`. A row enriched before E30 stored the """One stored production country as served: `{code, name}`. An older row stored the
bare English name; until `backfill_country_codes` reaches it, it serves with no code (the client bare English name; until `backfill_country_codes` reaches it, it serves with no code (the client
shows the name).""" shows the name)."""
return c if isinstance(c, dict) else {"code": None, "name": c} return c if isinstance(c, dict) else {"code": None, "name": c}
def backfill_country_codes(db: Session, tmdb: TmdbClient) -> int: 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 """Give the older countries (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 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. 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.""" A name the list does not hold keeps no code. Returns the number of rows rewritten."""
@@ -418,7 +418,7 @@ def run_enrich(db: Session) -> dict:
def run_people_enrich(db: Session) -> dict: def run_people_enrich(db: Session) -> dict:
"""Scheduler entry point of people_enrich (E30 S6): a batch of person bios (the most-credited """Scheduler entry point of people_enrich: 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 / first) and a batch of portraits. Each half is a no-op — never an error — without its TMDB key /
without Plex.""" without Plex."""
batch = settings.people_enrich_batch batch = settings.people_enrich_batch
@@ -475,7 +475,7 @@ def resolve_recs(db: Session, recs: list[dict] | None, *, lang: str) -> list[dic
"""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. One we have is named as the library names it, in 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.""" `lang`; 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)
@@ -515,7 +515,7 @@ def serve_block(db: Session, row, *, lang: str) -> dict | None:
} }
# --- Person enrichment (E5 S6) ------------------------------------------------------------------- # --- Person enrichment -------------------------------------------------------------------
# A person's bio + birth facts + a "Known for" credit pool, fetched once — by the people_enrich job, or # A person's bio + birth facts + a "Known for" credit pool, fetched once — by the people_enrich job, or
# 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 # 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).
@@ -525,7 +525,7 @@ def serve_block(db: Session, row, *, lang: str) -> dict | None:
# credits are often not the 15 a given library holds. # credits are often not the 15 a given library holds.
_KNOWN_FOR_MAX = 40 _KNOWN_FOR_MAX = 40
# TMDB genre ids of the shows a person visits as a guest (News, Reality, Talk). Ranked by popularity, # 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). # talk-show visits crowded out the films (Tom Cruise: 14 of 15 were talk shows).
_GUEST_GENRES = {10763, 10764, 10767} _GUEST_GENRES = {10763, 10764, 10767}
# A credit in which the person plays themselves ("Self", "Himself - Guest", "Herself (archive footage)"). # 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) _SELF_CREDIT = re.compile(r"^\s*(self|himself|herself|themselves)\b", re.IGNORECASE)
@@ -646,7 +646,7 @@ _PEOPLE_FAILS_IN_A_ROW = 3
def enrich_people_batch(db: Session, tmdb: TmdbClient, limit: int) -> dict: 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 """Fetch up to `limit` linked, not-yet-enriched people, 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 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 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.""" next run); _PEOPLE_FAILS_IN_A_ROW of them in a row end the batch."""
+6 -7
View File
@@ -11,7 +11,7 @@ already pages) and upsert them into the owner's `plex_states`. Plex wins on over
exist only in Siftlode are left untouched (union, Plex-authoritative on the intersection). exist only in Siftlode are left untouched (union, Plex-authoritative on the intersection).
Phases B (Siftlode→Plex push via scrobble/timeline) and C (incremental Plex→Siftlode via the watch Phases B (Siftlode→Plex push via scrobble/timeline) and C (incremental Plex→Siftlode via the watch
history + onDeck) build on this and land in later ships. As of S2 Plex is the source of truth: the history + onDeck) build on this and land in later ships. Plex is now the source of truth: the
player pushes the resume position back to Plex on the periodic checkpoint (not just the final flush), player pushes the resume position back to Plex on the periodic checkpoint (not just the final flush),
and the incremental pull resolves a genuine divergence to Plex's state (see `_pull_apply`) rather than and the incremental pull resolves a genuine divergence to Plex's state (see `_pull_apply`) rather than
running a last-write-wins comparison. running a last-write-wins comparison.
@@ -81,7 +81,7 @@ def _scan_plex_states(
The settled keys matter to the reconcile alone: it reads "absent from the scan" as "Plex 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 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 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).""" mirrored items are kept) must not stop un-watches syncing in all the others."""
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. # The sections we mirror something from: a total of 0 from one of those contradicts the mirror.
@@ -117,7 +117,7 @@ def _scan_plex_states(
out[item_id] = res out[item_id] = res
# A section the server SAYS is empty (total 0, nothing listed) settles too. Believed only for # 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 # 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 # the library for a moment, not one that un-watched all of it — so it is left
# unsettled, and its rows are not cleared. # unsettled, and its rows are not cleared.
listed = scanned > before listed = scanned > before
said_empty = progress.get("total") == 0 and not listed and key not in mirrored said_empty = progress.get("total") == 0 and not listed and key not in mirrored
@@ -402,7 +402,7 @@ def _pull_apply(
if st is not None and not st.synced_to_plex: if st is not None and not st.synced_to_plex:
st.synced_to_plex = True # already matches Plex → just settle the flag, no churn st.synced_to_plex = True # already matches Plex → just settle the flag, no churn
return None return None
# Plex-authoritative (S2): on a genuine divergence for an item Plex knows about, Plex wins — no # Plex-authoritative: on a genuine divergence for an item Plex knows about, Plex wins — no
# last-write-wins tug-of-war. This is safe because prompt write-back (playback.py pushes the resume # last-write-wins tug-of-war. This is safe because prompt write-back (playback.py pushes the resume
# position to Plex on the ~10s checkpoint, not just on the final flush) keeps Plex current, so a # position to Plex on the ~10s checkpoint, not just on the final flush) keeps Plex current, so a
# local edit that's "newer" than Plex has almost always already been pushed and matched above by # local edit that's "newer" than Plex has almost always already been pushed and matched above by
@@ -620,9 +620,8 @@ def _clear_unwatched(db: Session, user_id: int, plex_states: dict, settled: set[
"""Clear the user's rows Plex no longer has a record for — the mirrored ones (Plex un-watched """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 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 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 reach, and clearing those un-watched them here until the next complete run; an item of
a library the allowlist leaves out is absent from every scan, and was cleared on every run (E32 a library the allowlist leaves out is absent from every scan, and was cleared on every run."""
review)."""
n = 0 n = 0
rows = db.execute( rows = db.execute(
select(PlexState) select(PlexState)
+1 -1
View File
@@ -25,7 +25,7 @@ def report(current: int, total: int | None = None, phase: str | None = None) ->
class JobFailed(RuntimeError): class JobFailed(RuntimeError):
"""A job's own failure, said so the reader's language can name it (E31): the client reads """A job's own failure, said so the reader's language can name it: the client reads
`scheduler.failure.<reason>` and interpolates `params` (keep them to values the reader may see — `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 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, upstream's own error (a timeout, a refused connection) is not one of these: it stays as raised,
+2 -2
View File
@@ -1,4 +1,4 @@
"""Route-layer shared helpers for the API contract (R6 S1a). """Route-layer shared helpers for the API contract.
Small, dependency-light guards that several route modules had hand-rolled with subtle Small, dependency-light guards that several route modules had hand-rolled with subtle
drift. Keeping them here (imported by the route modules) means the "fetch a row or 404" drift. Keeping them here (imported by the route modules) means the "fetch a row or 404"
@@ -128,7 +128,7 @@ async def capped_stream_to(
def issue_refused(exc: gitea.GiteaError) -> HTTPException: def issue_refused(exc: gitea.GiteaError) -> HTTPException:
"""The refusal an issue-tracker failure (E16) answers as — the send, the preview and the """The refusal an issue-tracker failure answers as — the send, the preview and the
admin's connection test alike. Literal reasons, one per case: the frontend's contract test reads 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.""" them to require a sentence for each in both languages."""
match exc.reason: match exc.reason:
+16 -15
View File
@@ -138,20 +138,21 @@ def _activate_user_by_email(db: Session, email: str) -> None:
"""Approving access activates a matching (e.g. pending password-registration) account so """Approving access activates a matching (e.g. pending password-registration) account so
it can sign in. No-op if no such user exists yet (a Google user is created at first login). it can sign in. No-op if no such user exists yet (a Google user is created at first login).
E9.5 S1: this path deliberately does NOT revoke a preset password. Revoking here destroyed the This path deliberately does NOT revoke a preset password. Revoking here destroyed the
password a legitimate registrant had just chosen and made "your password was cleared for password a legitimate registrant had just chosen and made "your password was cleared for
security" the DEFAULT experience, because approve-before-verify is the normal ordering. security" the DEFAULT experience, because approve-before-verify is the normal ordering.
S1 justified that by saying `password_login` refuses an unverified account BEFORE it looks at That was once justified by saying `password_login` refuses an unverified account BEFORE it
`is_active`, so a pre-emptor could not sign in "approved or not". A review of the finished epic looks at `is_active`, so a pre-emptor could not sign in "approved or not". A review of the
showed that argument only holds while the address stays unverified — and S6's approval mail finished epic showed that argument only holds while the address stays unverified — and the
hands the real owner a confirmation link precisely so they will end that state. Nothing replaced approval mail hands the real owner a confirmation link precisely so they will end that state.
the revoke, and that is deliberate: round 2 of the same review tried "the newest registration Nothing replaced the revoke, and that is deliberate: the same review later tried "the newest
owns an unverified account" and reverted it, because the rule is symmetric — an attacker can registration owns an unverified account" and reverted it, because the rule is symmetric — an
always register last — and it handed anonymous callers a write onto a real account's credential. attacker can always register last — and it handed anonymous callers a write onto a real
The owner's recourse is password-reset, which proves the mailbox, rewrites the hash and bumps account's credential. The owner's recourse is password-reset, which proves the mailbox, rewrites
the session epoch. The residual is logged as SEC-16. The revoke also stays on the Google-adoption path the hash and bumps the session epoch. The residual is logged as SEC-16. The revoke also stays on
(auth.revoke_preset_password_if_unverified), where ownership is actually attested.""" the Google-adoption path (auth.revoke_preset_password_if_unverified), where ownership is
actually attested."""
# Canonical, with the literal spelling preferred when both exist: approving # Canonical, with the literal spelling preferred when both exist: approving
# `npeter83.temu@gmail.com` must activate the account that registered as `npeter83temu@gmail.com`, # `npeter83.temu@gmail.com` must activate the account that registered as `npeter83temu@gmail.com`,
# since Gmail delivers both to one mailbox and the admin decided about the mailbox. # since Gmail delivers both to one mailbox and the admin decided about the mailbox.
@@ -402,7 +403,7 @@ def _serialize_user(u: User) -> dict:
# deciding what kind of help someone needs. # deciding what kind of help someone needs.
"password_revoked_at": iso(u.password_revoked_at), "password_revoked_at": iso(u.password_revoked_at),
"has_google": u.google_sub is not None, "has_google": u.google_sub is not None,
# Only when it DIFFERS from the account address (E9.5 S3): "connected" is not the same # Only when it DIFFERS from the account address: "connected" is not the same
# statement as "connected as someone else", and the second one is the one that explains a # statement as "connected as someone else", and the second one is the one that explains a
# 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,
@@ -551,7 +552,7 @@ def admin_delete_user(
return {"deleted": user_id} return {"deleted": user_id}
# --- Stuck sign-ups: the admin lifeline (E9.5 S5, roadmap UX-25) --------------------------------- # --- Stuck sign-ups: the admin lifeline -----------------------------------------------------------
def _pending_access_rows(db: Session) -> list[User]: def _pending_access_rows(db: Session) -> list[User]:
@@ -609,7 +610,7 @@ def user_access(
_: User = Depends(admin_user), _: User = Depends(admin_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Everything the account panel shows about why this person can or cannot get in (E12 S5). """Everything the account panel shows about why this person can or cannot get in.
Its own endpoint rather than extra columns on /users: `is_allowed` and `can_activate` each run a Its own endpoint rather than extra columns on /users: `is_allowed` and `can_activate` each run a
query, and the panel opens for ONE user at a time — folding them into the list would pay that cost query, and the panel opens for ONE user at a time — folding them into the list would pay that cost
@@ -628,7 +629,7 @@ def create_set_password_link(
admin: User = Depends(admin_user), admin: User = Depends(admin_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Mint a one-time link that lets this account set a password, with no email involved (E12 S5). """Mint a one-time link that lets this account set a password, with no email involved.
The answer to SEC-15, SEC-16 and "no SMTP and someone forgot their password" at once. The admin The answer to SEC-15, SEC-16 and "no SMTP and someone forgot their password" at once. The admin
hands the link over by whatever channel they trust; opening it sets a password and signs out hands the link over by whatever channel they trust; opening it sets a password and signs out
+1 -1
View File
@@ -1,4 +1,4 @@
"""The admin diagnostics console's read side (E22.6): query `log_events`, the server's stored log. """The admin diagnostics console's read side: query `log_events`, the server's stored log.
Admin-only and read-only — rows leave the table through the retention job (`log_retention_days`), 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, never through here. Reading is not audited: it changes nothing, and the console's live tail polls,
+23 -23
View File
@@ -1,6 +1,6 @@
"""Auth HTTP endpoints (OAuth login/callback, register/verify, password, session mgmt). """Auth HTTP endpoints (OAuth login/callback, register/verify, password, session mgmt).
Split out of app/auth.py (R12 S2); the core auth surface (current_user, scopes, token Split out of app/auth.py; the core auth surface (current_user, scopes, token
helpers) stays in app/auth.py, imported below.""" helpers) stays in app/auth.py, imported below."""
import secrets import secrets
@@ -53,7 +53,7 @@ VERIFY_TTL = timedelta(hours=24)
# in and sets no password. # in and sets no password.
APPROVAL_VERIFY_TTL = timedelta(days=7) APPROVAL_VERIFY_TTL = timedelta(days=7)
RESET_TTL = timedelta(hours=1) RESET_TTL = timedelta(hours=1)
# An admin-issued set-password link (E12 S5) travels out of band — the admin has to actually reach the # An admin-issued set-password link travels out of band — the admin has to actually reach the
# person and hand it over — so the one-hour reset window is far too short. A day matches VERIFY_TTL and # person and hand it over — so the one-hour reset window is far too short. A day matches VERIFY_TTL and
# the affordance the admin was shown. # the affordance the admin was shown.
ADMIN_SETPW_TTL = timedelta(hours=24) ADMIN_SETPW_TTL = timedelta(hours=24)
@@ -163,7 +163,7 @@ class InviteRequest(NamedTuple):
`invite` is the row the request now lives under — which is NOT always the address that was just `invite` is the row the request now lives under — which is NOT always the address that was just
typed: one Gmail mailbox spelled two ways settles on whichever row already exists, and that row's typed: one Gmail mailbox spelled two ways settles on whichever row already exists, and that row's
address is where mail will go. Callers that tell the user what happened need it (E9.5 S4). address is where mail will go. Callers that tell the user what happened need it.
`is_new` is the old boolean return in a name: true only when a row was created or a denied one `is_new` is the old boolean return in a name: true only when a row was created or a denied one
reopened, i.e. exactly when admins should hear about it. A repeat request must not re-spam them. reopened, i.e. exactly when admins should hear about it. A repeat request must not re-spam them.
""" """
@@ -188,7 +188,7 @@ def upsert_pending_invite(db: Session, email: str, lang: str | None = None) -> I
The commit contract is unchanged: this still commits ONLY when it writes something. Callers rely The commit contract is unchanged: this still commits ONLY when it writes something. Callers rely
on that — `_register_account` persists its own flushed user precisely because a no-op here on that — `_register_account` persists its own flushed user precisely because a no-op here
commits nothing (E9.5 S6). commits nothing.
""" """
email = (email or "").lower() email = (email or "").lower()
if not valid_email(email): if not valid_email(email):
@@ -245,7 +245,7 @@ def _access_notice_url(kind: str, email: str = "", inv: Invite | None = None) ->
It rides the FRAGMENT, not the query string, for the same reason the verify/reset tokens do It rides the FRAGMENT, not the query string, for the same reason the verify/reset tokens do
(SB3): a fragment never reaches the server, a proxy access log or a Referer header — and this (SB3): a fragment never reaches the server, a proxy access log or a Referer header — and this
notice now carries the user's address. Naming it is the point of E9.5 S4: on 2026-09-03 a notice now carries the user's address. Naming it is the point: on 2026-09-03 a
registration as `npeter83.temu@` met an OAuth claim of `npeter83temu@`, and a screen that said registration as `npeter83.temu@` met an OAuth claim of `npeter83temu@`, and a screen that said
"request recorded" without saying FOR WHAT hid the mismatch for a day. "request recorded" without saying FOR WHAT hid the mismatch for a day.
@@ -297,9 +297,9 @@ class EmailPasswordIn(BaseModel):
password: str | None = None password: str | None = None
# The language the sign-up page was in. Captured at registration because it is the ONLY moment # The language the sign-up page was in. Captured at registration because it is the ONLY moment
# the choice exists anywhere the server can see: it lives in the browser until an account does, # the choice exists anywhere the server can see: it lives in the browser until an account does,
# and a preference nobody recorded cannot be backfilled later (the lesson `google_email` taught # and a preference nobody recorded cannot be backfilled later (as `google_email` showed: those
# in E9.5 S3 — those rows still read NULL until each user signs in again). Nothing reads it yet; # rows still read NULL until each user signs in again). Nothing reads it yet; the bilingual
# the bilingual mail templates are E9.5 S7. # mail templates will.
lang: str | None = None lang: str | None = None
@@ -510,7 +510,7 @@ def _sync_google_email(db: Session, user: User, email: str) -> None:
of the spelling they chose, because sign-in matches the stored address first. Google returns the of the spelling they chose, because sign-in matches the stored address first. Google returns the
account's PRIMARY address, which need not be what they registered with — and can never contain a account's PRIMARY address, which need not be what they registered with — and can never contain a
`+` at all — so without this guard every plus-tagged registration lost its tag on the first Google `+` at all — so without this guard every plus-tagged registration lost its tag on the first Google
sign-in. (E9.5 S3 made that reachable by matching adoption across spellings; the rewrite itself is sign-in. (Matching adoption across spellings made that reachable; the rewrite itself is
older.) older.)
Never overwrite with an address another account already owns either: that would hit the unique Never overwrite with an address another account already owns either: that would hit the unique
@@ -722,7 +722,7 @@ def _consume_password_token(db: Session, raw: str | None) -> tuple[User, str] |
def issue_set_password_link(db: Session, user: User) -> tuple[str, datetime]: def issue_set_password_link(db: Session, user: User) -> tuple[str, datetime]:
"""An admin-issued, single-use link that lets `user` set a password without any email (E12 S5). """An admin-issued, single-use link that lets `user` set a password without any email.
One mechanism answers three things: SEC-15 (on a no-SMTP instance every registration is One mechanism answers three things: SEC-15 (on a no-SMTP instance every registration is
auto-verified, so a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address auto-verified, so a pre-empted address cannot be recovered by mail), SEC-16 (a pre-empted address
@@ -804,10 +804,10 @@ def _register_account(email: str, password: str, lang: str | None = None) -> Non
# for the same input — so it is not a new capability, just the end of a dead end. An # for the same input — so it is not a new capability, just the end of a dead end. An
# account that is already confirmed still gets nothing: there is nothing to confirm. # account that is already confirmed still gets nothing: there is nothing to confirm.
# #
# And a re-send is ALL it does: the submitted password is discarded. E9.5's closing # And a re-send is ALL it does: the submitted password is discarded. A closing
# review tried "while the address is unverified, the newest registration owns it" # review tried "while the address is unverified, the newest registration owns it"
# (replace the hash, kill the outstanding verify tokens) as a fix for address # (replace the hash, kill the outstanding verify tokens) as a fix for address
# pre-emption, and round 2 showed it was worse than the problem — DO NOT REPEAT IT. # pre-emption, and it proved worse than the problem — DO NOT REPEAT IT.
# The rule is symmetric, and an attacker can always be last: it hands an unauthenticated # The rule is symmetric, and an attacker can always be last: it hands an unauthenticated
# caller the power to overwrite a real registrant's chosen password and delete the # caller the power to overwrite a real registrant's chosen password and delete the
# confirmation link already in their inbox, and on a SEC-10-stripped row (password_hash # confirmation link already in their inbox, and on a SEC-10-stripped row (password_hash
@@ -866,7 +866,7 @@ def _register_account(email: str, password: str, lang: str | None = None) -> Non
def issue_verification_link(db: Session, user: User, ttl: timedelta | None = None) -> str: def issue_verification_link(db: Session, user: User, ttl: timedelta | None = None) -> str:
"""A fresh confirmation link for `user`, returned rather than sent — for a mail that carries the """A fresh confirmation link for `user`, returned rather than sent — for a mail that carries the
link inside a larger message (the branching approval/invitation mails, E9.5 S6). link inside a larger message (the branching approval/invitation mails).
Same token machinery and the same fragment (SB3) as the standalone confirmation mail, so the two Same token machinery and the same fragment (SB3) as the standalone confirmation mail, so the two
kinds of link cannot drift apart in shape or in how they are consumed.""" kinds of link cannot drift apart in shape or in how they are consumed."""
@@ -881,8 +881,8 @@ def send_verification_link(db: Session, user: User) -> bool:
address is the one its owner chose to be reachable at, and a `+tag` there is usually deliberate. address is the one its owner chose to be reachable at, and a `+tag` there is usually deliberate.
The token rides the URL FRAGMENT (#) so it can never reach a proxy/access log or a Referer (SB3). The token rides the URL FRAGMENT (#) so it can never reach a proxy/access log or a Referer (SB3).
One definition, three callers — registration, the self-service resend, and the admin-side resend One definition, three callers — registration, the self-service resend, and the admin-side resend.
(E9.5 S5). The link's shape and TTL must not drift between them; a verification mail that differs The link's shape and TTL must not drift between them; a verification mail that differs
by who triggered it is a bug waiting to be discovered by exactly one user. by who triggered it is a bug waiting to be discovered by exactly one user.
Returns whether the mail actually went out, so a caller with a human waiting on the answer (the Returns whether the mail actually went out, so a caller with a human waiting on the answer (the
@@ -905,8 +905,8 @@ def verify_email_confirm(body: TokenIn, db: Session = Depends(get_db)) -> dict:
dropped the password this account registered with, so sign-in would answer with the deliberately dropped the password this account registered with, so sign-in would answer with the deliberately
uniform "Invalid email or password" — true, but unreadable to the person who just proved they own uniform "Invalid email or password" — true, but unreadable to the person who just proved they own
the mailbox. Saying so leaks nothing: holding a valid single-use verify token for this account the mailbox. Saying so leaks nothing: holding a valid single-use verify token for this account
already proves that much. Since E9.5 S1 the common source of that state is gone (admin approval no already proves that much. The common source of that state is gone (admin approval no longer
longer revokes), leaving accounts stripped before that change and the Google-adoption path, so this revokes), leaving accounts stripped before that change and the Google-adoption path, so this
flag is rare now — but it must stay, because those users have no other way back in.""" flag is rare now — but it must stay, because those users have no other way back in."""
user = _consume_token(db, body.token, "verify") user = _consume_token(db, body.token, "verify")
if user is None: if user is None:
@@ -966,7 +966,7 @@ def _login_refused(status: int, reason: str, user: User | None = None, **extra)
that proof (`invalid`, `rate_limited`) carry nothing at all, so they stay byte-identical for a that proof (`invalid`, `rate_limited`) carry nothing at all, so they stay byte-identical for a
real account and an unknown one. real account and an unknown one.
Since E9.5 S1 the two gates are order-independent, so `approved` really can be true while The two gates are order-independent, so `approved` really can be true while
`email_verified` is false, and vice versa — the SPA renders whichever is actually outstanding `email_verified` is false, and vice versa — the SPA renders whichever is actually outstanding
rather than assuming a sequence. rather than assuming a sequence.
""" """
@@ -1145,21 +1145,21 @@ def revoke_preset_password_if_unverified(user: User) -> None:
session it minted; the owner re-sets one via password-reset. No-op for an already-verified account, session it minted; the owner re-sets one via password-reset. No-op for an already-verified account,
so a legit no-SMTP registration is unaffected. so a legit no-SMTP registration is unaffected.
The Google-adoption callback is the ONLY caller (E9.5 S1). Admin approval used to call this too, and The Google-adoption callback is the ONLY caller. Admin approval used to call this too, and
dropping it was right for the reason S1 gave — the revoke destroyed the password a legitimate dropping it was right for the reason given — the revoke destroyed the password a legitimate
registrant had just chosen, and since the admin normally decides before the user reads their mail, registrant had just chosen, and since the admin normally decides before the user reads their mail,
that made the "your password was cleared" detour the DEFAULT path rather than an edge case. that made the "your password was cleared" detour the DEFAULT path rather than an edge case.
S1 also claimed the revoke was REDUNDANT there, because `password_login` refuses an unverified The revoke was also called REDUNDANT there, because `password_login` refuses an unverified
account before it reads `is_active`. That part was wrong, and the epic's own review caught it: the account before it reads `is_active`. That part was wrong, and the epic's own review caught it: the
gate holds only while the address is unverified, and S6's approval mail exists to get the real gate holds only while the address is unverified, and the approval mail exists to get the real
owner to end that state — at which point a pre-emptor's password works. Nothing replaced the owner to end that state — at which point a pre-emptor's password works. Nothing replaced the
revoke, and that is deliberate. The review tried "the newest registration owns an unverified revoke, and that is deliberate. The review tried "the newest registration owns an unverified
account" and reverted it: the rule is symmetric, so an attacker can simply register last, and it account" and reverted it: the rule is symmetric, so an attacker can simply register last, and it
would have handed anonymous callers a write onto a real account's credential. What the owner would have handed anonymous callers a write onto a real account's credential. What the owner
actually has is password-reset, which proves the mailbox, rewrites the hash and bumps the epoch. actually has is password-reset, which proves the mailbox, rewrites the hash and bumps the epoch.
The residual is logged as SEC-16; re-adding a caller here would put "your password was cleared" The residual is logged as SEC-16; re-adding a caller here would put "your password was cleared"
back in front of every legitimate registrant, which is the whole reason S1 removed it. back in front of every legitimate registrant, which is the whole reason it was removed.
`password_revoked_at` is what makes "the owner re-sets one via password-reset" actually true. The `password_revoked_at` is what makes "the owner re-sets one via password-reset" actually true. The
revoke leaves `password_hash IS NULL`, which is also what a Google-only account looks like — and revoke leaves `password_hash IS NULL`, which is also what a Google-only account looks like — and
+4 -4
View File
@@ -67,8 +67,8 @@ def list_channels(user: User = Depends(current_user), db: Session = Depends(get_
# Per-channel aggregates over the shared catalog (channel_aggregates), computed on read rather than # Per-channel aggregates over the shared catalog (channel_aggregates), 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: an Index Only Scan over a ~20 MB index, where it used to read every video's heap page
# (681 MB on prod, a 2–6 s first open once something else had pushed it out of memory, E31 S4d). # (681 MB on prod, a 2–6 s first open once something else had pushed it out of memory).
# (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, 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] = {}
@@ -276,7 +276,7 @@ def _channel_detail_dict(
"explored": explored, "explored": explored,
"blocked": blocked, "blocked": blocked,
"stored_videos": stored, "stored_videos": stored,
# Per-type breakdown (Normal / Shorts / Live) — R14 moved it off the manager table onto the # Per-type breakdown (Normal / Shorts / Live) — moved off the manager table onto the
# channel page. Same aggregation the manager list uses (count_normal = stored − shorts − live). # channel page. Same aggregation the manager list uses (count_normal = stored − shorts − live).
"count_normal": count_normal, "count_normal": count_normal,
"count_short": count_short, "count_short": count_short,
@@ -349,7 +349,7 @@ def channel_detail(
) )
blocked = _is_blocked(db, user, channel_id) blocked = _is_blocked(db, user, channel_id)
# Stored total + the Normal/Shorts/Live split in one grouped pass (same filters as the manager # Stored total + the Normal/Shorts/Live split in one grouped pass (same filters as the manager
# list), so the channel page can show the per-type breakdown R14 moved off the table. # list), so the channel page can show the per-type breakdown that moved off the table.
n_all, n_short, n_live = db.execute( n_all, n_short, n_live = db.execute(
select( select(
func.count(Video.id), func.count(Video.id),
+1 -1
View File
@@ -139,7 +139,7 @@ def test_issue_tracker(
_: User = Depends(admin_user), _: User = Depends(admin_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Check the configured issue tracker (E16): the repository answers with this token, takes """Check the configured issue tracker: the repository answers with this token, takes
issues, and is PRIVATE — the one it must be, since an issue carries its reporter's log.""" issues, and is PRIVATE — the one it must be, since an issue carries its reporter's log."""
try: try:
return {"ok": True, **gitea.check(db)} return {"ok": True, **gitea.check(db)}
+2 -2
View File
@@ -1,4 +1,4 @@
"""Debug sessions (E16): Start → reproduce → Stop, the screenshots, and the evidence under review. """Debug sessions: 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 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 are the user's handle on their OWN sessions only: another user's id 404s (`owned_or_404`), including
@@ -181,7 +181,7 @@ def export_session(
) )
# --- the issue tracker (S4) ----------------------------------------------------------------- # --- the issue tracker ----------------------------------------------------------------------
# Issues a user may open per hour — counted once the tracker is ASKED to open one (an answer that # 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 # 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 # one, or an attempt refused before the tracker was asked, is not counted. The composer sends one at
+4 -4
View File
@@ -1,4 +1,4 @@
"""The browser's log, sent while its user has a raised diagnostics level (E22 S5). """The browser's log, sent while its user has a raised diagnostics level.
The client keeps its log in memory (frontend `lib/log.ts`). Only after the user consented to Részletes 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 or Teljes in Settings → Diagnosztika does it stream that log here, in batches, and each batch lands in
@@ -14,7 +14,7 @@ Nothing from the body is trusted as-is:
modified client would not; modified client would not;
* an event below the consented level is dropped (a Részletes consent does not store what only * 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 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 session the request names counts as consent to ITS level, and only its own events keep
its tag; its tag;
* the timestamp is clamped to the consent window, so a row cannot dodge the retention job by * the timestamp is clamped to the consent window, so a row cannot dodge the retention job by
claiming to come from the future. claiming to come from the future.
@@ -63,7 +63,7 @@ class ClientEvent(BaseModel):
attrs: dict[str, Any] | None = None attrs: dict[str, Any] | None = None
request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9._-]{8,64}$") 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}$") 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 # The debug session the event was captured in; 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. # 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}$") debug_session_id: str | None = Field(default=None, pattern=r"^[0-9a-f]{16}$")
version: str | None = Field(default=None, max_length=64) version: str | None = Field(default=None, max_length=64)
@@ -138,7 +138,7 @@ def ingest_events(
body: ClientEventsIn = Depends(_parsed_events), body: ClientEventsIn = Depends(_parsed_events),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
# A debug session the request names raises the level for its own events (E16): starting one is # A debug session the request names raises the level for its own events: starting one is
# the consent to that level for its duration, whatever the user's own level 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)) session = debugsession.from_header(db, user, request.headers.get(debugsession.HEADER))
level, _ = debugsession.level_for(user, session) level, _ = debugsession.level_for(user, session)
+3 -3
View File
@@ -1,10 +1,10 @@
"""E24 · the Home surface's HTTP shell. """The Home surface's HTTP shell.
Thin by construction: every decision — what counts as in progress, what fits a time budget, which Thin by construction: every decision — what counts as in progress, what fits a time budget, which
tier the hero comes from — lives in `app/discover/`, so the scheduler, a future CLI or a test can ask tier the hero comes from — lives in `app/discover/`, so the scheduler, a future CLI or a test can ask
the same questions without a request. This file validates, gates and serializes. the same questions without a request. This file validates, gates and serializes.
The consolidated `GET /api/discover` mirrors the Plex Discover route's one-payload shape (E5 S4): a The consolidated `GET /api/discover` mirrors the Plex Discover route's one-payload shape: a
landing page that fires four requests to answer one question would spend its whole budget on the landing page that fires four requests to answer one question would spend its whole budget on the
thing it exists to remove. thing it exists to remove.
""" """
@@ -72,7 +72,7 @@ def get_discover(
# never reaches SQL — `hero.pick` uses it only as set membership over an already-bounded candidate # never reaches SQL — `hero.pick` uses it only as set membership over an already-bounded candidate
# pool (the resume rail's `RAIL_LIMIT` rows, the shelf, `SURPRISE_DRAW` surprise draws) — so there is # pool (the resume rail's `RAIL_LIMIT` rows, the shelf, `SURPRISE_DRAW` surprise draws) — so there is
# no scan for a crafted URL to unbound, and the set is built in full before this slice anyway. The # no scan for a crafted URL to unbound, and the set is built in full before this slice anyway. The
# comment that claimed otherwise was corrected in round 2; the request line's own length is what # comment that claimed otherwise was later corrected; the request line's own length is what
# actually bounds a hostile `exclude`. # actually bounds a hostile `exclude`.
MAX_EXCLUDE = 32 MAX_EXCLUDE = 32
+3 -3
View File
@@ -498,7 +498,7 @@ def upload_download(
upload: SpooledUpload = Depends(spool_upload), upload: SpooledUpload = Depends(spool_upload),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Ingest a local file into the caller's library (E7 S5). Any file type is accepted; media """Ingest a local file into the caller's library. Any file type is accepted; media
(video/audio) gets the full function set, a generic file is download-only. The body IS the file (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 (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 to a temp path under the download volume, so what is left here is the non-yt-dlp ingest which
@@ -914,7 +914,7 @@ 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",
# The client names the notice in the reader's language from `data` (E30); the title is the # The client names the notice in the reader's language from `data`; the title is the
# English fallback only. # English fallback only.
data={ data={
"kind": "download_shared", "kind": "download_shared",
@@ -1033,7 +1033,7 @@ def list_links(
class CreateLinkIn(BaseModel): class CreateLinkIn(BaseModel):
password: str = "" password: str = ""
# D1 (E7 S3): a newly shared download is downloadable by default — the common intent is "let the # D1: a newly shared download is downloadable by default — the common intent is "let the
# recipient have the file", not stream-only. Forward-only: this only sets the default for links # recipient have the file", not stream-only. Forward-only: this only sets the default for links
# minted from now on; existing links keep whatever `allow_download` they were created with (the # minted from now on; existing links keep whatever `allow_download` they were created with (the
# column default stays False, so no back-catalogue link silently flips). The owner can still # column default stays False, so no back-catalogue link silently flips). The owner can still
+1 -1
View File
@@ -235,7 +235,7 @@ def get_feed_years(
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Per-year video counts for the year control (item 8 / E8·S2): the quick recent-year chips and the """Per-year video counts for the year control: the quick recent-year chips and the
"more…" per-year-counts dialog. Every filter EXCEPT the publish-date narrowing applies, so picking a "more…" per-year-counts dialog. Every filter EXCEPT the publish-date narrowing applies, so picking a
year re-queries the feed but not this list. Response: {"years": [{"year": <int>, "count": <int>}]}, year re-queries the feed but not this list. Response: {"years": [{"year": <int>, "count": <int>}]},
newest year first.""" newest year first."""
+3 -3
View File
@@ -158,12 +158,12 @@ 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 # The consented diagnostics level in force and when it lapses. The client applies
# it to its own log and streams that log while it is raised. # it to its own log and streams that log while it is raised.
"diagnostics": diagnostics.state(db, user), "diagnostics": diagnostics.state(db, user),
# The live debug session, if one is recording (E16) — a reload resumes it. # The live debug session, if one is recording — a reload resumes it.
"debug_session": _live_session(db, user), "debug_session": _live_session(db, user),
# Where a bug report goes (E16 S4): the tracker's repository, or None — download only. # Where a bug report goes: the tracker's repository, or None — download only.
"issues": gitea.target(db), "issues": gitea.target(db),
} }
+1 -1
View File
@@ -4,7 +4,7 @@ Read endpoints (libraries/browse/show/image) are available to ANY authenticated
(pure-Siftlode included) — this is an access layer to the Plex library WITHOUT requiring a plex.tv (pure-Siftlode included) — this is an access layer to the Plex library WITHOUT requiring a plex.tv
account, and playback is a local file. Admin endpoints test the connection and run the catalog sync. account, and playback is a local file. Admin endpoints test the connection and run the catalog sync.
Split out of the former single routes/plex.py (R12 S1). `router` keeps the same shape the app Split out of the former single routes/plex.py. `router` keeps the same shape the app
wiring expects (`plex_routes.router`), so main.py is unchanged. wiring expects (`plex_routes.router`), so main.py is unchanged.
""" """
+4 -4
View File
@@ -285,7 +285,7 @@ def unified_library(
episodes = [] episodes = []
if tsq is not None and want_shows and field in ("all", "episode"): if tsq is not None and want_shows and field in ("all", "episode"):
ep_st = aliased(PlexState) ep_st = aliased(PlexState)
# In HU an episode is found by its Hungarian title too (E30: the overlay now fills it) — and # In HU an episode is found by its Hungarian title too (the overlay fills it) — and
# still by its English one, like `vector_match` does for the grid. # still by its English one, like `vector_match` does for the grid.
if field == "episode": if field == "episode":
ep_vec = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, "")) ep_vec = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, ""))
@@ -360,8 +360,8 @@ def facets(
# The shows that pass the watch-state scope, asked ONCE. The per-user episode rollup behind it is a # The shows that pass the watch-state scope, asked ONCE. The per-user episode rollup behind it is a
# full pass over the user's episodes (~10 ms on a prod-sized library), and each of the show groups # full pass over the user's episodes (~10 ms on a prod-sized library), and each of the show groups
# (genres, ratings, studios, years, rating max) asked it again: half of a filtered facets call # (genres, ratings, studios, years, rating max) asked it again: half of a filtered facets call.
# (E31 S4e). The same filter on the same rows, so the counts do not change # The same filter on the same rows, so the counts do not change
# (test_plex_facets_grid_parity). # (test_plex_facets_grid_parity).
shows_in_scope: list[int] | None = None shows_in_scope: list[int] | None = None
if show_ids and show in ("watched", "in_progress", "unwatched"): if show_ids and show in ("watched", "in_progress", "unwatched"):
@@ -545,7 +545,7 @@ def facets(
for c in split_csv(f.playable): for c in split_csv(f.playable):
if c in PLAYBACK_CLASSES: if c in PLAYBACK_CLASSES:
playable_counts.setdefault(c, 0) playable_counts.setdefault(c, 0)
# Names in the reader's alphabet: the genres are Hungarian words for a Hungarian reader (E30 S1). # Names in the reader's alphabet: the genres are Hungarian words for a Hungarian reader.
alpha = {w: i for i, w in enumerate(sorted_words(db, [*genre_counts, *studio_counts], lang))} alpha = {w: i for i, w in enumerate(sorted_words(db, [*genre_counts, *studio_counts], lang))}
return { return {
"genres": [ "genres": [
+4 -4
View File
@@ -153,7 +153,7 @@ def show_detail(
all_cards.append(card) all_cards.append(card)
show_roll = rollup.rollup(all_cards) show_roll = rollup.rollup(all_cards)
# Cast + IMDb + tagline are served from the DB row (persisted by the cast backfill, E5 S5b). The one # Cast + IMDb + tagline are served from the DB row (persisted by the cast backfill). The one
# remaining live call is `related` — a Plex recommendation hub we don't mirror — which also carries # remaining live call is `related` — a Plex recommendation hub we don't mirror — which also carries
# the metadata used to lazily fill a not-yet-enriched show. Any live failure just drops `related` and # the metadata used to lazily fill a not-yet-enriched show. Any live failure just drops `related` and
# the lazy fill; the row-served detail below still renders (outage-resilient). # the lazy fill; the row-served detail below still renders (outage-resilient).
@@ -213,7 +213,7 @@ def show_detail(
"first": show_roll["first"], "first": show_roll["first"],
"episode_count": show_roll["episode_count"], "episode_count": show_roll["episode_count"],
"collection_keys": sh.collection_keys or [], "collection_keys": sh.collection_keys or [],
# TMDB enrichment (E5 S2), served from the row. # TMDB enrichment, served from the row.
"tmdb": plex_tmdb.serve_block(db, sh, lang=lang), "tmdb": plex_tmdb.serve_block(db, sh, lang=lang),
}, },
"seasons": [_season_block(se, by_season.get(se.id, [])) for se in seasons], "seasons": [_season_block(se, by_season.get(se.id, [])) for se in seasons],
@@ -353,7 +353,7 @@ _TRACK_TAIL = re.compile(r"\(([^()]+)\)\s*$")
def _track_naming(s: dict) -> dict: def _track_naming(s: dict) -> dict:
"""What the player names a track from (E30). Plex's `displayTitle` is in the SERVER's language """What the player names a track from. Plex's `displayTitle` is in the SERVER's language
("English (AC3 5.1)", "magyar Forced", "中文 (EAC3 Stereo)"), so it is sent only as the fallback ("English (AC3 5.1)", "magyar Forced", "中文 (EAC3 Stereo)"), so it is sent only as the fallback
`label`; the client builds the name in the reader's language from the language tag, the `label`; the client builds the name in the reader's language from the language tag, the
forced / SDH flags and the codec tail. No title and no language → no label (the client numbers it).""" forced / SDH flags and the codec tail. No title and no language → no label (the client numbers it)."""
@@ -538,6 +538,6 @@ def item_detail(
"prev_id": prev_id, "prev_id": prev_id,
"next_id": next_id, "next_id": next_id,
"collections": _collection_strips(db, it, user.id, lang), "collections": _collection_strips(db, it, user.id, lang),
# TMDB enrichment (E5 S2), served from the row — movies only (episodes aren't enriched). # TMDB enrichment, served from the row — movies only (episodes aren't enriched).
"tmdb": plex_tmdb.serve_block(db, it, lang=lang) if it.kind == "movie" else None, "tmdb": plex_tmdb.serve_block(db, it, lang=lang) if it.kind == "movie" else None,
} }
+5 -5
View File
@@ -1,11 +1,11 @@
"""Plex Discover: the rail-surface read-model (E5 S4). """Plex Discover: the rail-surface read-model.
A consolidated payload for the dedicated Discover landing — a stack of horizontal rails: A consolidated payload for the dedicated Discover landing — a stack of horizontal rails:
Continue watching · Recently added · Browse-by-genre tiles · Because you watched X — plus a Continue watching · Recently added · Browse-by-genre tiles · Because you watched X — plus a
separate, reshuffling Surprise-me sample. Serve-from-DB only: the recommendation rail reads the separate, reshuffling Surprise-me sample. Serve-from-DB only: the recommendation rail reads the
recs the S2 TMDB engine already persisted (`PlexItem.recommendations`), never a live TMDB call, and recs the TMDB engine already persisted (`PlexItem.recommendations`), never a live TMDB call, and
every card comes from the same `movie_card`/`show_card` serializers the grid uses (so rails inherit every card comes from the same `movie_card`/`show_card` serializers the grid uses (so rails inherit
S1's `art` + S3's rating/summary for free). The rails carry EN metadata (like the other non-grid their `art` + rating/summary for free). The rails carry EN metadata (like the other non-grid
card paths); the HU overlay coalesces only in the main grid. card paths); the HU overlay coalesces only in the main grid.
The sidebar metadata filters (genre / rating / year / collection / …) narrow EVERY rail too, so the The sidebar metadata filters (genre / rating / year / collection / …) narrow EVERY rail too, so the
@@ -114,8 +114,8 @@ def _recently_added(
else [] else []
) )
# Merge the two type-homogeneous lists by added_at, then serialize each side in one batch so the # Merge the two type-homogeneous lists by added_at, then serialize each side in one batch so the
# per-card state lookups stay batched (no N+1). Same `or NO_TS` key as the two rails below — the # per-card state lookups stay batched (no N+1). Same `or NO_TS` key as the two rails below — a
# E24 review found this one still on the older `(is not None, value)` form, i.e. one rule in two # review found this one still on the older `(is not None, value)` form, i.e. one rule in two
# spellings, with the two comments explaining it differently. # spellings, with the two comments explaining it differently.
tagged = [(m.added_at, "movie", m) for m in movies] + [(s.added_at, "show", s) for s in shows] tagged = [(m.added_at, "movie", m) for m in movies] + [(s.added_at, "show", s) for s in shows]
tagged.sort(key=lambda t: t[0] or rollup.NO_TS, reverse=True) tagged.sort(key=lambda t: t[0] or rollup.NO_TS, reverse=True)
+4 -4
View File
@@ -176,10 +176,10 @@ def person_detail(
people who share a name share a page. Each section is capped at _PERSON_SECTION_MAX. An unknown name people who share a name share a page. Each section is capped at _PERSON_SECTION_MAX. An unknown name
simply returns empty sections. simply returns empty sections.
E5 S6: the first time a page is opened, the person's TMDB bio + "Known for" credits are lazily The first time a page is opened, the person's TMDB bio + "Known for" credits are lazily
fetched (from the S2-linked `tmdb_person_id`) and persisted, then served from the row on every open. fetched (from the linked `tmdb_person_id`) and persisted, then served from the row on every open.
The fetch runs OFF the request path (a background task); the first open serves whatever's in the row The fetch runs OFF the request path (a background task); the first open serves whatever's in the row
with `enriching` set, and the page asks again until the bio lands. E30 S6: the people_enrich job with `enriching` set, and the page asks again until the bio lands. The people_enrich job
fetches everyone ahead of time, so this lazy path is only for a person the job hasn't reached yet. fetches everyone ahead of time, so this lazy path is only for a person the job hasn't reached yet.
""" """
lang = norm_lang(lang) lang = norm_lang(lang)
@@ -187,7 +187,7 @@ def person_detail(
# Lazy one-time bio enrich, scheduled AFTER the response so the TMDB call is never on the serve path. # Lazy one-time bio enrich, scheduled AFTER the response so the TMDB call is never on the serve path.
# Gated by a per-person cooldown so concurrent/rapid opens (or repeated opens while TMDB is degraded) # Gated by a per-person cooldown so concurrent/rapid opens (or repeated opens while TMDB is degraded)
# can't pile background tasks onto the shared threadpool. # can't pile background tasks onto the shared threadpool.
# `enriching` tells the page the bio is on its way (E30 S6): it asks again shortly instead of showing # `enriching` tells the page the bio is on its way: it asks again shortly instead of showing
# an empty page until the next open. True while a fetch is pending, including one an earlier open # an empty page until the next open. True while a fetch is pending, including one an earlier open
# already reserved. # already reserved.
enriching = ( enriching = (
+4 -4
View File
@@ -169,7 +169,7 @@ _PROGRESS_MIN_S = 5
_FINISH_MARGIN_S = 30 _FINISH_MARGIN_S = 30
# S2 write-back throttle: the resume position is now pushed to Plex on the periodic (non-final) # Write-back throttle: the resume position is now pushed to Plex on the periodic (non-final)
# checkpoint too — not only on the final pause/unmount flush — so Plex (the source of truth) stays # checkpoint too — not only on the final pause/unmount flush — so Plex (the source of truth) stays
# current in near-real-time. To keep a single watch from spraying `/:/timeline` calls, a push is # current in near-real-time. To keep a single watch from spraying `/:/timeline` calls, a push is
# rate-limited to at most one per item per user every _PUSH_THROTTLE_S seconds; a `final` checkpoint # rate-limited to at most one per item per user every _PUSH_THROTTLE_S seconds; a `final` checkpoint
@@ -360,7 +360,7 @@ def item_progress(
) -> dict: ) -> dict:
"""Checkpoint the resume position while playing (near the end → mark watched + clear it). """Checkpoint the resume position while playing (near the end → mark watched + clear it).
S2 write-back: the resume position is mirrored to Plex (the source of truth) on the periodic Write-back: the resume position is mirrored to Plex (the source of truth) on the periodic
checkpoint too, not only on the final flush — throttled to ≥_PUSH_THROTTLE_S per item (see checkpoint too, not only on the final flush — throttled to ≥_PUSH_THROTTLE_S per item (see
`_should_push_resume`). `final` (sent on pause / seek / pagehide / unmount) pushes immediately and `_should_push_resume`). `final` (sent on pause / seek / pagehide / unmount) pushes immediately and
resets the throttle window, so Plex sees a settled position the instant the user pauses or seeks.""" resets the throttle window, so Plex sees a settled position the instant the user pauses or seeks."""
@@ -696,7 +696,7 @@ def stream_hls_file(
(viewer, rating_key) and a seek-restart replaces the viewer's registered one, while the DIRECTORY (viewer, rating_key) and a seek-restart replaces the viewer's registered one, while the DIRECTORY
is per (key, viewer, offset, tag) — so resolving by the key alone handed the old player the new is per (key, viewer, offset, tag) — so resolving by the key alone handed the old player the new
session's `seg_12.ts`, produced from a different offset under `-copyts`: wrong session's `seg_12.ts`, produced from a different offset under `-copyts`: wrong
bytes with a 200, which the player has no way to notice (deep-review round 5). A request for a bytes with a 200, which the player has no way to notice. A request for a
session that is no longer the registered one is a 404, and the player reclaims at the playhead. session that is no longer the registered one is a 404, and the player reclaims at the playhead.
That reclaim is also why a request with no live session is no longer auto-started here: the That reclaim is also why a request with no live session is no longer auto-started here: the
@@ -728,7 +728,7 @@ def stream_hls_file(
# `proc` so a session whose ffmpeg has already exited answers at once instead of polling out the # `proc` so a session whose ffmpeg has already exited answers at once instead of polling out the
# whole timeout: this is a SYNC route on the ~40-slot threadpool, holding a pooled DB connection # whole timeout: this is a SYNC route on the ~40-slot threadpool, holding a pooled DB connection
# with it, so every such wait is a worker (and a connection) parked for 20 s — an ordinary seek # with it, so every such wait is a worker (and a connection) parked for 20 s — an ordinary seek
# past the produced region was enough to do it (round 5). # past the produced region was enough to do it.
if not plex_stream.wait_for(path, timeout, proc=s.proc): if not plex_stream.wait_for(path, timeout, proc=s.proc):
raise refused(404, "not_ready") raise refused(404, "not_ready")
try: try:
+1 -1
View File
@@ -296,7 +296,7 @@ def _items_by_rks(db: Session, rks: list) -> list[PlexItem]:
class PlexItemAddBulkIn(PlexItemKeysIn): class PlexItemAddBulkIn(PlexItemKeysIn):
# Where each key goes back (E28 S3e2, the list page's "Undo" of a removal): an index over the # Where each key goes back (the list page's "Undo" of a removal): an index over the
# ORDERED rows, one per key. The two-call shape (append, then send the whole order) leaves the rows # ORDERED rows, one per key. The two-call shape (append, then send the whole order) leaves the rows
# at the end whenever the second call fails, and the order it sends would overwrite anything # at the end whenever the second call fails, and the order it sends would overwrite anything
# another tab changed in between. # another tab changed in between.
+1 -1
View File
@@ -54,7 +54,7 @@ def list_views(
# A feed view's tag pick is read by more than the feed: the autotag job keeps every tag a view names # A feed view's tag pick is read by more than the feed: the autotag job keeps every tag a view names
# (sync/autotag.py). Held here to the shape the feed sends — a list of tag ids, at most this many — so # (sync/autotag.py). Held here to the shape the feed sends — a list of tag ids, at most this many — so
# a hand-made body (`"tags": 5`, or 60k ids) can't break that job for every user (E32 review). # a hand-made body (`"tags": 5`, or 60k ids) can't break that job for every user.
_MAX_VIEW_TAGS = 500 _MAX_VIEW_TAGS = 500
+4 -4
View File
@@ -71,10 +71,10 @@ def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dic
raise refused(400, "invalid_email") raise refused(400, "invalid_email")
if len(password) < PASSWORD_MIN_LEN: if len(password) < PASSWORD_MIN_LEN:
raise refused(400, "weak_password", min=PASSWORD_MIN_LEN) raise refused(400, "weak_password", min=PASSWORD_MIN_LEN)
# Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's # Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and the
# whole point is that two spellings of one Gmail mailbox are one person — matching literally here # whole point of canonical email is that two spellings of one Gmail mailbox are one person —
# would let a second run under the other spelling create the DUPLICATE admin row the epic exists # matching literally here would let a second run under the other spelling create the DUPLICATE
# to prevent. Every other lookup went canonical in S3; this one was outside that diff. # admin row it exists to prevent. Every other lookup was canonical already; this one was missed.
user = db.execute( user = db.execute(
select(User).where(User.email_canonical == canonical_email(email)) select(User).where(User.email_canonical == canonical_email(email))
).scalar_one_or_none() ).scalar_one_or_none()
+1 -1
View File
@@ -37,7 +37,7 @@ def sync_subscriptions(user: User = Depends(current_user), db: Session = Depends
result = import_subscriptions(db, user) result = import_subscriptions(db, user)
except YouTubeError as exc: except YouTubeError as exc:
# The onboarding wizard's first call: an unguarded failure here was a raw 500 and the # The onboarding wizard's first call: an unguarded failure here was a raw 500 and the
# blocking "Server error" dialog, with nothing the user could act on (E9 F1). # blocking "Server error" dialog, with nothing the user could act on.
db.rollback() db.rollback()
raise youtube_refusal(exc) from exc raise youtube_refusal(exc) from exc
result["quota_used_estimate"] = m.used result["quota_used_estimate"] = m.used
+4 -4
View File
@@ -154,7 +154,7 @@ def _audit_run(db, actor_id, job_id, status, summary, result) -> None:
they changed something, so routine no-op polls don't spam the trail. Best-effort.""" they changed something, so routine no-op polls don't spam the trail. Best-effort."""
if actor_id is None and status == "ok" and not _run_changed(result): if actor_id is None and status == "ok" and not _run_changed(result):
return return
# The result as data too (E30): the audit log names it in the reader's language, as the inbox # The result as data too: the audit log names it in the reader's language, as the inbox
# does; `summary` stays the English line of the row's own summary column. # does; `summary` stays the English line of the row's own summary column.
data = _result_data(result) data = _result_data(result)
try: try:
@@ -252,7 +252,7 @@ _PAUSED = {"skipped": "sync paused"}
def _result_data(result) -> dict | None: def _result_data(result) -> dict | None:
"""A job's result as the client names it in the reader's language (E30): the dict itself (counts, """A job's result as the client names it in the reader's language: the dict itself (counts,
a `skipped` / `reason` word, a nested dict per phase), `enrich`'s bare count as one. The one-line a `skipped` / `reason` word, a nested dict per phase), `enrich`'s bare count as one. The one-line
`_summarize` is the English fallback.""" `_summarize` is the English fallback."""
if isinstance(result, dict): if isinstance(result, dict):
@@ -401,7 +401,7 @@ def _tmdb_enrich_job() -> None:
def _people_enrich_job() -> None: def _people_enrich_job() -> None:
# Make the people local (E30 S6): a batch of person bios from TMDB and a batch of portraits resized # Make the people local: a batch of person bios from TMDB and a batch of portraits resized
# through Plex, so a person page and a cast strip never wait on an outside service. # through Plex, so a person page and a cast strip never wait on an outside service.
_job("people_enrich", run_people_enrich) _job("people_enrich", run_people_enrich)
@@ -428,7 +428,7 @@ def _log_gc_job() -> None:
days = sysconfig.get_int(db, "log_retention_days") days = sysconfig.get_int(db, "log_retention_days")
reaped = logsink.gc(db, days) reaped = logsink.gc(db, days)
# Debug sessions (E16) go with the rows they bundle; an open one past its cap is closed. # Debug sessions go with the rows they bundle; an open one past its cap is closed.
closed = debugsession.sweep(db) closed = debugsession.sweep(db)
sessions = debugsession.gc(db, days) sessions = debugsession.gc(db, days)
db.commit() db.commit()
+3 -3
View File
@@ -116,7 +116,7 @@ _TOPIC_SLUGS = {
"Fashion": "Fashion & Beauty", "Fashion": "Fashion & Beauty",
"Physical_attractiveness": "Fashion & Beauty", "Physical_attractiveness": "Fashion & Beauty",
# YouTube links the British page (…/wiki/Humour); the key was "Humor", so no channel ever got Comedy # YouTube links the British page (…/wiki/Humour); the key was "Humor", so no channel ever got Comedy
# from its topic (E30). # from its topic.
"Humour": "Comedy", "Humour": "Comedy",
"Entertainment": "Entertainment", "Entertainment": "Entertainment",
"Film": "Film", "Film": "Film",
@@ -300,11 +300,11 @@ def _cleanup_orphan_system_tags(db: Session) -> int:
"""Delete system tags that no longer link to any channel (e.g. after a mapping change) — but not """Delete system tags that no longer link to any channel (e.g. after a mapping change) — but not
one a saved feed view filters on. Views hold tag IDS, and a deleted tag comes back under a new id one a saved feed view filters on. Views hold tag IDS, and a deleted tag comes back under a new id
when a channel earns it again, so the view's filter would point at nothing; the feed drops an when a channel earns it again, so the view's filter would point at nothing; the feed drops an
unknown id, and the view showed the whole feed under its old name (E32 S2). unknown id, and the view showed the whole feed under its old name.
The views are read defensively, and the kept ids are filtered here rather than in SQL: a view The views are read defensively, and the kept ids are filtered here rather than in SQL: a view
stored before its route checked the shape (or written around it) must not raise for everyone's stored before its route checked the shape (or written around it) must not raise for everyone's
job, nor push a `NOT IN` past the driver's bind-parameter limit (E32 review).""" job, nor push a `NOT IN` past the driver's bind-parameter limit."""
in_views = { in_views = {
t t
for (filters,) in db.execute(select(SavedView.filters).where(SavedView.source == "feed")) for (filters,) in db.execute(select(SavedView.filters).where(SavedView.source == "feed"))
+1 -1
View File
@@ -190,7 +190,7 @@ def _revalidate_rolling(db: Session, yt: YouTubeClient, now: datetime) -> dict:
.where(Video.unavailable_since.is_(None)) .where(Video.unavailable_since.is_(None))
# Not one this run already checked: with fewer available videos than the batch, the # Not one this run already checked: with fewer available videos than the batch, the
# loop used to wrap around and re-fetch the same ones until `checked` reached the # loop used to wrap around and re-fetch the same ones until `checked` reached the
# batch, spending quota on answers it already had (E32 S2). # batch, spending quota on answers it already had.
.where(or_(Video.last_checked_at.is_(None), Video.last_checked_at < now)) .where(or_(Video.last_checked_at.is_(None), Video.last_checked_at < now))
# NULLS FIRST = never-checked first, then least-recently-checked; equivalent to the old # NULLS FIRST = never-checked first, then least-recently-checked; equivalent to the old
# `(last_checked_at IS NULL) DESC, last_checked_at ASC` but in the exact shape of the # `(last_checked_at IS NULL) DESC, last_checked_at ASC` but in the exact shape of the
+3 -3
View File
@@ -168,7 +168,7 @@ def _load_baseline(pl: Playlist) -> tuple[list[str] | None, set[str]]:
def parse_baseline(pl: Playlist) -> list[str] | None: def parse_baseline(pl: Playlist) -> list[str] | None:
"""The last-synced ordered id set, or None when no baseline has been recorded yet (a fresh """The last-synced ordered id set, or None when no baseline has been recorded yet (a fresh
export, or a link created before S7). None makes the first merge union losslessly.""" export, or an older link). None makes the first merge union losslessly."""
return _load_baseline(pl)[0] return _load_baseline(pl)[0]
@@ -209,7 +209,7 @@ def _set_baseline_under_lock(
meanwhile — lock the list and derive `dirty` from what is there, or the edit would read as meanwhile — lock the list and derive `dirty` from what is there, or the edit would read as
synced and never reach YouTube. The name too: the lock re-reads a rename made meanwhile, and a synced and never reach YouTube. The name too: the lock re-reads a rename made meanwhile, and a
baseline taken over THAT name matched it, so the list read clean with YouTube's title still the baseline taken over THAT name matched it, so the list read clean with YouTube's title still the
old one (E28 review round 4).""" old one."""
_lock_or_gone(db, pl) _lock_or_gone(db, pl)
_set_baseline(pl, ids, unavailable, name=name) _set_baseline(pl, ids, unavailable, name=name)
recompute_dirty(db, pl) recompute_dirty(db, pl)
@@ -322,7 +322,7 @@ def user_sync_claim(db: Session, user_id: int) -> AbstractContextManager[bool]:
def _mirror_listing(yt: YouTubeClient, ytid: str, count: object, pl_id: int) -> list[str] | None: def _mirror_listing(yt: YouTubeClient, ytid: str, count: object, pl_id: int) -> list[str] | None:
"""A mirror's video ids from YouTube, or None when there is nothing to believe: the list was """A mirror's video ids from YouTube, or None when there is nothing to believe: the list was
deleted between the pull's listing and this read (the next pull drops the mirror as vanished — deleted between the pull's listing and this read (the next pull drops the mirror as vanished —
not a reason to fail every other list, E32 review round 2), or YouTube listed no items for a not a reason to fail every other list), or YouTube listed no items for a
list it counts as non-empty (`blank_listing`).""" list it counts as non-empty (`blank_listing`)."""
try: try:
ids = list(yt.iter_my_playlist_video_ids(ytid)) ids = list(yt.iter_my_playlist_video_ids(ytid))
+1 -1
View File
@@ -187,7 +187,7 @@ def run_rss_poll(db: Session, channels: list[Channel] | None = None) -> dict[str
# "fix this subscription"; `Connection refused` says "the egress is down". # "fix this subscription"; `Connection refused` says "the egress is down".
# The fallback's count rides along: a red card during an RSS outage should still say # The fallback's count rides along: a red card during an RSS outage should still say
# whether the uploads are arriving another way. # whether the uploads are arriving another way.
# As data too (E31): the audit log and the inbox say it in the reader's language, the first # As data too: the audit log and the inbox say it in the reader's language, the first
# failure as its channel and its reason code (`http_404` vs `transport` is that difference). # failure as its channel and its reason code (`http_404` vs `transport` is that difference).
first_channel, first_reason = first_failure or ("", "error") first_channel, first_reason = first_failure or ("", "error")
raise progress.JobFailed( raise progress.JobFailed(
+2 -2
View File
@@ -123,7 +123,7 @@ def should_prune(fetched_count: int, existing_count: int) -> bool:
def vanished[K, V](existing: dict[K, V], listed: Collection[K]) -> list[V]: def vanished[K, V](existing: dict[K, V], listed: Collection[K]) -> list[V]:
"""The local mirrors whose key the remote listing no longer has — none at all when the listing """The local mirrors whose key the remote listing no longer has — none at all when the listing
is an empty answer that contradicts them (`should_prune`). The YouTube playlist mirrors use it: is an empty answer that contradicts them (`should_prune`). The YouTube playlist mirrors use it:
without it, one blip dropped every mirror (E32 S2).""" without it, one blip dropped every mirror."""
if not should_prune(len(listed), len(existing)): if not should_prune(len(listed), len(existing)):
return [] return []
return [row for key, row in existing.items() if key not in listed] return [row for key, row in existing.items() if key not in listed]
@@ -132,7 +132,7 @@ def vanished[K, V](existing: dict[K, V], listed: Collection[K]) -> list[V]:
def blank_listing(listed: Collection, count: object) -> bool: def blank_listing(listed: Collection, count: object) -> bool:
"""The same rule one level down: no members from a container whose own listing counts some (a """The same rule one level down: no members from a container whose own listing counts some (a
playlist's `itemCount`) is a blip, not a list emptied. Replacing or merging on it deleted every playlist's `itemCount`) is a blip, not a list emptied. Replacing or merging on it deleted every
mirrored item and reset the merge baseline (E32 review).""" mirrored item and reset the merge baseline."""
return not listed and isinstance(count, int) and count > 0 return not listed and isinstance(count, int) and count > 0
+1 -1
View File
@@ -107,7 +107,7 @@ def stub_from_playlist_item(item: dict, channel_id: str) -> dict | None:
def _uploads_page(yt: YouTubeClient, channel_id: str, uploads: str, page_token: str | None) -> dict: def _uploads_page(yt: YouTubeClient, channel_id: str, uploads: str, page_token: str | None) -> dict:
"""One page of the channel's uploads. YouTube answers the uploads list of a channel with no """One page of the channel's uploads. YouTube answers the uploads list of a channel with no
uploads at all as not found: an empty back-catalogue, so an empty last page — not a failure uploads at all as not found: an empty back-catalogue, so an empty last page — not a failure
that left `recent_synced_at` unstamped and charged the channel again on every run (E32 review). that left `recent_synced_at` unstamped and charged the channel again on every run.
Any other failure raises.""" Any other failure raises."""
try: try:
return yt.get_playlist_items(uploads, page_token) return yt.get_playlist_items(uploads, page_token)
+7 -7
View File
@@ -181,7 +181,7 @@ SPECS: tuple[ConfigSpec, ...] = (
# --- Audit log --- # --- Audit log ---
# Days to keep admin audit-log rows before the audit_gc job prunes them (0 = keep forever). # Days to keep admin audit-log rows before the audit_gc job prunes them (0 = keep forever).
ConfigSpec("audit_retention_days", "int", "audit", "audit_retention_days", min=0, max=3650), ConfigSpec("audit_retention_days", "int", "audit", "audit_retention_days", min=0, max=3650),
# --- Diagnostics (E22) --- # --- Diagnostics ---
# Runtime log level for the API process. Applied without a restart — see routes/config.py and # Runtime log level for the API process. Applied without a restart — see routes/config.py and
# main.py's lifespan. # main.py's lifespan.
ConfigSpec("log_level", "choice", "diagnostics", "log_level"), ConfigSpec("log_level", "choice", "diagnostics", "log_level"),
@@ -198,7 +198,7 @@ SPECS: tuple[ConfigSpec, ...] = (
# (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.) # (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.)
ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16), ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16),
ConfigSpec("plex_max_sessions", "int", "plex", "plex_max_sessions", min=1, max=64), ConfigSpec("plex_max_sessions", "int", "plex", "plex_max_sessions", min=1, max=64),
# Transcode re-encode tuning (E6 S1). The libx264 knobs apply only to the software fallback. # Transcode re-encode tuning. The libx264 knobs apply only to the software fallback.
# crf 0–51 (x264 range); max_height 0 = source. # crf 0–51 (x264 range); max_height 0 = source.
ConfigSpec("plex_transcode_hw", "choice", "plex", "plex_transcode_hw"), ConfigSpec("plex_transcode_hw", "choice", "plex", "plex_transcode_hw"),
ConfigSpec("plex_transcode_preset", "choice", "plex", "plex_transcode_preset"), ConfigSpec("plex_transcode_preset", "choice", "plex", "plex_transcode_preset"),
@@ -210,10 +210,10 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec("plex_audio_loudnorm", "bool", "plex", "plex_audio_loudnorm"), ConfigSpec("plex_audio_loudnorm", "bool", "plex", "plex_audio_loudnorm"),
# NOTE: plex_watch_sync_interval_min is a config.py default read by the watch-sync scheduler job # NOTE: plex_watch_sync_interval_min is a config.py default read by the watch-sync scheduler job
# (scheduler.py); it is intentionally NOT a runtime admin knob, so it stays out of SPECS. # (scheduler.py); it is intentionally NOT a runtime admin knob, so it stays out of SPECS.
# --- TMDB enrichment (E5 S2; optional) — the API key is the only secret --- # --- TMDB enrichment (optional) — the API key is the only secret ---
ConfigSpec("tmdb_api_key", "str", "tmdb", "tmdb_api_key", secret=True), ConfigSpec("tmdb_api_key", "str", "tmdb", "tmdb_api_key", secret=True),
ConfigSpec("tmdb_language", "str", "tmdb", "tmdb_language"), ConfigSpec("tmdb_language", "str", "tmdb", "tmdb_language"),
# --- Bug reports (E16): the Gitea issue tracker a debug session's issue goes to --- # --- Bug reports: the Gitea issue tracker a debug session's issue goes to ---
ConfigSpec("issues_gitea_url", "str", "issues", "issues_gitea_url"), ConfigSpec("issues_gitea_url", "str", "issues", "issues_gitea_url"),
ConfigSpec("issues_gitea_repo", "str", "issues", "issues_gitea_repo"), ConfigSpec("issues_gitea_repo", "str", "issues", "issues_gitea_repo"),
ConfigSpec("issues_gitea_token", "str", "issues", "issues_gitea_token", secret=True), ConfigSpec("issues_gitea_token", "str", "issues", "issues_gitea_token", secret=True),
@@ -221,7 +221,7 @@ SPECS: tuple[ConfigSpec, ...] = (
# is a config.py default — both stay out of SPECS, like plex_sync_interval_min.) # is a config.py default — both stay out of SPECS, like plex_sync_interval_min.)
) )
# The admin page's sections (E31, the owner's pick): sixteen groups as tabs wrapped to three rows at 133% # The admin page's sections (the owner's pick): sixteen groups as tabs wrapped to three rows at 133%
# zoom, so they are a vertical list under four headings, in this order. Every group of SPECS sits in # zoom, so they are a vertical list under four headings, in this order. Every group of SPECS sits in
# exactly one section (test-held), so a new group cannot fall off the page. # exactly one section (test-held), so a new group cannot fall off the page.
SECTIONS: tuple[tuple[str, tuple[str, ...]], ...] = ( SECTIONS: tuple[tuple[str, tuple[str, ...]], ...] = (
@@ -275,7 +275,7 @@ def _coerce(s: ConfigSpec, raw: str):
if s.type == "int": if s.type == "int":
# Bounds are checked on READ, not only in `set_value`. Bounds get ADDED to a spec # Bounds are checked on READ, not only in `set_value`. Bounds get ADDED to a spec
# (download_upload_max_bytes gained a `max` when SEC-20 started enforcing the same number at # (download_upload_max_bytes gained a `max` when SEC-20 started enforcing the same number at
# the edge; its `min` went 0 → 1 during E7) and env defaults get LOWERED, and either leaves # the edge; its `min` went 0 → 1) and env defaults get LOWERED, and either leaves
# a stored value that was legal when it was written. Serving it is how one layer ends up # a stored value that was legal when it was written. Serving it is how one layer ends up
# acting on a number another layer refuses, with the admin page showing a value outside the # acting on a number another layer refuses, with the admin page showing a value outside the
# range it renders. # range it renders.
@@ -430,7 +430,7 @@ def describe(db: Session) -> dict:
only whether an override is set and whether an env default exists.""" only whether an override is set and whether an env default exists."""
groups: dict[str, list[dict]] = {} groups: dict[str, list[dict]] = {}
# Every override in one query: a db.get per key was one round trip each (two for a key with no # Every override in one query: a db.get per key was one round trip each (two for a key with no
# row, which the session cannot cache), ~40 ms of the page's ~55 on prod (E31 S4d). # row, which the session cannot cache), ~40 ms of the page's ~55 on prod.
rows = {r.key: r for r in db.scalars(select(SystemConfig))} rows = {r.key: r for r in db.scalars(select(SystemConfig))}
for s in SPECS: for s in SPECS:
row = rows.get(s.key) row = rows.get(s.key)
+1 -1
View File
@@ -57,7 +57,7 @@ def canonical_email(value: str | None) -> str:
# The shortest last word that is prefix-matched. A 1–2 letter prefix matches a large share of the # The shortest last word that is prefix-matched. A 1–2 letter prefix matches a large share of the
# library and ts_rank then scores every match: "m" took ~5–8 s on prod against ~100 ms for a whole # library and ts_rank then scores every match: "m" took ~5–8 s on prod against ~100 ms for a whole
# word (E31 S4e). A shorter last word is matched whole instead ("tv", "4k" still find themselves). # word. A shorter last word is matched whole instead ("tv", "4k" still find themselves).
MIN_PREFIX_LEN = 3 MIN_PREFIX_LEN = 3
+1 -1
View File
@@ -135,7 +135,7 @@ def refusal_reason(exc: YouTubeError, *, playlist: bool = False) -> YouTubeRefus
`playlist`: the action was on ONE playlist the user picked, so a missing playlist is that list, `playlist`: the action was on ONE playlist the user picked, so a missing playlist is that list,
gone (deleted on YouTube) — neither a retry nor a reconnect helps. Anywhere else the playlist gone (deleted on YouTube) — neither a retry nor a reconnect helps. Anywhere else the playlist
is one the user never named (a channel's uploads, one list of a bulk pull), and "this playlist is one the user never named (a channel's uploads, one list of a bulk pull), and "this playlist
no longer exists" would describe nothing on their screen (E32 review round 2).""" no longer exists" would describe nothing on their screen."""
if is_quota_refusal(exc): if is_quota_refusal(exc):
return "quota" return "quota"
if playlist and is_playlist_gone(exc): if playlist and is_playlist_gone(exc):
+1 -1
View File
@@ -73,7 +73,7 @@ def _get_config(client: httpx.Client) -> tuple[str, str]:
The fallback pair is never cached: it used to be, so one failed fetch (or a consent page with no The fallback pair is never cached: it used to be, so one failed fetch (or a consent page with no
keys on it) at the wrong moment pinned every later search to a hard-coded key and version for keys on it) at the wrong moment pinned every later search to a hard-coded key and version for
the life of the process — and when those went stale, search returned nothing, with no error the life of the process — and when those went stale, search returned nothing, with no error
anywhere (E32 S2). Now the next search simply tries the live pair again.""" anywhere. Now the next search simply tries the live pair again."""
global _config global _config
if _config is not None: if _config is not None:
return _config return _config
+2 -2
View File
@@ -1,4 +1,4 @@
# DB-backed pytest lane (R6 S3b): an ephemeral Postgres + the test image, wired together so the # DB-backed pytest lane: an ephemeral Postgres + the test image, wired together so the
# `db` fixture (tests/conftest.py) has a real database. The whole thing is throwaway — the pg data # `db` fixture (tests/conftest.py) has a real database. The whole thing is throwaway — the pg data
# lives in tmpfs (RAM) and nothing is persisted. The pure-logic lane still runs WITHOUT this file # lives in tmpfs (RAM) and nothing is persisted. The pure-logic lane still runs WITHOUT this file
# (plain `docker run … python -m pytest` — DB tests skip when no Postgres is reachable). # (plain `docker run … python -m pytest` — DB tests skip when no Postgres is reachable).
@@ -35,5 +35,5 @@ services:
volumes: volumes:
- ./:/app - ./:/app
working_dir: /app working_dir: /app
# Coverage is on demand, not in the gate (E35): see docs/development.md. # Coverage is on demand, not in the gate: see docs/development.md.
command: ["python", "-m", "pytest"] command: ["python", "-m", "pytest"]
+1 -1
View File
@@ -1,4 +1,4 @@
// The backend type-check lane (E27 S3) — the gate's ninth lane, and the first thing that has ever // The backend type-check lane — the gate's ninth lane, and the first thing that has ever
// type-checked the ~36k lines of `app/`. Read as JSONC: pyright accepts comments here. // type-checked the ~36k lines of `app/`. Read as JSONC: pyright accepts comments here.
// //
// basedpyright, not mypy: measured on this tree with the dependencies actually installed, both land // basedpyright, not mypy: measured on this tree with the dependencies actually installed, both land
+1 -1
View File
@@ -3,7 +3,7 @@
# import it as `app.*`, matching runtime. Most of the suite is pure-logic; the DB-backed lane opts in # import it as `app.*`, matching runtime. Most of the suite is pure-logic; the DB-backed lane opts in
# via the `db` fixture and runs in the compose test image. `-rs` reports skip REASONS so a silently # via the `db` fixture and runs in the compose test image. `-rs` reports skip REASONS so a silently
# skipped lane is visible (and conftest makes a missing DB/ffmpeg a hard error unless # skipped lane is visible (and conftest makes a missing DB/ffmpeg a hard error unless
# SIFTLODE_TEST_REQUIRE_DB=0). `--strict-markers` (E32): a misspelt `@pytest.mark.skipiff` is an error, # SIFTLODE_TEST_REQUIRE_DB=0). `--strict-markers`: a misspelt `@pytest.mark.skipiff` is an error,
# not an unknown marker that quietly does nothing. # not an unknown marker that quietly does nothing.
testpaths = tests testpaths = tests
pythonpath = . pythonpath = .
+1 -1
View File
@@ -4,7 +4,7 @@
# backend lint lane runs on the host (the gate fails when the host's ruff is not this one). httpx2: # backend lint lane runs on the host (the gate fails when the host's ruff is not this one). httpx2:
# Starlette 1.x's TestClient is built on it (`import httpx2 as httpx`) and only falls back to the # Starlette 1.x's TestClient is built on it (`import httpx2 as httpx`) and only falls back to the
# app's own httpx with a deprecation warning on every run. # app's own httpx with a deprecation warning on every run.
# basedpyright: the backend type-check lane (E27 S3). Pinned like everything else here — a type # basedpyright: the backend type-check lane. Pinned like everything else here — a type
# checker whose version floats silently changes what "clean" means, and the ratchet baseline it # checker whose version floats silently changes what "clean" means, and the ratchet baseline it
# writes is keyed to the diagnostics THIS version produces. # writes is keyed to the diagnostics THIS version produces.
# pytest-cov: the on-demand coverage report (docs/development.md) — a number to read, deliberately # pytest-cov: the on-demand coverage report (docs/development.md) — a number to read, deliberately
+2 -2
View File
@@ -2,9 +2,9 @@
# Source of truth is requirements.txt (the floor ranges); regenerate after editing it with: # Source of truth is requirements.txt (the floor ranges); regenerate after editing it with:
# docker run --rm -v "$PWD":/b python:3.12-slim sh -c 'pip install -q -r /b/requirements.txt && pip freeze --exclude pip --exclude setuptools --exclude wheel' | sort # docker run --rm -v "$PWD":/b python:3.12-slim sh -c 'pip install -q -r /b/requirements.txt && pip freeze --exclude pip --exclude setuptools --exclude wheel' | sort
# The prod Dockerfile + Dockerfile.test install THIS file; the gate's pip-audit (scripts/check.sh) # The prod Dockerfile + Dockerfile.test install THIS file; the gate's pip-audit (scripts/check.sh)
# scans it, blocking since E32. A security fix may be ONE pin bumped in place instead of a full # scans it, blocking. A security fix may be ONE pin bumped in place instead of a full
# regeneration, which would move every other package too — then `pip install -r requirements.lock && pip check` must # regeneration, which would move every other package too — then `pip install -r requirements.lock && pip check` must
# pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177, E32). # pass. In place so far: urllib3 2.7.0 -> 2.8.0 (PYSEC-2026-4175/4176/4177).
APScheduler==3.11.3 APScheduler==3.11.3
Authlib==1.7.2 Authlib==1.7.2
Mako==1.4.1 Mako==1.4.1
+2 -2
View File
@@ -19,8 +19,8 @@ argon2-cffi>=23.1,<26
# keep the floor loose and bump it when extraction breaks. # keep the floor loose and bump it when extraction breaks.
yt-dlp[default]>=2025.5.22 yt-dlp[default]>=2025.5.22
# Browser impersonation (`impersonate`) for Facebook downloads made with the user's cookie: some # Browser impersonation (`impersonate`) for Facebook downloads made with the user's cookie: some
# links answer a signed-in plain request with "Cannot parse data" and only parse as Chrome (E11.5, # links answer a signed-in plain request with "Cannot parse data" and only parse as Chrome
# measured). The range is YT-DLP's, not ours — yt-dlp 2026.07.04 imports curl_cffi only in # (measured). The range is YT-DLP's, not ours — yt-dlp 2026.07.04 imports curl_cffi only in
# 0.10.x–0.15.x and silently reports every target "unavailable" outside it. Move it when bumping # 0.10.x–0.15.x and silently reports every target "unavailable" outside it. Move it when bumping
# yt-dlp; `tests/test_facebook_impersonation.py::test_the_locked_curl_cffi_can_impersonate_chrome` # yt-dlp; `tests/test_facebook_impersonation.py::test_the_locked_curl_cffi_can_impersonate_chrome`
# fails the gate if the locked pair can no longer impersonate. # fails the gate if the locked pair can no longer impersonate.
+5 -5
View File
@@ -1,7 +1,7 @@
# Ruff config for the Siftlode backend. Keeps ruff's default rule set (E4/E7/E9 + F = the # Ruff config for the Siftlode backend. Keeps ruff's default rule set (E4/E7/E9 + F = the
# high-signal pyflakes/pycodestyle checks that catch real dead code + bugs), all of it on. E402 was # high-signal pyflakes/pycodestyle checks that catch real dead code + bugs), all of it on. E402 was
# ignored for main.py's logging setup; the ignore had let 11 incidental late imports in elsewhere # ignored for main.py's logging setup; the ignore had let 11 incidental late imports in elsewhere,
# (E32 review), so it is on now and main.py marks each deliberate one (`# noqa: E402 — <why>`). # so it is on now and main.py marks each deliberate one (`# noqa: E402 — <why>`).
# `scripts/e2e_seed.py` edits `sys.path` before its imports, which E402 itself allows. # `scripts/e2e_seed.py` edits `sys.path` before its imports, which E402 itself allows.
# #
# line-length 100 (ruff's default is 88): matches the frontend prettier printWidth so both halves of # line-length 100 (ruff's default is 88): matches the frontend prettier printWidth so both halves of
@@ -14,7 +14,7 @@ line-length = 100
target-version = "py312" target-version = "py312"
[lint] [lint]
# Beyond the default set, the rules that hold a LOG LINE to what E22.5 makes of it: # Beyond the default set, the rules that hold a LOG LINE to what the log pipeline makes of it:
# LOG — stdlib logging misuse (`.warn()`, a logger built by hand, `exc_info` on `.exception`) # LOG — stdlib logging misuse (`.warn()`, a logger built by hand, `exc_info` on `.exception`)
# G — the call's shape: %-style args, never an f-string the level filter cannot skip, and # G — the call's shape: %-style args, never an f-string the level filter cannot skip, and
# never `str(exc)` where `exc_info` would carry the traceback # never `str(exc)` where `exc_info` would carry the traceback
@@ -32,11 +32,11 @@ target-version = "py312"
# The wider rule these two enforce lives in tests/test_swallowed_exceptions.py: a handler that # The wider rule these two enforce lives in tests/test_swallowed_exceptions.py: a handler that
# neither logs nor re-raises says `# silent-ok: <why>`. Ruff sees only the two narrow shapes. # neither logs nor re-raises says `# silent-ok: <why>`. Ruff sees only the two narrow shapes.
# #
# E32 (gate integrity) added three more, each measured before it went on and with every hit fixed: # Gate integrity added three more, each measured before it went on and with every hit fixed:
# RUF100 — a `# noqa` that suppresses nothing. A stale one is a claim with no rule behind it, and # RUF100 — a `# noqa` that suppresses nothing. A stale one is a claim with no rule behind it, and
# it stays armed for the day the line changes (24 were removed). A comment that merely # it stays armed for the day the line changes (24 were removed). A comment that merely
# explains a noqa below it names the rule as `# TRY400: …`, never `# noqa TRY400`. # explains a noqa below it names the rule as `# TRY400: …`, never `# noqa TRY400`.
# PLW1510 — `subprocess.run` without an explicit `check=`. Found the real E32 bug: three ffprobe # PLW1510 — `subprocess.run` without an explicit `check=`. Found a real bug: three ffprobe
# probes read the output of a probe that had FAILED and took it for the file's truth. # probes read the output of a probe that had FAILED and took it for the file's truth.
# B — flake8-bugbear: loop-variable capture, `raise` without `from` inside `except`, a # B — flake8-bugbear: loop-variable capture, `raise` without `from` inside `except`, a
# `zip` that silently truncates (each one is `strict=True` now — the lengths are an # `zip` that silently truncates (each one is `strict=True` now — the lengths are an
+4 -4
View File
@@ -1,4 +1,4 @@
"""Pytest fixtures for the DB-backed integration lane (R6 S3b). """Pytest fixtures for the DB-backed integration lane.
The bulk of the suite is pure-logic and needs no database. This adds an OPT-IN `db` fixture The bulk of the suite is pure-logic and needs no database. This adds an OPT-IN `db` fixture
that hands a test a real session against a throwaway Postgres (the app's own engine, bound to that hands a test a real session against a throwaway Postgres (the app's own engine, bound to
@@ -38,8 +38,8 @@ from app.models import User
_MISSING = object() _MISSING = object()
# A would-be silent skip (no DB, or no ffmpeg) is a hard error unless the run says otherwise — the # A would-be silent skip (no DB, or no ffmpeg) is a hard error unless the run says otherwise — the
# suite must not print green while the DB-backed / ffmpeg tests were quietly not run. ON by default # suite must not print green while the DB-backed / ffmpeg tests were quietly not run. ON by default:
# since E32: as an opt-in only the compose lane set, a hand-run `pytest` skipped ~60 DB tests # as an opt-in only the compose lane set, a hand-run `pytest` skipped ~60 DB tests
# and still passed. `SIFTLODE_TEST_REQUIRE_DB=0` is the explicit "pure-logic only" run. # and still passed. `SIFTLODE_TEST_REQUIRE_DB=0` is the explicit "pure-logic only" run.
_REQUIRE_FULL = os.getenv("SIFTLODE_TEST_REQUIRE_DB", "1") != "0" _REQUIRE_FULL = os.getenv("SIFTLODE_TEST_REQUIRE_DB", "1") != "0"
@@ -117,7 +117,7 @@ def _request_dep(db, unfinished: list[str]):
savepoint released inside the handler commits nothing. The test's own staged rows are flushed before the request starts — they are the savepoint released inside the handler commits nothing. The test's own staged rows are flushed before the request starts — they are the
test's business, not the handler's. A request that raises is rolled back, as production's test's business, not the handler's. A request that raises is rolled back, as production's
`close()` rolls it back: without that, what a failing handler flushed but never committed stayed `close()` rolls it back: without that, what a failing handler flushed but never committed stayed
readable to the rest of the test — the one state production cannot be in (E16: an issue created readable to the rest of the test — the one state production cannot be in (an issue created
on the tracker before a failed upload was kept only by an explicit commit, and nothing saw it on the tracker before a failed upload was kept only by an explicit commit, and nothing saw it
missing).""" missing)."""
+3 -3
View File
@@ -1,4 +1,4 @@
"""E12 · S3 — the route-level halves of SEC-12 and SEC-14. """The route-level halves of SEC-12 and SEC-14.
`test_urlguard.py` pins the address judgement itself; this pins that the ENQUEUE route actually `test_urlguard.py` pins the address judgement itself; this pins that the ENQUEUE route actually
consults it and answers in the structured `{code, reason}` shape the frontend localizes, and that a consults it and answers in the structured `{code, reason}` shape the frontend localizes, and that a
@@ -38,7 +38,7 @@ def test_a_public_url_gets_past_the_guard(db, as_user, make_user, monkeypatch):
) )
# An inline spec, so nothing ELSE refuses it: without one the request stopped at the # An inline spec, so nothing ELSE refuses it: without one the request stopped at the
# profile lookup (400 no_profile) and "not url_blocked" held for a request the guard never # profile lookup (400 no_profile) and "not url_blocked" held for a request the guard never
# had to wave through (E32 S2). # had to wave through.
r = as_user(user).post( r = as_user(user).post(
"/api/downloads", json={"source": "https://example.com/v", "spec": {"format": "best"}} "/api/downloads", json={"source": "https://example.com/v", "spec": {"format": "best"}}
) )
@@ -56,7 +56,7 @@ def test_youtube_sources_never_consult_the_guard(db, as_user, make_user, monkeyp
db.commit() db.commit()
monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom) monkeypatch.setattr(urlguard.socket, "getaddrinfo", boom)
# The job must actually be created: a request refused for any other reason would never reach # The job must actually be created: a request refused for any other reason would never reach
# the guard either, and the test would pass without proving anything (E32 S2). # the guard either, and the test would pass without proving anything.
r = as_user(user).post( r = as_user(user).post(
"/api/downloads", json={"source": "dQw4w9WgXcQ", "spec": {"format": "best"}} "/api/downloads", json={"source": "dQw4w9WgXcQ", "spec": {"format": "best"}}
) )
+1 -1
View File
@@ -1,4 +1,4 @@
"""The admin diagnostics console's query API (E22.6 S1), DB-lane.""" """The admin diagnostics console's query API, DB-lane."""
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
+2 -2
View File
@@ -1,4 +1,4 @@
"""The admin lifeline for a sign-up that is stuck outside (E9.5 S5, roadmap UX-25), DB-lane. """The admin lifeline for a sign-up that is stuck outside, DB-lane.
Approve an address BEFORE the person registers and their sign-up produces no queue row at all — Approve an address BEFORE the person registers and their sign-up produces no queue row at all —
`_decide` only takes a still-pending invite — so the Access-requests screen stayed empty while they `_decide` only takes a still-pending invite — so the Access-requests screen stayed empty while they
@@ -133,7 +133,7 @@ def test_an_unconfirmed_account_is_listed_and_can_be_sent_a_fresh_link(
lambda to, lang, link, **kw: sent.append((to, link)) is None, lambda to, lang, link, **kw: sent.append((to, link)) is None,
) )
admin = make_user(email="admin@example.com", role="admin") admin = make_user(email="admin@example.com", role="admin")
# Active but unconfirmed — a real state since E9.5 S1 made the two gates order-independent. It is # Active but unconfirmed — a real state now that the two gates are order-independent. It is
# not the admin's to decide, but the mail may simply never have arrived, so it is theirs to help # not the admin's to decide, but the mail may simply never have arrived, so it is theirs to help
# with. The approved invite is what puts them on this screen at all: the list is "the address may # with. The approved invite is what puts them on this screen at all: the list is "the address may
# sign in, this account still can't", so an undecided request stays with the queue that owns it. # sign in, this account still can't", so an undecided request stays with the queue that owns it.
+1 -1
View File
@@ -1,4 +1,4 @@
"""E30: an admin action that counts something keeps the count as data in its audit row's `after`, under """An admin action that counts something keeps the count as data in its audit row's `after`, under
the key the audit log names in the reader's language (`scheduler.result.<key>`, frontend the key the audit log names in the reader's language (`scheduler.result.<key>`, frontend
auditColumns.tsx) — not only in the English `summary`. A renamed key would fall back to the raw word. auditColumns.tsx) — not only in the English `summary`. A renamed key would fall back to the raw word.
DB lane (Postgres). DB lane (Postgres).
+9 -9
View File
@@ -168,17 +168,17 @@ def test_reset_confirm_is_single_use_and_kills_existing_sessions(client, make_us
def test_approving_an_unverified_account_keeps_its_password(db, make_user): def test_approving_an_unverified_account_keeps_its_password(db, make_user):
# E9.5 S1, inverting an earlier rule. Approval used to drop a password set while the email was # This inverts an earlier rule. Approval used to drop a password set while the email was
# unverified (SEC-10, account pre-emption). It was dropped because it destroyed the password a # unverified (SEC-10, account pre-emption). It was dropped because it destroyed the password a
# legitimate registrant had just chosen, in what is the NORMAL ordering — the admin decides # legitimate registrant had just chosen, in what is the NORMAL ordering — the admin decides
# before the user reads their mail — making "your password was cleared for security" the default # before the user reads their mail — making "your password was cleared for security" the default
# experience. # experience.
# #
# S1 ALSO argued the revoke was redundant, because password_login refuses an unverified account # It was ALSO argued that the revoke was redundant, because password_login refuses an
# before it reads is_active. The epic's closing review refuted that: the gate holds only while # unverified account before it reads is_active. The epic's closing review refuted that: the gate
# the address is unverified, and S6's approval mail exists to get the real owner to end that # holds only while the address is unverified, and the approval mail exists to get the real
# state. Do not restore that reasoning from this comment — the residual is SEC-16, and round 2 # owner to end that state. Do not restore that reasoning from this comment — the residual is
# of the review tried and reverted a guard for it (see test_review_round.py). # SEC-16, and the same review tried and reverted a guard for it (see test_review_round.py).
from app.routes.admin import _activate_user_by_email from app.routes.admin import _activate_user_by_email
user = make_user( user = make_user(
@@ -245,7 +245,7 @@ def test_verifying_afterwards_lets_the_original_password_in(client, db, make_use
def test_activating_a_verified_account_keeps_its_password(db, make_user): def test_activating_a_verified_account_keeps_its_password(db, make_user):
# The no-SMTP shape: registration marks such an account verified up front, so it was never in # The no-SMTP shape: registration marks such an account verified up front, so it was never in
# scope for the SEC-10 guard even before E9.5 S1. Kept as the other half of the pair. # scope for the SEC-10 guard even back then. Kept as the other half of the pair.
from app.routes.admin import _activate_user_by_email from app.routes.admin import _activate_user_by_email
user = make_user( user = make_user(
@@ -354,7 +354,7 @@ def test_an_admin_can_activate_a_pre_approved_registrant(db, monkeypatch):
db.expire_all() db.expire_all()
assert user.is_active is True assert user.is_active is True
assert user.password_hash is not None # approval never touches the password (E9.5 S1) assert user.password_hash is not None # approval never touches the password
def test_registration_of_an_unknown_email_still_persists_account_and_pending_invite( def test_registration_of_an_unknown_email_still_persists_account_and_pending_invite(
@@ -483,7 +483,7 @@ def test_register_route_is_refused_when_registration_is_closed(client, db, monke
# was a deliberately uniform "ok"/401. `password_revoked_at` is the trace that tells the two apart; # was a deliberately uniform "ok"/401. `password_revoked_at` is the trace that tells the two apart;
# these pin each step of the way back in. # these pin each step of the way back in.
# #
# E9.5 S1 removed the admin-approval caller, so Google adoption is the only path that still produces # The admin-approval caller is gone, so Google adoption is the only path that still produces
# this state — plus every account stripped on prod before that change, which is why the whole recovery # this state — plus every account stripped on prod before that change, which is why the whole recovery
# route below must keep working. These tests manufacture the state through the surviving producer. # route below must keep working. These tests manufacture the state through the surviving producer.
+2 -2
View File
@@ -1,4 +1,4 @@
"""sync/autotag's orphan cleanup (E32 S2: the module was 32% covered). """sync/autotag's orphan cleanup (the module was 32% covered).
A system tag no channel carries any more is deleted — unless a saved feed view filters on it. The A system tag no channel carries any more is deleted — unless a saved feed view filters on it. The
views hold tag ids; a deleted tag returns under a NEW id when a channel earns it again, and the feed views hold tag ids; a deleted tag returns under a NEW id when a channel earns it again, and the feed
@@ -38,7 +38,7 @@ def test_an_orphan_system_tag_goes_but_one_a_saved_view_filters_on_stays(db, mak
def test_a_malformed_saved_view_cannot_break_the_cleanup(db, make_user): def test_a_malformed_saved_view_cannot_break_the_cleanup(db, make_user):
"""A view stored around the route's shape check (or before it) must not raise for everyone's job """A view stored around the route's shape check (or before it) must not raise for everyone's job
— `"tags": 5` raised TypeError — and `True` is not tag id 1 (E32 review).""" — `"tags": 5` raised TypeError — and `True` is not tag id 1."""
user = make_user() user = make_user()
orphan = Tag(user_id=None, name="Cooking", category="topic") orphan = Tag(user_id=None, name="Cooking", category="topic")
db.add(orphan) db.add(orphan)
+9 -9
View File
@@ -209,12 +209,12 @@ def test_the_default_ceiling_clears_the_largest_body_a_default_route_takes():
# its route is not in ROUTE_MAX_BODY_BYTES, this middleware refuses a request the route would have # its route is not in ROUTE_MAX_BODY_BYTES, this middleware refuses a request the route would have
# taken — silently, in production, on a path nobody tested. The point of the list is the third entry. # taken — silently, in production, on a path nobody tested. The point of the list is the third entry.
# #
# BOTH helpers, not just `capped_body`: P1 added `capped_stream_to` as its sibling and the registry # BOTH helpers, not just `capped_body`: `capped_stream_to` was added later and the registry
# did not follow, so the upload route — the one this whole invariant was written for — was the one # did not follow, so the upload route — the one this whole invariant was written for — was the one
# site the drift guard could not see (deep-review round 1). The names come from `_common.py` itself # site the drift guard could not see. The names come from `_common.py` itself
# below, so a THIRD sibling cannot be added without this list noticing either — and a route that # below, so a THIRD sibling cannot be added without this list noticing either — and a route that
# skips the helpers altogether and awaits `request.body()` lands here as `<no ceiling declared>` # skips the helpers altogether and awaits `request.body()` lands here as `<no ceiling declared>`
# rather than vanishing (round 3). # rather than vanishing.
# #
# The module is the path relative to `app/routes/`, so a site under `plex/` is spelled # The module is the path relative to `app/routes/`, so a site under `plex/` is spelled
# `plex/admin.py` and cannot collide with the top-level `admin.py`. # `plex/admin.py` and cannot collide with the top-level `admin.py`.
@@ -255,7 +255,7 @@ def test_the_registry_covers_every_body_reading_helper():
# they are blind to the shape `_common.py` itself is written in. A route spelling `await # they are blind to the shape `_common.py` itself is written in. A route spelling `await
# request.body()` inline declares no ceiling, hands no connection back, and would be invisible to # request.body()` inline declares no ceiling, hands no connection back, and would be invisible to
# all three — reading as "no new sites" rather than as a failure, which is the exact way a drift # all three — reading as "no new sites" rather than as a failure, which is the exact way a drift
# guard dies quietly (deep-review round 3). # guard dies quietly.
RAW_BODY_READS = {"body", "stream", "form", "json"} RAW_BODY_READS = {"body", "stream", "form", "json"}
@@ -476,7 +476,7 @@ def test_the_upload_ceiling_and_the_admin_knob_cannot_drift_apart():
constant. If the knob could be set above the edge, an admin raising it would silently start constant. If the knob could be set above the edge, an admin raising it would silently start
getting 413s from a layer the admin page never mentions. getting 413s from a layer the admin page never mentions.
Since P1 the two count the SAME bytes — the body is the file, with no multipart envelope Now the two count the SAME bytes — the body is the file, with no multipart envelope
wrapped round it — so the old allowance is gone and the edge is the knob's own maximum, wrapped round it — so the old allowance is gone and the edge is the knob's own maximum,
exactly. A gap reappearing means someone re-wrapped the upload in an envelope without saying so. exactly. A gap reappearing means someone re-wrapped the upload in an envelope without saying so.
@@ -509,13 +509,13 @@ def _route_objects(root):
@pytest.mark.parametrize(("method", "template"), sorted(bodylimit.ROUTE_MAX_BODY_BYTES)) @pytest.mark.parametrize(("method", "template"), sorted(bodylimit.ROUTE_MAX_BODY_BYTES))
def test_no_route_with_a_raised_ceiling_declares_a_body_parameter(method, template): def test_no_route_with_a_raised_ceiling_declares_a_body_parameter(method, template):
"""P1's invariant, and the reason this is a test rather than a comment. """The upload fix's invariant, and the reason this is a test rather than a comment.
A raised ceiling is only honest when the route's own cap applies to the bytes AS THEY ARRIVE. A raised ceiling is only honest when the route's own cap applies to the bytes AS THEY ARRIVE.
FastAPI reads and spools a DECLARED body (`= File(...)`, `= Form(...)`, a Pydantic model) FastAPI reads and spools a DECLARED body (`= File(...)`, `= Form(...)`, a Pydantic model)
BEFORE the first dependency resolves — so on such a route this middleware's ceiling is the only BEFORE the first dependency resolves — so on such a route this middleware's ceiling is the only
thing between an anonymous caller and the whole allowance, the auth dependency never gets a say, and thing between an anonymous caller and the whole allowance, the auth dependency never gets a say, and
the route's in-handler cap measures bytes that have already landed. That is exactly what P1 the route's in-handler cap measures bytes that have already landed. That is exactly what a fix
closed on the upload route, which sat in `ROUTE_MAX_BODY_BYTES` with a 500 MiB ceiling and a closed on the upload route, which sat in `ROUTE_MAX_BODY_BYTES` with a 500 MiB ceiling and a
`file: UploadFile = File(...)` body for one release. `file: UploadFile = File(...)` body for one release.
@@ -529,7 +529,7 @@ def test_no_route_with_a_raised_ceiling_declares_a_body_parameter(method, templa
itself consults when it decides to read the body. Measured on the pinned FastAPI: a body declared itself consults when it decides to read the body. Measured on the pinned FastAPI: a body declared
inside a sub-dependency leaves `body_params` EMPTY while the body is still read in full before inside a sub-dependency leaves `body_params` EMPTY while the body is still read in full before
auth, so the first version of this test was green over a reproduction of the very defect it auth, so the first version of this test was green over a reproduction of the very defect it
exists to forbid (deep-review round 1). The upload route's own idiom — a body read from a exists to forbid. The upload route's own idiom — a body read from a
`yield` dependency — is exactly the shape that would have slipped through.""" `yield` dependency — is exactly the shape that would have slipped through."""
from app.main import app from app.main import app
@@ -546,7 +546,7 @@ def test_no_route_with_a_raised_ceiling_declares_a_body_parameter(method, templa
def test_a_stored_override_outside_the_bounds_falls_back_on_READ(db): def test_a_stored_override_outside_the_bounds_falls_back_on_READ(db):
"""`set_value` refuses an out-of-range int, but neither bound has always been what it is now — """`set_value` refuses an out-of-range int, but neither bound has always been what it is now —
the upload knob had no `max` before SEC-20 and its `min` was 0 before E7 — so a value that was the upload knob had no `max` before SEC-20 and its `min` used to be 0 — so a value that was
legal when it was written can be sitting in the database. Served as-is it is exactly the drift legal when it was written can be sitting in the database. Served as-is it is exactly the drift
the test above forbids: the route would accept bytes the edge refuses. the test above forbids: the route would accept bytes the edge refuses.
+1 -1
View File
@@ -1,4 +1,4 @@
"""Two rows of CLAUDE.md's canonical-patterns table, held in code (E35 S4). """Two rows of CLAUDE.md's canonical-patterns table, held in code.
Both had drifted while the table said otherwise: seven `db.query(...)` calls stood beside the Both had drifted while the table said otherwise: seven `db.query(...)` calls stood beside the
`select()` the table names, and four modules carried a private `_now` / `now_utc` helper (nine uses) `select()` the table names, and four modules carried a private `_now` / `now_utc` helper (nine uses)
+2 -2
View File
@@ -1,4 +1,4 @@
"""R14 backend coverage: the channel-detail Normal/Shorts/Live counts (moved off the manager table), """Backend coverage: the channel-detail Normal/Shorts/Live counts (moved off the manager table),
the update_channel -> immediate-backfill wiring, and the interactive the update_channel -> immediate-backfill wiring, and the interactive
"get full history" guard that keeps it from racing the scheduler's cursor or draining the budget.""" "get full history" guard that keeps it from racing the scheduler's cursor or draining the budget."""
@@ -54,7 +54,7 @@ def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch, overr
@pytest.mark.parametrize("hidden", [True, False]) @pytest.mark.parametrize("hidden", [True, False])
def test_channel_detail_surfaces_subscription_hidden_flag(db, monkeypatch, hidden, override_dep): def test_channel_detail_surfaces_subscription_hidden_flag(db, monkeypatch, hidden, override_dep):
# S6: the detail GET must expose the per-subscription `hidden` flag so the channel page can offer # The detail GET must expose the per-subscription `hidden` flag so the channel page can offer
# the same Unhide toggle the manager has (the flag was previously omitted from the detail dict). # the same Unhide toggle the manager has (the flag was previously omitted from the detail dict).
ch = Channel(id="UC_hid", title="Hidden?", published_at=_PUB) ch = Channel(id="UC_hid", title="Hidden?", published_at=_PUB)
db.add(ch) db.add(ch)
@@ -1,4 +1,4 @@
"""The channel manager's sums and the feed sidebar's counts read the index alone (E31 S4d). """The channel manager's sums and the feed sidebar's counts read the index alone.
`GET /api/channels` counts each subscribed channel's videos and sums their durations; `/feed/summary` `GET /api/channels` counts each subscribed channel's videos and sums their durations; `/feed/summary`
and `/feed/years` count the reader's videos per channel and per year under the default filters. They and `/feed/years` count the reader's videos per channel and per year under the default filters. They
+1 -1
View File
@@ -1,4 +1,4 @@
"""DB-backed regression (R8 Part 2): GET /api/channels/{id} returns exactly THIS user's personal """DB-backed regression: GET /api/channels/{id} returns exactly THIS user's personal
tag links for the channel — not another user's, and not the auto/system links (user_id NULL) — tag links for the channel — not another user's, and not the auto/system links (user_id NULL) —
so the channel detail page can reuse the manager's tag editor. Would ship broken if the tag_ids so the channel detail page can reuse the manager's tag editor. Would ship broken if the tag_ids
query ever dropped its user_id filter. query ever dropped its user_id filter.
+2 -2
View File
@@ -1,4 +1,4 @@
"""E30 S3: every A→Z list the server orders is in the reader's alphabet. The database image is Alpine, """Every A→Z list the server orders is in the reader's alphabet. The database image is Alpine,
whose `en_US.utf8` is BYTE order: "Életrajzi" and "Ötödik" sorted after "Zorró" in the feed, the channel whose `en_US.utf8` is BYTE order: "Életrajzi" and "Ötödik" sorted after "Zorró" in the feed, the channel
list, the Plex grid, the genre list. Hungarian puts "Ö" after "O" and "Cs" after "Cu". Needs the DB.""" list, the Plex grid, the genre list. Hungarian puts "Ö" after "O" and "Cs" after "Cu". Needs the DB."""
@@ -209,7 +209,7 @@ def test_channel_discovery_keeps_that_order_after_enriching(db, monkeypatch, ove
assert [c["title"] for c in r.json()] == HU assert [c["title"] for c in r.json()] == HU
# --- The other lists the server puts in the reader's alphabet (E30 review): each call site, once. --- # --- The other lists the server puts in the reader's alphabet: each call site, once. ---
def _others(db, n_offset: int = 0) -> list[User]: def _others(db, n_offset: int = 0) -> list[User]:
@@ -1,4 +1,4 @@
"""E3 S3 (B-core-10): the config/state mutators flush but do NOT self-commit, so the caller owns the """The config/state mutators flush but do NOT self-commit, so the caller owns the
transaction and a change lands together with its audit row. These lock that contract — a rollback transaction and a change lands together with its audit row. These lock that contract — a rollback
before the caller commits discards the change (proving no hidden internal commit), while a same-session before the caller commits discards the change (proving no hidden internal commit), while a same-session
read still sees it (flush visibility). DB lane (`db` fixture) — skipped without Postgres. read still sees it (flush visibility). DB lane (`db` fixture) — skipped without Postgres.
+2 -2
View File
@@ -1,6 +1,6 @@
"""Smoke test proving the DB-backed lane works (R6 S3b): a real commit round-trips, and each """Smoke test proving the DB-backed lane works: a real commit round-trips, and each
test starts from a clean database (TRUNCATE isolation), which is what lets the transaction- test starts from a clean database (TRUNCATE isolation), which is what lets the transaction-
ownership tests (R6 S2) trust what they commit.""" ownership tests trust what they commit."""
from sqlalchemy import func, select from sqlalchemy import func, select
+3 -3
View File
@@ -1,4 +1,4 @@
"""E16 S1 — debug sessions: Start → reproduce → Stop, the tag on every line, the screenshots. """Debug sessions: Start → reproduce → Stop, the tag on every line, the screenshots.
Four parts: Four parts:
* the session's life: one open per user, a second Start joins it, a Stop ends it, the cap ends it * the session's life: one open per user, a second Start joins it, a Stop ends it, the cap ends it
@@ -774,11 +774,11 @@ def test_the_export_is_the_saved_draft_the_evidence_and_the_screenshots(db, make
assert z.read("screenshot-1.png") == _PNG assert z.read("screenshot-1.png") == _PNG
# --- the issue tracker (S4) ----------------------------------------------------------------- # --- the issue tracker ----------------------------------------------------------------------
class FakeGitea: class FakeGitea:
"""Gitea 1.27 as measured (plans/e16 S0): a private repository, one file per asset upload, and """Gitea 1.27 as measured: a private repository, one file per asset upload, and
a way to make one upload fail.""" a way to make one upload fail."""
def __init__(self): def __init__(self):

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