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:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 .
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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,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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}$")
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,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,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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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."""
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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,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
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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
@@ -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 = .
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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,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).
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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,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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user